Пример HTTP-сервера на PHP с использованием файберов. Улучшенная версия

от автора

В статье Пример HTTP-сервера на PHP с использованием файберов / Хабр краеугольным камнем организации обработки HTTP-соединений является функция socket_select(), которая имеет значительное ограничение — максимальное значение дескриптора, которое можно добавить в любой из трёх аргументов данной функции составляет 1024. Данный лимит определяется константой FD_SETSIZE, для увеличения которой придётся сконфигурировать системные лимиты и как минимум пересобрать интерпретатор PHP, что нецелесообразно и может создать эксплуатационные проблемы. К тому же, производительность функции select(), обёрткой над которой является функция socket_select(), значительно проседает при ощутимом увеличении значения константы FD_SETSIZE. В данной статье я постараюсь продемонстрировать альтернативу, позволяющую избавить пример из предыдущей статьи от данного ограничения.

Фрагмент man-страницы функции select, предупреждающий о проблеме с дескрипторами:

WARNING: select() can monitor only file descriptors numbers that        are less than FD_SETSIZE (1024)—an unreasonably low limit for many        modern applications—and this limitation will not change.  All        modern applications should instead use poll(2) or epoll(7), which        do not suffer this limitation.

Разработчики Linux и других операционных систем семейства Unix предоставили несколько способов устранения проблем, присущих вызову select(). В Linux самой современной и лишённой недостатков select() альтернативой является вызов epoll(). Чтобы не привязываться конкретно к Linux и в целях обеспечения портируемости создаваемых приложений, была разработана библиотека libevent и ей подобные (libev, libuv), которые под капотом содержат вызовы select, poll, epoll и другие доступные механизмы, в зависимости от того, что поддерживается библиотекой и при этом доступно в среде выполнения программы. Выбор конкретной функции для обеспечения отслеживания состояния сокетов производится такими библиотеками при выполнении кода.

Библиотека libevent довольно популярна, поэтому она была добавлена в PHP в качестве расширения event. Однако, API последнего сильно отличается от использования вызова select(), предлагая использование собственных абстракций для создания HTTP-сервера. Такой подход может показаться более удобным и надёжным, но он может снизить гибкость, т.к. может потребоваться постановка на отслеживание состояния не только дескрипторов сокетов входящих HTTP-соединений, но и сокетов соединения с сервером СУБД или сервером очередей. К счастью, в PHP также есть расширение ev, которое представляет собой интерфейс к библиотеке libev, альтернативной для libevent. Интерфейс последнего расширения позволяет использовать его в качестве удобной замены для вызова socket_select(), без необходимости больших изменений в коде. Ветка event примера содержит все изменения, необходимые для перехода от использования функции socket_select() к использованию расширения ev.

Изменения, которые были произведены в коде примера, для замены вызова socket_select() на функции расширения ev

Основные изменения были произведены в классе CleanCodeMonkey\Fabio\App. Основной цикл сервера теперь заключён в статическом вызове Ev::run() и функция запуска приложения теперь выглядит так:

public function run(): void {     App::debug("HTTP server has been started. Waiting for connections...");     $supportedBackendNames = [];     $supportedBackends = Ev::supportedBackends();     if ($supportedBackends & Ev::BACKEND_SELECT) {         $supportedBackendNames[Ev::BACKEND_SELECT] = 'select';     }     if ($supportedBackends & Ev::BACKEND_POLL) {         $supportedBackendNames[Ev::BACKEND_POLL] = 'poll';     }     if ($supportedBackends & Ev::BACKEND_EPOLL) {         $supportedBackendNames[Ev::BACKEND_EPOLL] = 'epoll';     }     if ($supportedBackends & Ev::BACKEND_KQUEUE) {         $supportedBackendNames[Ev::BACKEND_KQUEUE] = 'kqueue';     }     if ($supportedBackends & Ev::BACKEND_DEVPOLL) {         $supportedBackendNames[Ev::BACKEND_DEVPOLL] = 'devpoll';     }     if ($supportedBackends & Ev::BACKEND_PORT) {         $supportedBackendNames[Ev::BACKEND_PORT] = 'port';     }     App::debug('Supported backends: %s, selected backend: %s', implode(', ', $supportedBackendNames), $supportedBackendNames[Ev::backend()] ?? 'unknown');     Ev::run(); }

