В данной статье речь пойдет о взаимодействии WebSocket сервера и сервера рассчитывающего события в мультиплеерных играх (команды пользователей, игровую физику, алгоритмический искусственный интеллект и т.п.)
Будет затронута тема очередей, асинхронного логирования, параллельного программирования на CPU и использования каналов (сhannel) для взаимодействия между процессами (thread - ветками, нитями) на языке программирования PHP (аналогичный функционал есть в языке GO).
![](https://habrastorage.org/getpro/habr/upload_files/53c/1f2/01e/53c1f201e70424b508c42361d3d793a1.png)
Два сервера в одном
Когда речь идет про приложения на PHP их ассоциируют непосредственно с веб сервером (запрос-ответ и постоянная перекомпиляция php скриптов), а когда туда же добавляются игры - идет ассоциация с браузерными играми, однако в данном проекте мы используем запущенные процессы (демонов) на языке php, а их код компилируется при запуске один раз, которые слушают соединения, принимают и отправляют данные, рассчитывают игровые механики, а игры помимо браузерных поддерживаются на ПК, мобильные устройства (код демонстрационной игры на Unity доступен в GIT).
В предыдущей статье я упомянул что наш игровой сервер способен принимать множество запросов, но отдав результат на него позже (и в большинстве своем - всем игрокам).
Работает это по следующему принципу: после того как WebSocket сервер получил команды от игрока нужно эти команды обработать. Если мы будем делать этот в текущем процессе это будет медленно т.к. расчетам будут постоянно мешать приходящие новые команды игроков которые будут обрабатываться асинхронно - по простому говоря когда мы будем обрабатывать команды (например рассчитывать поиск пути к точке которой игрок желает пройти).
Для этого у нас есть параллельно работающий с Websocket сервером процесс обмен данных с которым осуществляете благодаря библиотеке написанной на С++ - Parallel, функции обмена между этими процессами строятся на базе каналов (аналог Channel в языке программирования GO) на скорости 1.600.000 запросов в секунду из одного процесса в другой (без учета времени на исполнения этих запросов) согласно проводимых мною замерам. Такие цифры достигаются за счет обмена в оперативной памяти.
![](https://habrastorage.org/getpro/habr/upload_files/4f6/292/cc3/4f6292cc3a79c74d8a55e6c57704c909.png)
Подход на рисунке выше дает нам, в том числе некоторое подобие Socket IO в той части, где один пакет данных (с игрового сервер на сторону WebSocket) отправляется всем подключенным к Websocket серверу игрокам, а так же позволяет перезапускать сам игровой сервер(и тем самым обновляя его код) без потери соединений игроков (в одной из статей я рассказал что Socket IO это не прерогатива языка Node JS, а лишь подход) .
Например: мы отправили команду идти вверх на websocket, он передал в игровой сервер, в очередном кадре игрового сервера последний проверил можно ли нам пройти в указанную клетку (с кем мы столкнёмся, будем ли ранены из-за этого и т.п.), после чего добавит в пакет на передачу обратно в websocket новые координаты нашего героя и в конце кадра (цикла) сервера одной командой отправит все назад в websocket (в этот момент игровой сервер блокируется, но как я писал выше это 1.600.000 пакетов в секунду, да и на это время уменьшится время паузы между кадрами сервера - в настоящий момент я экспериментирую на 1000 кадров сервера в секунду, аналог 1000 FPS и Fixed Update
в Unity), а websocket сервер разошлет всем игрокам данные не блокируя игровой сервер (сам он может иметь несколько worker процессов наподобие того как их имеют веб сервера Apache, Nginx, Swoole и т.п. что бы была возможность принимать и отправлять еще больше пакетов от игроков, однако все эти worker должны иметь "подписку" на данные что рассылает игровой сервер).
Что еще можно распараллелить?
![](https://habrastorage.org/getpro/habr/upload_files/dfa/3cf/bd8/dfa3cfbd881149fbd36ba21d86bdc48c.png)
Процессы выше способны работать параллельно на разных ядрах CPU, иметь внутри себя свой загруженный код и обмениваться не только текстовыми данными, но и анонимными функциями (Closure - замыкания). В процессе разработки можно перекладывать расчеты и выполнения части код на другой параллельный процесс, проверять его состояние и ошибки. Один из таких долгих процессов - это логирование которое по результатам моих исследований находится в районе 80.000 записей в секунду запись Hello World. Можно улучшить этот показатель в 3 раза если нам не важна последовательность записей (писать в фаилы асинхронно, например благодаря корутинам библиотеки Swoole), однако при разработке ММО игры это достаточно "узкое горлышко". И нам даже не обязательно иметь 3 ядра ведь так или иначе websocket сервер или игровой сервер будет иметь окошко когда логирование сможет вклиниться в очередь исполнение команд процессором (FIFO) и тем самым у нас появился сервер логирования (в дополнение можно создать сервера для вычисления сложных механик, например поиска пути, но для этого лучше использовать видео карту и программировать на ней - на GPU, но об этом в следующих статьях).
![](https://habrastorage.org/getpro/habr/upload_files/b88/d26/b6b/b88d26b6b920d29320adfd22307c1340.png)
Для справки: максимальное количество одновременно работающих параллельных потоков (thread, нитей) на CPU равно сумме количества логических процессов всех ядер CPU (одно ядро может иметь несколько логических процессов (нитей thread) которое указано в спецификации к процессору).
Однако следует иметь в виду, что помимо вычислений кода приложений нужно оставлять свободным достаточное количество логических процессов для работы самой операционной системы и других работающих приложений.
Популярные очереди
Возможно многим покажется избыточным использование в PHP такой специфический для языка функционал параллельного программирования (что более популярно в программировании на GO) и покажется более практичным использование очередей и даже разделить WebSocket сервер и игровой сервер на разные физические машины.
Когда мы используем очереди мы преследуем 3 основные цели (поправьте если я не прав):
Очередность выполнения команд - очереди позволяют нам вести учет какие команды пришли раньше
Масштабирование - т.е. переложить выполнение части функционала на другое приложение (сервис), которое чаще всего находится на другой физической машине (сюда же добавляется гарантированная доставка в случае сбоя)
Шина обменена данными - создание некого канала обмена данными между процессами на одной машине (когда мы используем ядра процессора), а так и между разными физическими машинами (когда у нас некая масштабированная сервисная архитектура) которые выполняют вычисления
Из популярных систем очередей можно выделить RabbitMQ и Redis Pub-Sub (некоторым можем показаться это странным, но чаще он используется как шина обмена данных между сервисами, например для масштабированного кеше данных из БД и его обновления). Их показатели скорости (perfomance) на рисунках ниже:
![RabbitMQ: https://blog.rabbitmq.com/posts/2022/05/rabbitmq-3.10-performance-improvements/ RabbitMQ: https://blog.rabbitmq.com/posts/2022/05/rabbitmq-3.10-performance-improvements/](https://habrastorage.org/getpro/habr/upload_files/781/ecd/9c4/781ecd9c47750ed69beaa027537d4b2d.png)
![Redis: https://redis.io/docs/management/optimization/benchmarks/ Redis: https://redis.io/docs/management/optimization/benchmarks/](https://habrastorage.org/getpro/habr/upload_files/df7/9a5/557/df79a5557374db3bdf0b8b77f882b4ad.png)
Полагаю можно использовать несколько запущенных экземпляров (instant - "инстант") Redis и RabbitMQ, однако проблема заключается в том что когда речь идет о скорости в среднем +-60.000 запросов в секунду на запись - это 1.6мс и это много по сравнению со скоростями работ игровых механик и команд от игроков (я их называю RPS игрового сервера) скорость которых лежит в районе сотый долей миллисекунды и достигает скорости обработки 1.000.000 и пропускной способностью WebSocket сервера которая может достигать ~2.000.000 обработки запросов в секунду (исключительно на прием и отправку пакетов, без выполнения какой-либо логики т.к. у нас всю логику выполняет другой процесс. Такая скорость достигается так же из-за наличия большого количества worker процессов) согласно публичным тестам.
И если WebSocket сервер благодаря множеству запущенных экземпляров (workers) еще может масштабировать запись в разные instance Redis (например) данные, то игровой сервер одной определенной локации - он один на одну локацию (у него уже нет workers как у websocket сервера) и его дробление (масштабирование) это уже другая локаций и постоянно блокировать его работу на 1.6 мс. при поступлении данных будет являться узким горлышком и снизит показатели игрового сервера до порога меньше этих 60.000 запросов в секунду (т.к. еще есть и игровые механики которые нужно обработать).
Однако признаюсь изначально я работал именно с Redis в качестве шины и в одном из своих старых видео я показал результаты. Как говорят "Опыт - сын ошибок трудных".
Я предполагаю что "узким горлышком" в Redis и RabbitMQ является тот факт что они работают на базе TCP протокола (Redis может работать на сокете если клиент и сервер находятся на одном железе что немного быстрее) и наличие некой базы данных в которую пишется эта очередь как сделано например в очередях Laravel (там очередь пишется в базу данных Mysql).
Эта база нужна, в том числе и для гарантированной доставки в случаях сбоя и может быть завязана на файловом хранилище т.к. диапазон скорости записи очень похож на скорость записи в файлы - 80 000 в секунду (на хорошем железе) о которой я писал выше, хотя могут быть и другие причины) .
Собственная очередь в оперативной памяти
Оперативная память компьютера (ОЗУ, RAM) - самый быстрый способ обмена данных. При разработке непосредственно ММО игр нам в целом не важна гарантированная доставка попавшей в очередь команды игрока (в играх бывают и платежи и операции связанные с риском потерей ценной информации, но такие операции обычно выносят в отдельные сервисы с которыми взаимодействие идет по HTTP и вот там уже используют RabbitMQ и пр.)
В связи с этим мы можем пренебречь гарантированной доставкой ряда пакетов (кроме тех о которых писал выше) в случае падения сервера (например за секунду до падения сервера мы не успеем двинуться на шаг дальше и не продолжим шагать после входа в игру снова после перезагрузки сервера) и отказаться от использования какой-либо базы - вся наша очередь будет обычным массивом (списком) в оперативной памяти игрового сервера, а команды выполнятся по мере достижения времени их выполнения (о паузах между командами и зачем они нужны я писал рассказывая про интерполяцию в предыдущей статье) тем самым очередность исполнения команд у нас имеется (из списка основных целей очереди).
Использование библиотеки Parall и Channel, речь о которых была в начале статьи, поможет нам обмениваться данными между процессами так же в оперативной памяти как шина между процессами (например мы можем передавать из websocket сервера список команд игроков на исполнение игровому серверу, передавать текст логов на запись в сервер логов, передавать координаты для расчета оптимального пути в сервер для расчета поиска пути и т.д.) - это значит что шина обмена данными так же присутствует.
Что же касается масштабирования - данная архитектура предполагает что websocket сервер и игровой сервер определенной локации всегда находятся на железе. Однако локаций много и даже одну локацию можно разбить на много маленьких. Тем самым масштабирование (последнее из списка того что нам дает классическая очередь сообщений) присутствует, хоть достигается и не очередь (отличный пример масштабируемости игровыми локациями можно увидеть в моей статье про бесшовный мир).
Заключение
Документация и примеры игр данного проекта доступны на моем сайте http://my-fantasy.ru (есть бесплатный демо доступ в панель администратора) . Проект является моим стартапом - вы можете следить за выпуском статей подписавшись на мой профиль (пишу только о проекте), а так же делаю ролики в Youtube
Надеюсь что мои исследования и архитектурные идеи помогут людям в разработке их проектов онлайн игр т.к. информации в интернете практически нет (именно как писать свой авторитарный сервер без использования каких либо сервисов типа Photon Engine, Mirrot и т.д.), еще меньше проектов которые в итоге заработали и всего лишь пару статей на русском
Комментарии (8)
peterjohnsons
13.06.2023 08:57Мне на php нужна была очередь, а подключать сторонние библиотеки не было желания. Требования к производительности позволяли использовать просто файлы для очереди с блокировкой на уровне файловой системы. В итоге сделал довольно простую и очевидную очередь на чанках. Добавление пакетов пишет в последний чанк, при его заполнении создает новый файл чанка. Считывание пакета считывает данные из чанка по указателю с его смещением. Как только все данные из чанка считаны, он удаляется, указатель переносится на следующий чанк. В итоге получилось элегантное решение с двумя точками доступа, одна это запись в конец списка, вторая считывания начиная с первого чанка. Добавление и получение из очереди блокируют очередь через flock($handle, LOCK_EX). Не тестировал, но уверен, что это решение более эффективно в плане производительности, чем использование Redis, RabbitMQ и им подобных (если требования позволяют)
webrobot Автор
13.06.2023 08:57+2Решение просто - тут спору нет.
Но работа на запись в фаил это 80 000 запросов в секунду (если процесс держит поток открытый и не делает fclose и fopen постоянно - то больше).
Минус этого подхода - эта очередь работает лишь на том сервере (железе) где у вас фаил. Redis и RabbitMQ полезные тем что они масштабируются (например есть приложение на одном сервере что пишет в очередь, а другое приложение в микро сервисной архитектуре его читает)smarthomeblog
13.06.2023 08:57А как насчет задержки между игровым сервром и Redis/RabbitMQ? Она приемлема? В моем случае отказался от Redis именно из-за задержки.
webrobot Автор
13.06.2023 08:57Она примерно равна 0.02мс. времени на запись (в самом быстром случае) , но я не использую ее в игровом сервере. Redis используется в приложении авторизации по http на одной машине записывая временный ключ в кеш и websocket сервере когда человек устанавливает соединение читая кеш redis в асинхронном режиме что бы на это время не блокировать worker этого websocket сервера (их много , как с веб сервером). В игровом я использую очередь в оперативной памяти о которой писал в статье
peterjohnsons
13.06.2023 08:57Да вы правы, еще задержки будут при удалении считанного чанка. Чем больше размер удаляемого файла, тем больше задержка. На файловой системе ext4 и размере файла 200 гигабайт удаление могло занять секунд 10 и более. Но повторюсь, мои требования позволяли только локальный доступ к очереди и производительность мелких чанков устраивала.
webrobot Автор
13.06.2023 08:57для сравнения передача из потока (условно процесса) A в процесс B делается при скорости 1 600 000 пакетов в секунду и тут это важно тк нужно быстро освобождать процесс что бы он выполнял другую работу (тк пока он передает он ждет и не принимает новые соединения, не отправляет пакеты которые надо отправлять)
WaveCut
Вышла хорошая лабораторная работа с неявной практичностью :)
webrobot Автор
Почему неявной?) Вот игру делаю - все о чем пишу в ней реализовано