После выхода PHP7 появилась возможность сравнительно небольшой ценой писать долгоживущие приложения. Для программистов стали доступны такие проекты, как prooph, broadway, tactician, messenger, авторы которых берут на себя решение наиболее частых проблем. Но что если сделать небольшой шаг вперёд, углубившись в вопрос? Попробуем разобрать судьбу ещё одного велосипеда, который позволяет реализовать Publish/Subscribe приложение. Для начала постараемся вкратце рассмотреть современные тенденции в мире PHP, а также поверхностно коснёмся асинхронной работы. Долгое время PHP использовался в основном в схеме работы request/response. С точки зрения разработчиков это довольно удобно, ведь нет необходимости беспокоиться об утечках памяти, следить за подключениями.  Все запросы будут выполнены изолированно друг от друга, занимаемые ресурсы высвобождены, а подключения, например, к базе данных закрыты при завершении процесса.  В качестве примера можно взять обычное CRUD приложение, написанное на базе фреймворка Symfony. Для того, чтобы выполнить чтение из базы данных и вернуть JSON, необходимо выполнить ряд шагов (для экономии места и времени исключим шаги по генерации/выполнению опкодов): Как и в случае с PHP (использование акселераторов), фреймворк активно пользуется кешированием (часть задач не будет выполнена при следующем запросе), а также отложенной инициализацией. Начиная с версии 7.4 станет доступен preload, который позволит дополнительно оптимизировать инициализацию приложения.  Тем не менее, полностью все накладные расходы на инициализацию убрать не получится.  Решение проблемы выглядит довольно простым: если запускать приложение каждый раз слишком дорого, то его надо инициализировать единожды и затем просто передавать ему запросы, контролируя их выполнение. В экосистеме PHP есть такие проекты, как php-pm и RoadRunner. Оба концептуально делают одно и то же: Если какой-либо дочерний процесс умирает, то супервайзер создаёт его вновь и добавляет в пул. Мы сделали из нашего приложения демона с одной единственной целью: убрать накладные расходы на инициализацию, существенно повысив скорость обработки запросов. Это самый безболезненный способ повышения производительности, но не единственный. Взаимодействие с I/O в PHP по умолчанию выполняется в блокирующем режиме. Это означает то, что если мы выполним запрос на обновление информации в таблице, то поток выполнения приостановится в ожидании ответа от базы данных. Чем больше таких вызовов в процессе обработки запроса, тем большее время ресурсы сервера простаивают. Ведь в процессе обработки запроса нам необходимо несколько раз сходить в базу данных, записать что-либо в лог, да и вернуть клиенту результат, в конце концов — тоже блокирующая операция. Код, который не блокирует поток выполнения (не использует блокирующих I/O вызовов, а также функций вроде sleep()), называют асинхронным. Вернёмся к нашему Symfony CRUD приложению. Практически невозможно его заставить работать в асинхронном режиме из-за обилия использования блокирующих функций: вся работа с конфигами, кешами, логированием, рендерингом ответа, взаимодействие с базой данных. Но это всё условности, давайте попробуем выкинуть Symfony и воспользоваться для решения нашей задачи Amp, который предоставляет реализацию Event Loop (включающую ряд биндингов), Promises и Coroutines в качестве вишенки на тортик. Promise — это один из способов организации асинхронного кода. Например, нам необходимо выполнить обращение к какому-либо http ресурсу.  Мы создаём объект запроса и передаём его в транспорт, который возвращает нам Promise, содержащий текущее состояние. Всего возможны три состояния: Каждый Promise имеет один метод (в примере разбирается Promise от Amp) — onResolve(), в который передаётся callback функция с двумя аргументами После того, как мы получили Promise, возникает вопрос: а кто будет следить за его состоянием и сообщит нам об изменении статуса? Для этого используется Event Loop. В сущности, Event Loop — это планировщик, который контролирует выполнение. Как только выполнение задачи будет завершено (не важно, как), будет вызван callable, который мы передали в Promise. Что касается нюансов, то я бы рекомендовал почитать статью от Никиты Попова: Cooperative multitasking using coroutines. Она поможет внести некоторую ясность относительно того, что происходит и причём тут генераторы. Вооружившись новыми знаниями попробуем вернуться к нашей задаче по рендерингу JSON.
Пример обработки входящего http запроса с помощью amphp/http-server.
Как только мы получили запрос, выполняется асинхронное чтение из базы данных (получаем Promise) и по его завершению пользователю будет отдан заветный JSON, сформированный на основании полученных данных. Основное различие в том, что один единственный процесс может обслуживать несколько запросов единовременно за счёт того, что поток выполнения не заблокирован. Клиент получит свой ответ тогда, когда завершится чтение из базы данных, а пока ответа нет, можно заняться обслуживанием следующего запроса. Существует ряд проектов, которые помогают в написании неблокирующего PHP кода. В рамках статьи я не стану полностью разбирать все плюсы и минусы, а постараюсь лишь поверхностно рассмотреть каждый из них. Асинхронный фреймворк, написанный в отличии от остальных на Си и поставляемый в виде расширения к PHP. Обладает, пожалуй, лучшими показателями производительности на текущий момент.  Имеется реализация каналов, корутин и прочих вкусных штук, но есть у него 1 большой минус — документация. Она хоть и отчасти на английском языке, но на мой взгляд не очень детализирована, а сам api не очень очевидный.  Что касается комьюнити, то тоже не всё просто и однозначно. Лично я не знаю ни одного живого человека, кто использует Swoole в бою. Возможно, я поборю свои страхи и мигрирую на него, но это случится не в ближайшее время. К минусам можно добавить ещё и то, что внести свой вклад в проект (с помощью pull request) с какими-либо изменениями тоже затруднительно в случае, если не знаешь Си на должном уровне. Если и проигрывает в скорости своему конкуренту (речь о Swoole), то не сильно ощутимо и разницей в ряде сценариев можно пренебречь.  Имеет интеграцию с ReactPHP, что в свою очередь расширяет количество имплементаций инфраструктурных моментов. Для экономии места, минусы опишу вместе с ReactPHP. К плюсам можно отнести довольно большое комьюнити и огромное количество примеров. Минусы же начинают проявляться в процессе использования — это концепция Promise.
Если необходимо выполнить несколько асинхронных операций, код превращается в бесконечную портянку вызовов then (вот пример простого подключения к RabbiqMQ без создания exchange/queue и их биндингов).  При некоторой доработке напильником (считается нормой), можно получить реализацию корутин, которая поможет избавиться от Promise hell.  Без проекта recoilphp/recoil использовать ReactPHP, на мой взгляд, не представляется возможным в сколько-нибудь вменяемом приложении.  Так же, помимо всего прочего, складывается ощущение, что его разработка очень сильно замедлилась. Не хватает, например, нормальной работы с PostgreSQL. На мой взгляд самый лучший из вариантов, существующих на текущий момент времени.
Помимо привычных Promise, есть реализация Coroutine, что здорово облегчает процесс разработки и код выглядит наиболее привычно для PHP программистов.  Разработчики постоянно дополняют и улучшают проект, с обратной связью так же нет никаких проблем.  К сожалению, при всех плюсах фреймворка, комьюнити относительно небольшое, но при этом есть реализации, например, работы с PostgreSQL, а также всех базовых вещей (файловая система, http клиент, DNS, etc). Мне ещё не совсем понятна судьба проекта ext-async, но ребята идут с ним в ногу. Что из этого получится в 3-й версии, покажет время. Итак, мы немного разобрали теоретическую часть, самое время переходить к практике и набивать шишки. Для начала немного формализуем требования: По списку требований лучше всего подходит простая реализация Publish/Subscribe паттерна.
Чтобы обеспечить распределённое выполнение, в качестве брокера сообщений воспользуемся RabbitMQ. Прототип был написан с помощью ReactPHP, Bunny и DoctrineDBAL.
Внимательный читатель мог заметить, что Dbal использует внутри блокирующие вызовы pdo/mysqli, но на текущем этапе это было не особо важно, так как надо было понимать, что должно получиться в итоге.  Одной из проблем стало отсутствие библиотек для работы с PostgreSQL. Какие-то наброски есть, но этого недостаточно для полноценной работы (об этом ниже). После непродолжительных изысканий ReactPHP был убран в пользу Amp, так как он относительно простой и очень активно развивается. Но при всех плюсах Amp была 1 проблема: драйвера для RabbitMQ у Amp готового нет (Bunny поддерживает только ReactPHP).  В теории, Amp позволяет использовать Promise от конкурента. Казалось бы, всё должно быть просто, но для работы с сокетами в библиотеке используется Event Loop от ReactPHP.
В один момент времени, очевидно, не может быть запущено двух разных Event Loop'ов, поэтому я не мог воспользоваться функцией adapt().  К сожалению, качество кода в bunny оставляло желать лучшего и адекватно подменить одну реализацию на другую не получилось. Что бы не останавливать работу было принято решение немного переписать библиотеку так, чтобы она заработала с Amp и не приводила к блокировке потока выполнения. Данная адаптация выглядела очень страшно, всё время мне за неё было безумно стыдно, но самое главное — она работала. Ну а так как нет ничего более постоянного, чем временное, адаптер остался в ожидании человека, которому не лень будет заниматься реализацией драйвера. И такой человек нашёлся. Проект PHPinnacle, помимо всего прочего, предоставляет реализацию адаптера, заточенного под использование Amp. Вторая особенность работы — взаимодействие с базой данных. В условиях «традиционного» PHP у нас всё просто: есть подключение и все запросы выполняются последовательно.  В случае с асинхронным выполнением, мы должны уметь единовременно выполнять несколько запросов (например, 3 транзакции). Для того, чтобы иметь такую возможность, необходима реализация пула соединений.  Механизм работы довольно простой:  Во-первых, это нам позволяет запускать несколько транзакций в один момент времени, а, во-вторых, ускоряет работу за счёт наличия уже открытых соединений. У Amp есть компонент amphp/postgres. Он берёт на себя работу с подключениями: следит за их количеством, сроком жизни и всё это без блокирования потока выполнения.  К слову сказать, при использовании, например, ReactPHP, это придётся реализовать самостоятельно если вы захотите работать с базой данных. Для эффективной и, самое главное, правильной работы приложения необходимо реализовать что-то на подобии мьютексов. Мы можем выделить 3 сценария их использования: Мьютексы нужны для решения проблем, связанных с race condition. Ведь мы не знаем (и не можем знать) в каком порядке у нас будут выполняться задачи, но тем не менее мы должны обеспечивать целостность данных. Для логирования используется уже ставший стандартом Monolog, но с некоторыми оговорками: мы не можем использовать встроенные хэндлеры, так как они будут приводить к блокировкам.
Для записи в stdOut можно взять amphp/log, либо написать простую отправку сообщений в какой-нибудь Graylog. Так как в один момент времени у нас может обрабатывать множество задач и при записи логов необходимо понимать, в каком контексте пишутся данные. В ходе экспериментов было принято решение сделать trace_id (Distributed tracing). Суть в том, что вся цепочка вызовов должна сопровождаться сквозным идентификатором, который можно отследить. Дополнительно в момент приёма сообщения генерируется package_id, который указывает именно на полученное сообщение. Таким образом при использовании обоих идентификаторов мы можем легко отследить к чему относится конкретная запись. Всё дело в том, что в традиционном PHP все записи у нас попадают в лог в основном в том порядке, в котором они были записаны. В случае с асинхронным выполнением, никакой закономерности в порядке записей нет. Ещё один из нюансов асинхронной разработки — контроль за отключением нашего демона. Если просто убить процесс, то все выполняющиеся задачи не будут завершены, а данные будут потеряны.В обычном подходе такая проблема также есть, но она не столь велика, ведь одновременно выполняется лишь одна задача. Что бы корректно завершить выполнение нам необходимо: Вопреки распространённому мнению, в современном PHP не так уж и просто столкнуться с ситуациями, при которых возникает утечка памяти. Необходимо сделать что-то абсолютно неправильно. Тем не менее, единожды столкнулся и с этим, но из-за банальной невнимательности. Во время реализации heartbeat сделал так, что каждые 40 секунд добавлялся новый таймер для опроса подключения. Не трудно догадаться, что спустя некоторое время использование памяти начинало ползти вверх и довольно быстро. Также, помимо всего прочего, написал простенький watcher, который опционально будет запускаться каждые 10 минут и вызывать gc_collect_cycles() и gc_mem_caches().
Но принудительный запуск сборщика мусора не является чем-то нужным и принципиальным. Для того, чтобы постоянно видеть использование памяти, в логирование был добавлен стандартный MemoryUsageProcessor.  Если возникает мысль о том, что Event Loop чем-то блокируется, это можно также легко проверить: достаточно подключить LoopBlockWatcher. Но необходимо убедиться, что данный наблюдатель не запускается в production окружении. Эта возможность используется исключительно во время разработки. На свет появился очередной велосипед: php-service-bus, который позволяет реализовать Message Based приложение. Попробуем сделать простой демон, который принимает команду и отправляет событие в качестве реакции: Пока сервис запускается, мы можем немного разобраться в том, как устроен пример.  В файле /bin/consumer описана инициализация демона, а также конфигурация запуска.
В директории /src находится 3 файла: Ping выполняет роль команды; Pong: выступает в качестве события; PingService: контейнер, содержащий наши обработчики.
Если остановиться подробнее на PingService, то в нём можно увидеть 2 метода: Любой обработчик содержит минимум 2 аргумента: собственно, само сообщение (всегда идёт первым) и контекст. Контекст содержит в себе методы по работе с входящим сообщением, метод отправки сообщения в шину, а также несколько хэлперов (например, логирование). Наш пример получает команду Ping, при её получении отправляет событие Pong. При получении события записывается лог. Для примера есть файл, запустив который мы отправим сообщение в RabbitMQ: Если подвести некоторые итоги, то php-service-bus позволяет относительно легко реализовать приложение, использующее Message based архитектуру. Ping\Pong, указанный в примере — это, по сути, обычный Hello, world и не несёт в себе никакой ценности.  Чтобы узнать про все возможности, можно обратиться к документации.  Если сама тема будет кому-либо интересна, распишу дополнительно как применяется, например, Saga pattern (Process manager) и какие проблемы можно решить с его помощью. Что касается производительности, то есть сравнение с symfony/messenger.  Оно не очень объективное, так как сравнивается мягкое с тёплым, но прекрасно демонстрирует преимущества неблокирующих вызовов. Software developer