Миллисекунда, превратившаяся в неделю

История об ошибке и о том, почему сетевое программирование в реальном времени чертовски сложно.

Пролог

Я руководитель проекта многопользовательского онлайн-шутера CYPHER. Вы управляете агентом на Марсе, взламываете компьютерные терминалы и прокладываете себе путь к победе. Эта история об очень конкретной проблеме, которую я начал замечать около года назад.

Если бы вы спросили меня, над чем еще работали, или какие другие ошибки происходили в то время, я бы не смог вам ответить. Этот остался со мной, и я решил написать об этом.

Сетевое программирование в реальном времени

CYPHER использует UDP, управляемую вводом, серверную полномочную модель сети синхронизации перемещения. Позвольте мне разобрать это. Поскольку CYPHER работает быстро, TCP не может использоваться для работы в сети, потому что нас не могут утомить повторные передачи сообщений и все другие приятные вещи, которые включает в себя TCP, которые плохо работают с общением в реальном времени.

Клиент передает необработанные данные (нажатия кнопок) на сервер, потому что сервер не может доверять ничему другому, что отправляет клиент (например, позиции, скорости и т. д.). CYPHER использует сетевую модель синхронизации движений, потому что это отличный способ управления игровыми объектами, когда вам нужно выполнять такие вещи, как согласование сервера и прогнозирование на стороне клиента. Термин Уполномоченный сервер просто означает, что сервер контролирует каждое действие и может исправлять клиентов, если они ошибаются.

Подводя итог, можно сказать, что сервер имитирует свою версию игры, в то время как все пользователи также запускают свою собственную игру. Клиенты постоянно отправляют то, что делает его локальный игрок, одновременно получая новые пакеты с сервера о статусе всех остальных. Большинство современных игр в реальном времени используют аналогичную модель.

Все просто персики, пока несколько вещей не испортят вечеринку:

Общение не происходит мгновенно.

Для передачи пакетов требуется время, даже если вы предполагаете, что ваша информация движется со скоростью света (это не так). Расстояние, которое может пройти пакет, также не является «по прямой» от местоположения к местоположению, а маршрутизаторы и коммутаторы имеют время обработки. Я уверен, что вы слышали о термине Ping. Пинг — это время, необходимое для передачи данных между хостами.

Например, серверу в Великобритании может потребоваться более 150 миллисекунд, чтобы поговорить с пользователем в США. Эта задержка вполне заметна игрокам, поэтому для сокрытия этого досадного факта необходимо использовать методы программирования (например, прогнозирование клиента).

Медленные/поврежденные/потерянные пакеты и их использование.

Пакеты не всегда доходят до места назначения и не всегда принимаются в том же порядке, в котором были отправлены. Они также могут деформироваться при перемещении по трубам.

Не все компьютеры и сети одинаковы. Компьютеры обрабатывают данные быстрее/медленнее, а их сети имеют разную пропускную способность. Эти факторы необходимо учитывать. Не каждый фрагмент информации об игре или состоянии может быть отправлен каждому клиенту в любое время.

Различия между симуляциями

Поскольку связь имеет время в пути, это приводит к различиям между всеми клиентами и серверами при распределении пакетов. Давайте рассмотрим сценарий этого:

  1. Игрок А стреляет в игрока Б и попадает в него.
  2. На экране игрока B игрок B прыгнул и уклонился от пули.
  3. Игрок C, наблюдавший за ними обоими, ничего не видел, так как отставал.

Каждый игрок находится в своей версии мира относительно всех остальных. Эти сетевые проблемы возникают независимо от того, какую библиотеку или игровой фреймворк вы используете. Конечно, некоторые библиотеки могут обрабатывать их более полно, поэтому конечному разработчику это не нужно, но они все еще существуют.

Таким образом, вы можете сразу увидеть, почему сетевое программирование сложно, и как все быстро усложняется, когда вы начинаете внедрять решения.

Таракан

При игре в сети пользователи сталкивались с синхронизацией серверов примерно каждые 3–5 минут. Что происходит во время согласования серверов? Сервер отправляет пакет, сообщая клиенту, что он не в том месте, и клиент должен привязаться к позиции, указанной сервером.

Агент в игре мгновенно появлялся в новой позиции, отправленной сервером, и игра продолжалась. Очевидно, что привязка может часто происходить с пользователями, у которых плохое соединение (задержка, дрожание и т. д.), потому что они не получают достаточно информации в обычном темпе, чтобы не отставать от остальной части сети.

Привязка не нравится игроку, и появление лагов никогда не должно происходить, если только клиент не сможет догнать сервер. Однако это происходило не только из-за плохих связей; это происходило с игроками с хорошим соединением, и я заметил, что это иногда появлялось в моей локальной среде разработки. Я зарегистрировал это как ошибку в нашем трекере проблем и начал работать над этим.

Призраки

За некоторое время до этого мы внедрили функцию отладки, которую нам нравится называть «Призраки». Когда это включено, сервер отправляет пакеты сетевого объекта на основе обновления за обновлением, а клиент просто рисует именно то, что отправляет сервер:

Вы можете увидеть, как работает прогнозирование на стороне клиента, здесь. Ясно, что представление сервера игрока (призрак) нарисовано за локальным игроком. Это нормально из-за следующего:

  1. Когда пользователь нажимает кнопку, мы немедленно перемещаем агента на клиенте.
  2. Пакет отправляется с вводом на сервер.
  3. Сервер соответственно перемещает игрока.
  4. Сервер отправляет обратно фантомное сообщение.

