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

Будет затронута тема очередей, асинхронного логирования, параллельного программирования на CPU и использования каналов (сhannel) для взаимодействия между процессами (thread - ветками, нитями) на языке программирования PHP (аналогичный функционал есть в языке GO).

Два сервера в одном

Когда речь идет про приложения на PHP их ассоциируют непосредственно с веб сервером (запрос-ответ и постоянная перекомпиляция php скриптов), а когда туда же добавляются игры - идет ассоциация с браузерными играми, однако в данном проекте мы используем запущенные процессы (демонов) на языке php, а их код компилируется при запуске один раз, которые слушают соединения, принимают и отправляют данные, рассчитывают игровые механики, а игры помимо браузерных поддерживаются на ПК, мобильные устройства (код демонстрационной игры на Unity доступен в GIT).

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

Работает это по следующему принципу: после того как WebSocket сервер получил команды от игрока нужно эти команды обработать. Если мы будем делать этот в текущем процессе это будет медленно т.к. расчетам будут постоянно мешать приходящие новые команды игроков которые будут обрабатываться асинхронно - по простому говоря когда мы будем обрабатывать команды (например рассчитывать поиск пути к точке которой игрок желает пройти).

Для этого у нас есть параллельно работающий с Websocket сервером процесс обмен данных с которым осуществляете благодаря библиотеке написанной на С++ - Parallel, функции обмена между этими процессами строятся на базе каналов (аналог Channel в языке программирования GO) на скорости 1.600.000 запросов в секунду из одного процесса в другой (без учета времени на исполнения этих запросов) согласно проводимых мною замерам. Такие цифры достигаются за счет обмена в оперативной памяти.

Подход на рисунке выше дает нам, в том числе некоторое подобие 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 должны иметь "подписку" на данные что рассылает игровой сервер).

Что еще можно распараллелить?

Процессы выше способны работать параллельно на разных ядрах CPU, иметь внутри себя свой загруженный код и обмениваться не только текстовыми данными, но и анонимными функциями (Closure - замыкания). В процессе разработки можно перекладывать расчеты и выполнения части код на другой параллельный процесс, проверять его состояние и ошибки. Один из таких долгих процессов - это логирование которое по результатам моих исследований находится в районе 80.000 записей в секунду запись Hello World. Можно улучшить этот показатель в 3 раза если нам не важна последовательность записей (писать в фаилы асинхронно, например благодаря корутинам библиотеки Swoole), однако при разработке ММО игры это достаточно "узкое горлышко". И нам даже не обязательно иметь 3 ядра ведь так или иначе websocket сервер или игровой сервер будет иметь окошко когда логирование сможет вклиниться в очередь исполнение команд процессором (FIFO) и тем самым у нас появился сервер логирования (в дополнение можно создать сервера для вычисления сложных механик, например поиска пути, но для этого лучше использовать видео карту и программировать на ней - на GPU, но об этом в следующих статьях).

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

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

Популярные очереди

Возможно многим покажется избыточным использование в PHP такой специфический для языка функционал параллельного программирования (что более популярно в программировании на GO) и покажется более практичным использование очередей и даже разделить WebSocket сервер и игровой сервер на разные физические машины.

Когда мы используем очереди мы преследуем 3 основные цели (поправьте если я не прав):

  1. Очередность выполнения команд - очереди позволяют нам вести учет какие команды пришли раньше

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

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

Из популярных систем очередей можно выделить RabbitMQ и Redis Pub-Sub (некоторым можем показаться это странным, но чаще он используется как шина обмена данных между сервисами, например для масштабированного кеше данных из БД и его обновления). Их показатели скорости (perfomance) на рисунках ниже:

RabbitMQ: https://blog.rabbitmq.com/posts/2022/05/rabbitmq-3.10-performance-improvements/
Redis: https://redis.io/docs/management/optimization/benchmarks/

Полагаю можно использовать несколько запущенных экземпляров (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)


  1. WaveCut
    13.06.2023 08:57

    Вышла хорошая лабораторная работа с неявной практичностью :)


    1. webrobot Автор
      13.06.2023 08:57

      Почему неявной?) Вот игру делаю - все о чем пишу в ней реализовано


  1. peterjohnsons
    13.06.2023 08:57

    Мне на php нужна была очередь, а подключать сторонние библиотеки не было желания. Требования к производительности позволяли использовать просто файлы для очереди с блокировкой на уровне файловой системы. В итоге сделал довольно простую и очевидную очередь на чанках. Добавление пакетов пишет в последний чанк, при его заполнении создает новый файл чанка. Считывание пакета считывает данные из чанка по указателю с его смещением. Как только все данные из чанка считаны, он удаляется, указатель переносится на следующий чанк. В итоге получилось элегантное решение с двумя точками доступа, одна это запись в конец списка, вторая считывания начиная с первого чанка. Добавление и получение из очереди блокируют очередь через flock($handle, LOCK_EX). Не тестировал, но уверен, что это решение более эффективно в плане производительности, чем использование Redis, RabbitMQ и им подобных (если требования позволяют)


    1. webrobot Автор
      13.06.2023 08:57
      +2

      Решение просто - тут спору нет.

      Но работа на запись в фаил это 80 000 запросов в секунду (если процесс держит поток открытый и не делает fclose и fopen постоянно - то больше).

      Минус этого подхода - эта очередь работает лишь на том сервере (железе) где у вас фаил. Redis и RabbitMQ полезные тем что они масштабируются (например есть приложение на одном сервере что пишет в очередь, а другое приложение в микро сервисной архитектуре его читает)


      1. smarthomeblog
        13.06.2023 08:57

        А как насчет задержки между игровым сервром и Redis/RabbitMQ? Она приемлема? В моем случае отказался от Redis именно из-за задержки.


        1. webrobot Автор
          13.06.2023 08:57

          Она примерно равна 0.02мс. времени на запись (в самом быстром случае) , но я не использую ее в игровом сервере. Redis используется в приложении авторизации по http на одной машине записывая временный ключ в кеш и websocket сервере когда человек устанавливает соединение читая кеш redis в асинхронном режиме что бы на это время не блокировать worker этого websocket сервера (их много , как с веб сервером). В игровом я использую очередь в оперативной памяти о которой писал в статье


      1. peterjohnsons
        13.06.2023 08:57

        Да вы правы, еще задержки будут при удалении считанного чанка. Чем больше размер удаляемого файла, тем больше задержка. На файловой системе ext4 и размере файла 200 гигабайт удаление могло занять секунд 10 и более. Но повторюсь, мои требования позволяли только локальный доступ к очереди и производительность мелких чанков устраивала.


    1. webrobot Автор
      13.06.2023 08:57

      для сравнения передача из потока (условно процесса) A в процесс B делается при скорости 1 600 000 пакетов в секунду и тут это важно тк нужно быстро освобождать процесс что бы он выполнял другую работу (тк пока он передает он ждет и не принимает новые соединения, не отправляет пакеты которые надо отправлять)