Pixockets: как мы написали собственную сетевую библиотеку для игрового сервера
Привет! На связи Станислав Яблонский, 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 для следующего уровня;
- На втором слое идет непосредственно работа с сессией, фрагментация/сборка пакетов, их пересылка и т. п.
Класс для работы с подключением, в свою очередь, тоже разделен на два уровня:
- Нижний уровень (SockBase) отвечает за посылку и прием данных по UDP. Он представляет собой тонкую обертку над системным объектом сокета.
- Верхний уровень (SmartSock) обеспечивает дополнительную функциональность поверх UDP. Разрезание и склеивание пакетов, пересылка не дошедших данных, отброс дубликатов ― все это зона его ответственности.
Нижний уровень делится еще на два класса: BareSock и ThreadSock.
- BareSock работает в том же потоке, откуда произошел вызов, передавая и принимая данные в неблокирующем режиме.
- ThreadSock складывает пакеты в очереди и таким образом создает отдельные потоки для отправки и приема данных. При обращении к нему происходит только одна операция: добавления или удаления данных из очереди.
BareSock чаще используется для работы с клиентом, ThreadSock ― с сервером.
Особенности работы
Низкоуровневых сокетов я написал также два вида:
- Первый ― синхронный однопоточный. В нем мы получаем минимальные накладные расходы по памяти и процессору, но при этом системные вызовы происходят прямо при обращении к сокету. Это минимизирует накладные расходы в целом (не нужно использовать очереди и дополнительные буферы), но сам вызов может занять больше времени, чем взятие элемента из очереди.
- Второй ― асинхронный с отдельными потоками для чтения и записи. В этом случае мы получаем дополнительные накладные расходы на очередь, синхронизацию и время отправки/приема (в пределах нескольких миллисекунд), так как в момент обращения к сокету тред чтения или записи ставится на паузу.
Мы также пробовали использовать SocketAsyncEventArgs ― пожалуй, самое современное сетевое API в .NET из тех, что я знаю. Но оказалось, для UDP оно, вероятно, не подходит: TCP-стек через него работает нормально, но UDP выдает ошибки о получении странно обрезанных фреймов и даже падения внутри .NET ― как если бы повреждалась память в нативной части виртуальной машины. Примеров работы подобной схемы я не нашел.
Еще одной важной особенностью нашей библиотеки является пониженная потеря данных. У нас сложилось впечатление, что для избавления от дубликатов многие библиотеки отбрасывают старые пакеты с данными, в чем впоследствии мы убедились на собственном опыте. Конечно, такая реализация намного проще, ведь в ее случае достаточно одного счетчика с номером последнего пришедшего фрейма, но нас она не очень-то устраивала. Поэтому в Pixockets для отсеивания дубликатов используется циклический буфер из номеров последних фреймов: вновь пришедшие номера перезаписываются вместо старых, и поиск дубликатов происходит среди последних пришедших фреймов.
Таким образом, если пакет был послан до текущего фрейма, а пришел после, он все равно дойдет до адресата. Это может сильно выручить, например, в случае интерполяции позиций. В таком случае у нас будет более полная история.
Структура пакета данных
Данные в библиотеке передаются в следующем виде:
В начале пакета идет заголовок:
- Он начинается с размера пакета, который, в свою очередь, ограничен 64 килобайтами.
- За размером следует байт с флагами. От их наличия зависит интерпретация остальной части заголовка.
- Далее ― идентификатор сессии, или соединения.
При наличии соответствующих флагов далее мы получаем:
- Если установлен флаг с номером пакета по очереди, после идентификатора сессии передается номер пакета.
- Следом за ним ― также в случае установленного флага ― количество подтверждаемых пакетов и их номера.
В конце заголовка идет информация о фрагменте:
- идентификатор последовательности фрагментов, который необходим для того, чтобы различать фрагменты разных сообщений;
- порядковый номер фрагмента;
- общее количество фрагментов в сообщении.
Информация о фрагменте также требует установки соответствующего флага.
Библиотека написана. Что дальше?
Для того, чтобы иметь более точную синхронную информацию о соединении, позже мы организовали явное соединение. Это помогло нам ясно осознавать ситуации, когда одна сторона думает, что соединение установлено и не прерывалось, а другая ― что прервалось.
В первой версии Pixockets этого не было: клиенту не нужно было звать метод Connect(host, port) ― он просто начинал передавать данные по известному адресу и порту. Тогда сервер вызывал метод Listen(port) и начинал получать данные с определенного адреса. Данные о сессии инициализировались по факту приема/передачи пакета.
Теперь для установления соединения стало необходимо «рукопожатие» ― обмен специально сформированными пакетами, ― а клиент обязан вызвать Connect.
Кроме того, один из моих коллег сделал форк библиотеки, уделив больше внимания сетевой безопасности, а также добавив некоторые фичи, такие как возможность восстановления соединения прямо внутри сокета: так, при переключении между Wi-Fi и 4G соединение теперь восстанавливается автоматически. Но об этом мы еще расскажем позже.
Тестирование
Само собой, для библиотеки мы написали юнит-тесты: ими проверяются все основные способы установления соединения, отправки и приема данных, фрагментация и сборка пакетов, различные аномалии при отправке и получении данных ― такие как дубликация, пропажа, несоответствие порядка отправки и получения. Для первоначальной проверки работоспособности я написал специальные тестовые приложения для интеграционного тестирования: пинг-клиент, пинг-сервер и приложение, синхронизирующее по сети положение, цвет и количество цветных кружков на экране.
После того, как тестовые приложения доказали работоспособность нашей библиотеки, мы приступили к сравнению ее с другими библиотеками: с нашим старым Photon Realtime и с UDP-библиотекой LiteNetLib 0.7.
Мы тестировали упрощенный вариант гейм-сервера, который просто собирает ввод от игроков и отсылает обратно «склеенный» результат. Мы взяли 500 игроков в комнатах по 6 человек, частота обновления 30 раз в секунду.
Нагрузка на сборщик мусора и потребление процессора оказывались ниже в случае Pixockets, как и процент пропавших пакетов ― видимо, за счет того, что, в отличие от остальных исполнений UDP, мы не игнорируем опоздавшие пакеты.
После того, как мы получили подтверждение преимущества нашего решения в синтетических тестах, следующим шагом было принято обкатать библиотеку на реальном проекте.
На тот момент в выбранном нами проекте клиенты и игровые сервера синхронизировались через Photon Server. Я добавил поддержку Pixockets на клиент и на сервер, сделав возможность управления выбором протокола с матчмейкинг-сервера ― того самого, которому клиенты отправляют запрос на вход в игру.
В какой-то период клиенты играли одновременно по обоим протоколам, а мы в это время собирали статистику, как у них обстоят дела. По окончании сбора статистики оказалось, что результаты не отличаются от синтетических тестов: нагрузка на сборщик мусора и процессор снизилась, потеря пакетов тоже. Заодно и пинг стал немного ниже. Поэтому следующая версия игры вышла уже полностью на Pixockets без использования Photon Realtime SDK.
Планы на будущее
Теперь мы хотим внедрить в библиотеку следующие фичи:
- Упрощенное подключение: сейчас оно работает не совсем оптимально, и после вызова Connect на клиенте нужно вызывать Read до изменения статуса соединения;
- Явное отключение: на данный момент отключение на другой стороне происходит только по таймеру;
- Встроенные пинги для поддержания жизнеспособности подключения;
- Автоматическое определение оптимального размера фрейма (сейчас используется просто константа).
Посмотреть и поучаствовать в дальнейшей разработке Pixockets можно по адресу репозитория