В статье Пример 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