Перед переходом к циклу здесь также производится журналирование названия системного бэкенда, который будет использован библиотекой libev (расширением ev).

Для того, чтобы цикл в вызове Ev::run() не завершился по причине отсутствия отслеживаемых сокетов, в конструкторе класса App выполняется создание обработчика новых HTTP-соединений для основного сокета сервера:

private function __construct(FiberedHandlerFactory $factory) {     $this->handlerCollection = new FiberedHandlerCollection($this);     $this->factory = $factory;     pcntl_async_signals(true);     pcntl_signal(SIGINT, [$this, 'interrupt']);     $serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);     if ($serverSocket === false) {         exit('Error creating server socket.');     }     if (!socket_set_nonblock($serverSocket)) {         exit('Error switching socket to the nonblocking mode.');     }     if (!socket_bind($serverSocket, getenv('HTTP_BIND_HOST'), (int)getenv('HTTP_BIND_PORT'))) {         exit('Error binding socket.');     }     if (!socket_listen($serverSocket, SOMAXCONN)) {         exit('Error switching socket to the listening mode.');     }     $this->handlerCollection->push($this->factory->createServerHandler($serverSocket, $this->handlerCollection)); }

На 20 строке данного фрагмента создаётся объект обработчика и передаётся в коллекцию обработчиков, которая и производит подписку на события по сокету:

public function push(FiberedHandler $handler): void {     $this->handlers[$handler->getId()] = $handler;     $this->app->subscribeHandler($handler); }

В функции subscribeHandler() регистрация слушателей событий сокетов производится с помощью класса EvIo расширения ev:

public function subscribeHandler(FiberedHandler $handler): void {     foreach ($handler->getReadSockets() as $socket) {         if (!isset($this->watchers[$handler->getId()])) {             $watcher = new EvIo(socket_export_stream($socket), Ev::READ, [$this, 'handleEvent'], $handler->getId());             $watcher->start();             $this->watchers[$handler->getId()] = $watcher;         }     }     foreach ($handler->getWriteSockets() as $socket) {         if (!isset($this->watchers[$handler->getId()])) {             $watcher = new EvIo(socket_export_stream($socket), Ev::WRITE, [$this, 'handleEvent'], $handler->getId());             $watcher->start();             $this->watchers[$handler->getId()] = $watcher;         }     } }

Конструктор EvIo не принимает объекты класса Socket а ожидает скалярное значение дексриптора, но функция socket_export_stream() поможет с необходимым преобразованием. При создании экземпляра EvIo в конструктор передаётся коллбэк handleEvent(), который будет вызван при наступлении отслеживаемого события, задаваемого вторым аргументом. Для того, чтобы коллбэк handleEvent() смог определить нужный обработчик для вызова при наступлении события, в качестве четвёртого аргумента конструктора EvIo передаётся идентификатор обработчика, который при вызове коллбэка handleEvent() будет передан в него в составе аргумента:

