Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера

в 14:06, , рубрики: .net, game development, Gamedev, networking, udp, Блог компании Pixonic, Программирование, разработка игр, Сетевые технологии

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 1

Привет! На связи Станислав Яблонский, Lead Server Developer из Pixonic.

Когда я только пришел в Pixonic, наши игровые сервера представляли собой приложения на основе Photon Realtime SDK: многофункционального, но весьма тяжелого фреймворка. Решение это, казалось бы, должно было упростить работу с сервером. Так оно и было ― до определенного момента.

Photon Realtime привязывал нас к себе тем, что приходилось использовать его для обмена данными между игроками и сервером, ― а также привязывал к ОС Windows, поскольку может работать только на ней. Это накладывало на нас ограничения как с точки зрения runtime (среды исполнения): нельзя было изменить многие важные настройки виртуальной машины .NET, ― так и операционной системы. Мы привыкли работать с Linux-серверами, а не Windows. Кроме того, они нам обходились дешевле.

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

Короче говоря, решение с Photon Realtime было далеко от оптимального для нас, и давно надо было что-то с этим делать ― но всегда находились более срочные задачи, и до решения проблем с сервером руки банально не доходили.

Так как мне было интересно не только решить проблему, но и лучше разобраться в работе сети, я решил взять инициативу в свои руки и попробовать написать библиотеку самостоятельно. Но, сами понимаете, дома ― дом, на работе ― работа, в результате время на разработку библиотеки находилось только в транспорте. Однако это не помешало довести идею до реализации.

Что из этого вышло ― читайте дальше.

Идеология библиотеки

Поскольку мы занимаемся разработкой онлайн-игр, нам очень важно работать без пауз, поэтому главным требованием к библиотеке стали низкие накладные расходы. Для нас это, прежде всего, низкая нагрузка на сборщик мусора. Чтобы ее добиться, я старался избегать аллокаций, а в случаях, когда этого добиться было сложно или не получалось вовсе, мы делали пулы (для байтовых буферов, состояний соединений, заголовков и т. д.).

Для простоты и удобства поддержки и сборки мы стали использовать только C# и системные сокеты. Кроме того, важно было вписываться в бюджет времени на кадр, ведь данные с сервера должны были приходить вовремя. Поэтому я старался уменьшить время выполнения операций, пусть даже ценой некоторой неоптимальности: то есть, местами стоило заменить быстрые и отчасти более сложные алгоритмы и структуры данных на более простые и предсказуемые. Например, мы не использовали lock-free очереди, так как они создавали нагрузку на сборщик мусора.

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

UDP-фрейм в нашей библиотеке по умолчанию занимает 1200 байт. Пакеты такого размера должны передаваться в современных сетях с достаточно низким риском фрагментации, так как MTU в большинстве современных сетей выше этого значения. В то же время обычно этого объема достаточно, чтобы туда поместились изменения, которые нужно послать игроку после очередного тика (обновления состояния) в игре.

Архитектура

В своей библиотеке мы используем двухслойный сокет:

  • Первый слой отвечает за работу с системными вызовами и обеспечивает более удобное API для следующего уровня;
  • На втором слое идет непосредственно работа с сессией, фрагментация/сборка пакетов, их пересылка и т. п.

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 2

Класс для работы с подключением, в свою очередь, тоже разделен на два уровня:

  • Нижний уровень (SockBase) отвечает за посылку и прием данных по UDP. Он представляет собой тонкую обертку над системным объектом сокета.
  • Верхний уровень (SmartSock) обеспечивает дополнительную функциональность поверх UDP. Разрезание и склеивание пакетов, пересылка не дошедших данных, отброс дубликатов ― все это зона его ответственности.

Нижний уровень делится еще на два класса: BareSock и ThreadSock.

  • BareSock работает в том же потоке, откуда произошел вызов, передавая и принимая данные в неблокирующем режиме.
  • ThreadSock складывает пакеты в очереди и таким образом создает отдельные потоки для отправки и приема данных. При обращении к нему происходит только одна операция: добавления или удаления данных из очереди.

BareSock чаще используется для работы с клиентом, ThreadSock ― с сервером.

Особенности работы

Низкоуровневых сокетов я написал также два вида:

  • Первый ― синхронный однопоточный. В нем мы получаем минимальные накладные расходы по памяти и процессору, но при этом системные вызовы происходят прямо при обращении к сокету. Это минимизирует накладные расходы в целом (не нужно использовать очереди и дополнительные буферы), но сам вызов может занять больше времени, чем взятие элемента из очереди.
  • Второй ― асинхронный с отдельными потоками для чтения и записи. В этом случае мы получаем дополнительные накладные расходы на очередь, синхронизацию и время отправки/приема (в пределах нескольких миллисекунд), так как в момент обращения к сокету тред чтения или записи ставится на паузу.