Отслеживание ошибки

В первый день я просто попытался воспроизвести ошибку в своей среде разработки. Состояния символов агента на сервере и клиенте были правильными. Я начал замечать, что что-то происходит, когда я играл в игру. Позиции игрока и призрака больше не будут одинаковыми после того, как я перестану бегать или летать.

Например, я бегу 10 секунд и останавливаюсь. Призрак сделал бы то же самое, но теперь призрак находился позади игрока. Призрак всегда должен оказаться точно над игроком. Я повторил это, продолжая бегать, и о чудо, сервер щелкнул моего агента.

Исправление сервера будет происходить постоянно с прогнозированием на стороне клиента, если не будет какого-либо допустимого количества ошибок. Так что я включил допустимую ошибку epsilon и снова начал бегать. Призрак будет продолжать удаляться от местного агента все дальше и дальше. Теперь источник проблемы смотрел мне прямо в лицо: сервер и клиент по-разному имитировали движение игрока.

Послойная отладка

Я начал изучать некоторые из анимаций, на которых проблема возникала чаще всего. Несмотря на то, что анимация отрисовывается только на клиенте, логика анимации по-прежнему выполняется на сервере, поскольку на сервере должна быть точная копия игры.

Прошел день, пока я трассировал код анимации. Этот код является общим для сервера и клиента, поэтому пошаговое обновление пока за кадром выявило очень небольшие различия. Еще один день был потрачен на изучение логики столкновений. Возможно, агент на клиенте взаимодействовал с объектами иначе, чем на сервере. Лишних столкновений не происходило, и ничего не казалось из ряда вон выходящим.

Физический код был следующим, когда я изучал, как сетевые объекты моделируются на сервере и клиенте. Я включил подробное ведение журнала, чтобы видеть, как происходят изменения агента по ходу игры. Я запускал небольшое количество, приостанавливал работу сервера и клиента и сравнивал сотни записей о позициях агентов и изменениях состояния всего за несколько секунд. Я привлек к битве своего лучшего разработчика, и он начал помогать мне с отладкой.

Все дело во времени

Мы подтвердили, что фактическое количество сообщений, которые клиент получал от сервера, соответствовало целевому, и что сервер перемещал игрока, как только получал входящее сообщение. Хотя мы знали, что положение сервера агента не поспевает за положением клиента, было полезно увидеть, что в журналах начали появляться несоответствия положения.

Что-то должно было произойти с физикой. Я начал модифицировать наш журнал, чтобы включить больше времени: временную метку сервера, временную метку клиента, а также игровое время клиента и сервера. Вот оно; время между кадрами на сервере и на клиенте очень медленно начинало рассинхронизироваться.

Модель синхронизации движений была проанализирована, когда мы начали изучать возможные проблемы с обработкой нашего прогноза. Там тоже ничего не было не так. Поскольку игровое время отправляется с сервера на клиент достаточно регулярно, я начал изучать возможные проблемы с дизассемблированием сетевых сообщений. Прошла почти неделя, пока мы искали эту ужасно разочаровывающую ошибку, и это становилось серьезной инвестицией времени разработчиков.

Пестицид

Мы проследили проблему до самого ядра приложений. В играх есть так называемый цикл обновления. Он работает все время, пока игра активна, и в большинстве случаев он есть как на сервере, так и на клиенте. Было ясно, что ошибка возникла из-за того, что сервер и клиент не зацикливались с одинаковой скоростью. Кроме того, это было прослежено до двух переменных:

public static readonly double ClientTimeStep = 1d / 30d;

и

public static readonly double ServerTimeStep = .0333d;

Цель обновления CYPHER — 30 итераций в секунду. При представлении этого значения вне дроби это повторяющееся десятичное число. Таким образом, сервер выполнял свой цикл обновления на .0333, а клиент работал на .0333… (повторяясь). Это разница в 0,00003… секунды!

Мало того, числа с плавающей запятой не представлены в памяти точно так же, как значение в вашем коде. Например, если вы поместите .03d в свой редактор кода, он на самом деле будет ближе к .029999999, чем к .03 из-за природы чисел с плавающей запятой. Кроме того, разное оборудование или архитектура машин реализуют плавающие числа по-разному, поэтому код, запущенный на одной машине, может не сработать на другой.

Рефакторинг — вот как это произошло. Цикл обновления — одна из старейших частей кодовой базы, и мы думали, что эта переменная уже используется совместно. Как только мы начали фактически совместно использовать переменную для скорости сервера и клиента, поправки сервера внезапно исчезли.

Эпилог

Невероятно малая разница в скорости цикла вызывала десинхронизацию времени (и, следовательно, положения агента) после тысяч итераций спустя. Одна из основных причин, по которой отладка сетевого кода занимает так много времени, заключается в том, что это происходит из-за множества слоев.

Просто для настройки тестов требуется время, и это не включает в себя какие-либо изменения кода. Верхний уровень необходимо протестировать в первую очередь, потому что он обычно является источником проблемы. Однако в данном случае слой, вызывающий проблему, находился в самом низу.

Я ценю, что вы нашли время, чтобы прочитать это, рекомендую, если вам понравилось, и, пожалуйста, не стесняйтесь комментировать, если вы тоже боролись с ужасной ошибкой.

J