public function handleEvent(EvIo $watcher): void {     if (!is_int($watcher->data)) {         App::error('Handler ID is missing.');          return;     }     $fiberedHandler = $this->handlerCollection->getHandlerById($watcher->data);     if ($fiberedHandler === null) {         App::error("Can't get socket event handler. Handler id: %s", (int)$watcher->data);          return;     }     //уничтожение watcher, теоретически можно переиспользовать в self::subscribeHandler()     $watcher->stop();     unset($this->watchers[$fiberedHandler->getId()]);     App::debug("Count of fibers: %d", $this->handlerCollection->getCount());     $fiberedHandler->resetSockets();     if ($fiberedHandler->isSuspended()) {         $fiberedHandler->resume();         //новая подписка, т.к. в строках выше подписка была удалена         $this->subscribeHandler($fiberedHandler);     }     if (!$fiberedHandler->isStarted()) {         $fiberedHandler->start();         //новая подписка, т.к. в строках выше подписка была удалена         $this->subscribeHandler($fiberedHandler);     }     if ($fiberedHandler->isTerminated()) {         $this->handlerCollection->remove($fiberedHandler);     } }

Как и в предыдущей статье, при обработке события ввода-вывода по идентификатору переданному при подписке определяется необходимый для вызова обработчик, затем, если его внутренний файбер ещё не стартовал — он запускается, если он был приостановлен и отдал управление — возобновляется, если уже завершился — обработчик удаляется из коллекции обработчиков handlerCollection. Если обработчик был запущен или возобновлён, после возврата управления производится новая подписка данного обработчика на события ввода/вывода, если данный обработчик вернёт какие либо сокеты при вызовах getReadSockets() или getWriteSockets(), вызовы которых были приведены выше.

В остальном же код примера особо не изменился, разве что упростилась реализация коллекции обработчиков событий ввода-вывода, т.к. упростилась их идентификация:

declare(strict_types=1);  namespace CleanCodeMonkey\Fabio;  class FiberedHandlerCollection {     /** @var array<int, FiberedHandler> */     private array $handlers = [];      private App $app;      /**      * @param App $app      */     public function __construct(App $app)     {         $this->app = $app;     }      public function push(FiberedHandler $handler): void     {         $this->handlers[$handler->getId()] = $handler;         $this->app->subscribeHandler($handler);     }      public function remove(FiberedHandler $handler): void     {         $id = $handler->getId();          if (isset($this->handlers[$id])) {             unset($this->handlers[$id]);         }     }      public function getCount(): int     {         return count($this->handlers);     }      public function getHandlerById(int $handlerId): ?FiberedHandler     {         return $this->handlers[$handlerId] ?? null;     }      /**      * @return array<int, FiberedHandler>      */     public function getHandlers(): array     {         return $this->handlers;     } }

Улучшения по отзывам

В комментариях к предыдущей статье внимательным пользователем было справедливо замечено, что чтение HTTP-заголовков из сокета по 1 байту за вызов socket_read() — не лучшая идея. Не могу не согласиться с этим, а потому переделал чтение HTTP-запроса. Теперь код пытается сразу прочитать все заголовки используя предел в 1024 байта. Если они будут больше — будет пытаться прочитать ещё 1024 байта, если нет — то значит была захвачена часть тела запроса, которая вместе с прочитанными заголовками будет передана далее на чтение тела запроса.

private function readRequestHeaders(): RequestFragments {     $buffer = '';     $start = time();     while (true) {         $fragment = socket_read($this->acceptedSocket, self::HTTP_HEADERS_BUFFER_SIZE);         if ($fragment === false) {             $errorCode = socket_last_error($this->acceptedSocket);             socket_clear_error($this->acceptedSocket);             if (in_array($errorCode, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK])) {                 $this->addReadSocket($this->acceptedSocket);                 Fiber::suspend();                 if ($this->isTimeoutReached($start)) {                     App::debugFiber($this, 'Connection timed out while reading request headers. Socket error code: %d. Data: %s', $errorCode, json_encode($requestHeaders));                     throw new HttpRequestException('Connection timed out while reading request headers.');                 }                  continue;             }             if (!in_array($errorCode, [0, SOCKET_EINPROGRESS])) {                 throw new HttpRequestException(sprintf("Error reading data: %s", socket_strerror($errorCode)));             }         }         $buffer .= $fragment;         if (strlen($buffer) > self::MAX_HTTP_HEADERS_SIZE) {             throw new HttpRequestException('HTTP Request headers are too large.');         }         if (str_contains($buffer, "\r\n\r\n")) {             $separator = "\r\n\r\n";         } elseif (str_contains($buffer, "\r\r")) {             $separator = "\r\r";         } elseif (str_contains($buffer, "\n\n")) {             $separator = "\n\n";         } else {             $separator = '';         }         if (!empty($separator)) {             $fragments = explode($separator, $buffer);              return new RequestFragments($fragments[0] . $separator, $fragments[1] ?? '');         }         if (strlen($fragment) === 0) {             throw new HttpRequestException('HTTP Request is invalid.');         }     } }