Мы также пробовали использовать SocketAsyncEventArgs ― пожалуй, самое современное сетевое API в .NET из тех, что я знаю. Но оказалось, для UDP оно, вероятно, не подходит: TCP-стек через него работает нормально, но UDP выдает ошибки о получении странно обрезанных фреймов и даже падения внутри .NET ― как если бы повреждалась память в нативной части виртуальной машины. Примеров работы подобной схемы я не нашел.

Еще одной важной особенностью нашей библиотеки является пониженная потеря данных. У нас сложилось впечатление, что для избавления от дубликатов многие библиотеки отбрасывают старые пакеты с данными, в чем впоследствии мы убедились на собственном опыте. Конечно, такая реализация намного проще, ведь в ее случае достаточно одного счетчика с номером последнего пришедшего фрейма, но нас она не очень-то устраивала. Поэтому в Pixockets для отсеивания дубликатов используется циклический буфер из номеров последних фреймов: вновь пришедшие номера перезаписываются вместо старых, и поиск дубликатов происходит среди последних пришедших фреймов.

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 3

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

Структура пакета данных

Данные в библиотеке передаются в следующем виде:

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 4

В начале пакета идет заголовок:

  • Он начинается с размера пакета, который, в свою очередь, ограничен 64 килобайтами.
  • За размером следует байт с флагами. От их наличия зависит интерпретация остальной части заголовка.
  • Далее ― идентификатор сессии, или соединения.

При наличии соответствующих флагов далее мы получаем:

  • Если установлен флаг с номером пакета по очереди, после идентификатора сессии передается номер пакета.
  • Следом за ним ― также в случае установленного флага ― количество подтверждаемых пакетов и их номера.

В конце заголовка идет информация о фрагменте:

  • идентификатор последовательности фрагментов, который необходим для того, чтобы различать фрагменты разных сообщений;
  • порядковый номер фрагмента;
  • общее количество фрагментов в сообщении.

Информация о фрагменте также требует установки соответствующего флага.

Библиотека написана. Что дальше?

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

В первой версии Pixockets этого не было: клиенту не нужно было звать метод Connect(host, port) ― он просто начинал передавать данные по известному адресу и порту. Тогда сервер вызывал метод Listen(port) и начинал получать данные с определенного адреса. Данные о сессии инициализировались по факту приема/передачи пакета.

Теперь для установления соединения стало необходимо «рукопожатие» ― обмен специально сформированными пакетами, ― а клиент обязан вызвать Connect.

Кроме того, один из моих коллег сделал форк библиотеки, уделив больше внимания сетевой безопасности, а также добавив некоторые фичи, такие как возможность восстановления соединения прямо внутри сокета: так, при переключении между Wi-Fi и 4G соединение теперь восстанавливается автоматически. Но об этом мы еще расскажем позже.

Тестирование

Само собой, для библиотеки мы написали юнит-тесты: ими проверяются все основные способы установления соединения, отправки и приема данных, фрагментация и сборка пакетов, различные аномалии при отправке и получении данных ― такие как дубликация, пропажа, несоответствие порядка отправки и получения. Для первоначальной проверки работоспособности я написал специальные тестовые приложения для интеграционного тестирования: пинг-клиент, пинг-сервер и приложение, синхронизирующее по сети положение, цвет и количество цветных кружков на экране.

После того, как тестовые приложения доказали работоспособность нашей библиотеки, мы приступили к сравнению ее с другими библиотеками: с нашим старым Photon Realtime и с UDP-библиотекой LiteNetLib 0.7.

Мы тестировали упрощенный вариант гейм-сервера, который просто собирает ввод от игроков и отсылает обратно «склеенный» результат. Мы взяли 500 игроков в комнатах по 6 человек, частота обновления 30 раз в секунду.

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 5

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

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

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

В какой-то период клиенты играли одновременно по обоим протоколам, а мы в это время собирали статистику, как у них обстоят дела. По окончании сбора статистики оказалось, что результаты не отличаются от синтетических тестов: нагрузка на сборщик мусора и процессор снизилась, потеря пакетов тоже. Заодно и пинг стал немного ниже. Поэтому следующая версия игры вышла уже полностью на Pixockets без использования Photon Realtime SDK.

Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера - 6

Планы на будущее

Теперь мы хотим внедрить в библиотеку следующие фичи:

  • Упрощенное подключение: сейчас оно работает не совсем оптимально, и после вызова Connect на клиенте нужно вызывать Read до изменения статуса соединения;
  • Явное отключение: на данный момент отключение на другой стороне происходит только по таймеру;
  • Встроенные пинги для поддержания жизнеспособности подключения;
  • Автоматическое определение оптимального размера фрейма (сейчас используется просто константа).

Посмотреть и поучаствовать в дальнейшей разработке Pixockets можно по адресу репозитория

Автор: Pixonic

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js