Бенчмарки

Реализация с файберами:

$ ab -c 1000 -n 10000 http://localhost:8085/ Server Software: Server Hostname:        localhost Server Port:            8085  Document Path:          / Document Length:        244 bytes  Concurrency Level:      1000 Time taken for tests:   37.653 seconds Complete requests:      10000 Failed requests:        1108    (Connect: 0, Receive: 0, Length: 1108, Exceptions: 0) Non-2xx responses:      50 Total transferred:      2936789 bytes HTML transferred:       2425389 bytes Requests per second:    265.58 [#/sec] (mean) Time per request:       3765.275 [ms] (mean) Time per request:       3.765 [ms] (mean, across all concurrent requests) Transfer rate:          76.17 [Kbytes/sec] received

Реализация на PHP-FPM:

$ ab -c 1000 -n 10000 http://localhost:8086/ Server Software:        nginx/1.27.2 Server Hostname:        localhost Server Port:            8086  Document Path:          / Document Length:        244 bytes  Concurrency Level:      1000 Time taken for tests:   76.497 seconds Complete requests:      10000 Failed requests:        927    (Connect: 0, Receive: 0, Length: 927, Exceptions: 0) Total transferred:      3666924 bytes HTML transferred:       2436924 bytes Requests per second:    130.72 [#/sec] (mean) Time per request:       7649.725 [ms] (mean) Time per request:       7.650 [ms] (mean, across all concurrent requests) Transfer rate:          46.81 [Kbytes/sec] received

~265 запросов в секунду против ~130 запросов в секунду. разница ~2 раза.

Выводы и дальнейшие планы

Таким образом, расширение ev с помощью использования всего двух простых классов Ev и EvIo позволяет использовать представленный подход без досадного ограничения на дескрипторы ввода-вывода, присущего явно устаревшей функции socket_select(), делая такой подход к реализации серверов по крайней мере жизнеспособным. Помимо преимуществ в производительности и потреблении ресурсов за счёт сокращения количества процессов PHP в состоянии «ожидание ввода-вывода», такой способ кодирования серверов также даёт возможность в одном цикле сервера обрабатывать соединения с сетевыми контрагентами (peers) различных типов (к которым можно подключиться через сокет с использованием неблокирующего ввода-вывода), что позволяет строить очень интересные и высокопроизводительные решения с кэшированием в памяти самого процесса сервера, что устраняет необходимость (и соответствующие затраты) обращений по сети к таким серверам как Redis или Memcached, обычно используемым для этих целей. Инвалидация и(или) обновление кэша в таких решениях может выполняться по событиям от сервера очередей, читаемых из сокета, отслеживаемого с помощью функций расширения ev наряду с отслеживанием сокетов HTTP-соединений. В дальнейшем я постараюсь продемонстрировать пример приложения, которое будет использовать упомянутую в здешних выводах парадигму.

Полезные ссылки

Предыдущая статья — Пример HTTP-сервера на PHP с использованием файберов / Хабр

Репозиторий примера, ветка event — cleancodemonkey/fabio

Сайт библиотеки libevent — libevent

Cайт библиотеки libev — libev

Документация к расширению event — PHP: Event — Manual

Документация к расширению ev, которое использовалось в статье — PHP: Ev — Manual


ссылка на оригинал статьи https://habr.com/ru/articles/895580/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *