Справочник по конфигурации

OxPHP настраивается через переменные окружения и один необязательный файл — .oxphpdeny, правила запрета путей, которые читаются из корня DOCUMENT_ROOT. У каждой настройки есть значение по умолчанию, поэтому развёртывание без какой-либо конфигурации работает сразу из коробки.

Булевы значения

Переменные, помеченные как булевы, принимают фиксированный канонический набор значений — без учёта регистра и с обрезкой пробелов:

  • истинные: on, true, 1, yes
  • ложные: off, false, 0, no

Любое непустое значение вне этого набора — например, опечатка вроде ture — приводит к немедленной ошибке при запуске с указанием имени переменной. Так ошибочная конфигурация отлавливается до того, как пойдёт трафик, а не приводит к молчаливому выставлению флага не в ту сторону.

Неустановленная переменная или пустое присваивание (FOO=) откатывается к задокументированному значению по умолчанию. Пустое значение намеренно трактуется как неустановленное: подстановка в Docker Compose / Kubernetes вида FOO=${FOO} даёт FOO=, когда переменной на хосте нет, и это не должно мешать серверу запускаться.

Сервер

Переменная По умолчанию Описание
LISTEN_ADDR 0.0.0.0:80 Адрес и порт основного HTTP-сервера
DOCUMENT_ROOT /var/www/html/public Корневой каталог для отдачи файлов и PHP-скриптов
ENTRY_FILE (не задано) Единственный канонический входной файл. Не задано = прямое отображение файлов. *.php = фронт-контроллер. Не-.php = статический фолбэк (SPA). При WORKER_MODE_ENABLED=true = bootstrap-скрипт воркера. Разрешается относительно DOCUMENT_ROOT (относительные пути и .. допускаются; абсолютные пути используются как есть). См. Маршрутизация
WORKER_MODE_ENABLED false Включает постоянный режим воркеров. Требует, чтобы ENTRY_FILE указывал на .php-скрипт. Булево — см. Булевы значения
MAX_CONNECTIONS 10000 Максимальное число одновременных TCP-соединений. Также потолок для QUEUE_MAX_WAITING по умолчанию (его половина), поэтому некорректное значение — ошибка при запуске, а не молчаливый откат. Его понижение не сдвигает значение по умолчанию QUEUE_CAPACITY, которое рассчитывается только из числа воркеров, — держите его выше PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING, см. Сохранение запаса для цикла приёма
TOKIO_WORKERS CPU / 2 (мин. 1) Потоки асинхронного ввода-вывода. 1 = однопоточный режим, N > 1 = фиксированное число потоков, не задано = автоматически (CPU / 2, мин. 1). См. Подбор размера асинхронного рантайма

Подбор размера асинхронного рантайма

По умолчанию TOKIO_WORKERS равен половине доступных ядер, и оставшаяся половина не простаивает: она принадлежит пулу PHP-воркеров. Два набора потоков соперничают за одни и те же ядра, поэтому расширить рантайм, не отняв циклы у приложения, нельзя.

Поднимать его до полного числа ядер — проигрыш везде, где пул уже подобран близко к этому числу. На 4-ядерной машине с пулом из 4 воркеров, отдающим сжатые ответы, переход с 2 потоков рантайма на 4 стоил, по замерам, 13% пропускной способности на JSON-теле в 1,6 КБ и 26% на теле в 5 КБ — и сжигал при этом больше CPU, потому что потоков было больше, чем ядер.

Поднимайте его там, где баланс иной: при пуле, небольшом относительно машины, или при работе, упирающейся в ввод-вывод, а не в PHP (проксируемые апстримы, статические файлы, потоковые ответы), — там работу делает рантайм, а пул в основном ждёт. Меняя его, следите не только за пропускной способностью, но и за пулом: oxphp_busy_workers против oxphp_workers_current показывает, какой стороне достались ядра. Рост пропускной способности при всё более загруженном пуле — реальный выигрыш; рост при пустеющем пуле обычно означает, что работа переехала, а не что её стало делаться больше.

Tip

TOKIO_WORKERS и PHP_WORKERS оба по умолчанию равны половине доступных ядер, так что их сумма приходится на число ядер. Измените один — и сумма сдвинется; поэтому любой из них стоит измерять, а не угадывать.

PHP-воркеры

Переменная По умолчанию Описание
EXECUTOR sapi Бэкенд-исполнитель PHP. sapi — исполнение PHP, stub — для бенчмаркинга без PHP
PHP_WORKERS CPU / 2 (мин. 1) Размер пула воркеров. N = фиксированный пул, MIN:MAX = динамическое масштабирование, 0 = автоматически
PHP_WORKERS_IDLE_SECONDS 30 Сколько секунд динамический воркер остаётся простаивающим, прежде чем будет выведен из пула (только в динамическом режиме). Воркер выводится в момент, когда у него нет ничего в обработке, поэтому ничто из обслуживаемого им не обрывается — а воркер, удерживающий запрос, который никогда не завершается, например открытый поток, такого момента не достигает и не выводится вовсе
QUEUE_CAPACITY Начальное число воркеров × 128 Максимальное число ожидающих запросов в очереди PHP. Запросы, пришедшие в заполненную очередь, ждут слота (см. QUEUE_WAIT_TIMEOUT_MS). Для динамических пулов (MIN:MAX) начальное число воркеров = минимальное значение. 0 = автоматически
QUEUE_WAIT_TIMEOUT_MS 1000 Наибольшее время, которое запрос может провести в ожидании PHP-воркера, прежде чем получит 529. Это потолок, а не фиксированное значение. Один дедлайн, проставляемый при прибытии и покрывающий оба ожидания, которые могут выпасть запросу: слота в очереди и воркера внутри очереди. При перегрузке сервер сам укорачивает бюджет ниже этого потолка и удлиняет его обратно, когда очередь разгружается; oxphp_admission_wait_budget_us публикует действующее значение, oxphp_admission_wait_budget_ceiling_us — настроенное (см. Адаптивный бюджет ожидания). Дедлайн соблюдается с обеих сторон: когда он истекает, на запрос отвечают 529, а воркер, добравшийся до запроса после дедлайна, отклоняет его, а не выполняет, так что запрос в очереди, который никто не забирает, не ждёт бесконечно. Сколько затем работает обработчик, он не ограничивает. Одновременно ждут не более QUEUE_MAX_WAITING запросов; сверх этого запросы отклоняются немедленно. 0 = отклонять немедленно, как только очередь заполнена; это убирает ожидание слота в очереди, но не ожидание внутри очереди. Понижайте его, когда приложение по HTTP обращается обратно к этому же серверу (внутреннему вызову нужен воркер, которого держит внешний, так что ожидание тратится впустую), или когда у балансировщика нагрузки впереди собственный таймаут короче. Клиент, закрывший соединение посреди ожидания, немедленно возвращает своё место — как на HTTP/1.1, так и на HTTP/2; клиент, закрывший его, уже находясь в очереди, сохраняет своё место, пока до него не доберётся воркер или не истечёт бюджет, и тогда получает ответ 499, а не учитывается как отказ из-за перегрузки. Чего сервер не видит — это клиента, переставшего ждать без закрытия. Такой клиент держит своё место, пока не будет допущен или пока не истечёт бюджет, и если воркер освободится раньше, его скрипт отработает впустую, так что держать бюджет ниже таймаута всего, что стоит впереди, всё равно стоит
QUEUE_MAX_WAITING Начальное число воркеров × 128, не более MAX_CONNECTIONS / 2 Максимум запросов, одновременно припаркованных в ожидании слота очереди. Сверх него запросы отклоняются немедленно, а не ждут. Каждый ожидающий удерживает соединение и полностью буферизованное тело запроса всё время ожидания, поэтому это граница удерживаемых ресурсов, а не ожиданий, которые окупятся. Потолок MAX_CONNECTIONS / 2 на значении по умолчанию ограничивает только эту часть затора; запросы в очереди и выполняющиеся удерживают соединение точно так же, поэтому запас, который сервер реально сохраняет для приёма и отказов, следует из PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING против MAX_CONNECTIONS — см. ниже. Подбирайте его из задержки собственного обработчика — тоже ниже. 0 = автоматически, никогда не ниже 1
QUEUE_MAX_WAITING_BYTES 67108864 (64 МиБ) Максимум байтов тел запросов, которые припаркованные запросы могут удерживать в сумме. Запрос, чьё тело вытолкнуло бы сумму за предел, отклоняется с 529 немедленно, а не ждёт; запрос без тела по этой причине не отклоняется никогда. QUEUE_MAX_WAITING ограничивает то же множество в запросах, что ничего не говорит об их размере — тела буферизуются целиком до того, как запрос достигает очереди, так что с одним лишь счётчиком в качестве преграды припаркованное множество может держать QUEUE_MAX_WAITING × 10 МиБ. Поднимайте его для приложения с обилием загрузок, которое должно поглощать всплески больших тел, а не сбрасывать их; понижайте в контейнере с жёстким лимитом памяти. 0 = автоматически

Подбор размера множества ожидающих

Сколько запросов могут ждать с пользой, следует из того, как быстро пул их разбирает. При W воркерах и обработчике, занимающем T миллисекунд, пул допускает W / T запросов в миллисекунду, поэтому бюджет в B миллисекунд может впустить около W × B / T ожидающих. Всё сверх этого ждёт весь бюджет и всё равно получает отказ, удерживая всё это время соединение и буферизованное тело запроса.

Скорость обслуживания при запуске неизвестна, поэтому значение по умолчанию вычислить это не может и намеренно сделано щедрым. Это подходит быстрым обработчикам, где пул разбирает глубокий затор задолго до конца бюджета, и слишком велико для медленных: при 8 воркерах, обработчике на 200 мс и потолке по умолчанию в 1 с (бюджете, действующем всегда, когда пул не сбрасывает нагрузку) вовремя могут быть допущены лишь около 40 запросов, тогда как умолчание паркует до 1024.

bash
QUEUE_MAX_WAITING=40

Установка значения около W × B / T превращает излишек в немедленный 529 вместо 529 секундой позже. Сокращение QUEUE_WAIT_TIMEOUT_MS достигает того же через другой множитель. Эти два размениваются друг на друга, и на медленном обработчике меньший бюджет обычно лучший рычаг, потому что он ещё и ограничивает, как долго клиент ждёт отказа. B здесь — действующий бюджет, а при перегрузке это тот, до которого его укоротил сервер, а не настроенный потолок. Прежде чем подбирать размер множества ожидающих по числу из конфигурационного файла, посмотрите oxphp_admission_wait_budget_us.

Множество ограничено и второй раз — в байтах. Каждый ожидающий держит тело своего запроса в памяти всё время ожидания, поэтому предел, считаемый в запросах, оставляет удерживаемую ими память на волю трафика: те же 1024 ожидающих ничего не стоят на GET без тел и обходятся в гигабайты на загрузках. QUEUE_MAX_WAITING_BYTES ограничивает эту сумму напрямую — за его пределами запрос с телом отклоняется сразу, а не паркуется, тогда как запросы без тела продолжают ждать как обычно. Приложению с обилием загрузок, которое должно поглощать всплески, а не сбрасывать их, нужно значение больше; контейнеру с жёстким лимитом памяти — меньше. Отказы по каждому из пределов считаются в oxphp_admission_refused_total отдельно (waiting_full — по счётчику, waiting_bytes — по памяти), так что метрика сама называет ручку, за которую браться.

Адаптивный бюджет ожидания

QUEUE_WAIT_TIMEOUT_MS — это потолок. Пока работа, которую впускает ожидание, завершается для клиентов, уже ушедших, сервер сам укорачивает бюджет, причём круто, — это стоит знать, прежде чем выбирать потолок:

  • Вниз: бюджет уменьшается вдвое всякий раз, когда четверть или больше того, что запустил пул, досталась клиентам, которые уже ушли; измеряется это по окну минимум из восьми запущенных запросов, а не по фиксированному интервалу.
  • Нижняя граница: одна шестьдесят четвёртая потолка (15,6 мс при умолчании) или 10 мс — что больше, но никогда не выше самого потолка.
  • Вверх: бюджет возвращается шагами в восьмую долю потолка, и только пока очередь наблюдается пустой и менее четверти запущенного пулом досталось ушедшим клиентам. Обычный ручеёк закрытых вкладок и клиентских таймаутов его не удерживает, а сервер, чья очередь никогда не пустеет, остаётся на укороченном бюджете, пока нагрузка не спадёт.

Оператору, выставившему 1000, стоит ожидать, что во время эпизода действовать будет около 15, поэтому алерты на ожидание пишите по паре oxphp_admission_wait_budget_us / oxphp_admission_wait_budget_ceiling_us, а не по настроенному числу.

Укорачивание запускают клиенты, которые закрывают соединение. Аудитория, перестающая ждать без закрытия, сигнала не даёт и оставляет потолок в силе.

Вызовы самого себя

Приложение, которое по HTTP обращается обратно к этому же серверу, тратит свои ожидания впустую, какими бы длинными они ни были: внутреннему вызову нужен воркер, которого держит внешний. oxphp_admission_wait_wasted_total считает такие ожидания, но только пока бюджет на потолке. Там, где бюджет опустился, счётчик затихает, так что 0 в нём сам по себе не доказывает, что такого паттерна нет. Опустится ли бюджет, тоже не свойство самого вызова себя: приложение, вызывающее само себя, чьи собственные вызывающие ждут щедро (внутренний cron, сайдкар с длинным таймаутом), сохраняет свой потолок.

QUEUE_WAIT_TIMEOUT_MS=0 этого не исправляет. Он убирает ожидание слота в очереди, но не ожидание внутри очереди, так что, если только QUEUE_CAPACITY не настолько мал, чтобы очередь была заполнена, внутренний вызов допускается в очередь, которую никто не разбирает, и ответа не получает вовсе.

Сохранение запаса для цикла приёма

Запрос удерживает своё соединение — и одно из разрешений MAX_CONNECTIONS — с момента прибытия и до момента ответа, чем бы он в промежутке ни занимался. Это покрывает три отдельные популяции, потому что слот очереди освобождается в момент, когда воркер забирает запрос, ещё до запуска скрипта:

  • выполняющиеся в воркере: как минимум PHP_WORKERS, а в режиме воркеров больше — там один поток мультиплексирует файберы;
  • в очереди, до QUEUE_CAPACITY;
  • припаркованные на допуске, до QUEUE_MAX_WAITING.

Только у третьей есть потолок, выведенный из MAX_CONNECTIONS. Держите PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING ниже MAX_CONNECTIONS. За этой чертой режим отказа меняется к худшему: цикл приёма берёт разрешение на соединение до того, как начнёт его обслуживать, поэтому, когда путь PHP разберёт их все, цикл встаёт, и клиент, пришедший в этот момент, не получает вообще никакого ответа вместо 529 — а этого балансировщик нагрузки не отличит от мёртвого экземпляра, тогда как проба работоспособности на INTERNAL_ADDR остаётся зелёной, потому что не проходит ни через один из этих лимитов.

Остановка логируется. Приём, которому приходится ждать разрешения, пишет WARN с указанием бюджета, а в момент, когда разрешение приходит, записывается INFO о том, сколько цикл простоял. Сообщения ограничены по частоте, чтобы сервер, колеблющийся около потолка, не превратил собственный лог в аварию: остановка, начавшаяся в пределах нескольких секунд после уже зарегистрированной, поначалу придерживается и сообщается, когда это окно закроется, если она всё ещё продолжается; остановка, закончившаяся внутри окна, не оставляет ни строки. Метрики видят каждую: oxphp_accept_stalled (датчик, читаемый, пока остановка длится, потому что внутренний слушатель не проходит через MAX_CONNECTIONS) и oxphp_accept_stalls_total (счётчик); см. справочник метрик. Приходящие клиенты по-прежнему не получают никакого ответа вместо ошибки, но лог и метрики остановку фиксируют.

Сервер проверяет это при запуске и предупреждает, когда сумма достигает бюджета, называя каждое значение; oxphp config --check сообщает то же самое, не меняя ни вердикта, ни кода выхода. Считайте устранение предупреждения необходимым, но не достаточным: простаивающие keep-alive-соединения, запросы статических файлов и рукопожатия в процессе тоже удерживают разрешения, и ничего из этого при запуске не подсчитать.

Обычный путь к предупреждению — понижение MAX_CONNECTIONS, потому что умолчание QUEUE_CAPACITY рассчитывается из числа воркеров и вслед за ним не опускается — 7 воркеров и MAX_CONNECTIONS=1000 дают 7 + 896 + 500 против бюджета 1000. Обратите внимание, за какую ручку браться: повышение одного лишь MAX_CONNECTIONS предупреждение не убирает, пока QUEUE_MAX_WAITING остаётся на умолчании, — ведь это умолчание равно половине MAX_CONNECTIONS и растёт вместе с ним: при 7 воркерах сумма устаканивается на 1799, и условие снимается только с MAX_CONNECTIONS=1800 и выше. Понизьте QUEUE_CAPACITY или задайте QUEUE_MAX_WAITING явно, а уже потом поднимайте бюджет.

Для динамического пула (MIN:MAX) слагаемое выполняющихся — это минимум, то же число, из которого рассчитаны умолчания очереди, поэтому пул, выросший до максимума, удерживает больше, чем говорит сумма.

Сравнение считает соединения, тогда как бюджет расходуется запросами, поэтому развёртывание с преобладанием HTTP/2 может законно находиться выше него: одно соединение несёт до H2_MAX_CONCURRENT_STREAMS запросов, так что тот же затор удерживается долей соединений. Предупреждение там ожидаемо. Большой пул на стандартном бюджете тоже до него добирается — автоматически подобранный пул из 39 воркеров даёт 39 + 4992 + 4992 против 10 000, — и вот на этот случай стоит реагировать, а не игнорировать его.

Статические и динамические воркеры

Укажите в PHP_WORKERS одно число для фиксированного пула:

bash
PHP_WORKERS=8 # Fixed 8 workers PHP_WORKERS=0 # Auto-detect: CPU / 2 (min 1)

Укажите в PHP_WORKERS значение MIN:MAX для автоматического масштабирования:

bash
PHP_WORKERS=2:16 # Scale between 2 and 16 workers PHP_WORKERS=4:0 # 4 minimum, auto-detect maximum (CPU × 2) PHP_WORKERS=0:16 # auto-detect minimum (CPU / 4, min 1), 16 maximum

В динамическом режиме OxPHP увеличивает число воркеров, когда все они заняты, и уменьшает его, когда воркеры простаивают дольше PHP_WORKERS_IDLE_SECONDS.

Как определяется число CPU

Каждое значение по умолчанию на этой странице, выраженное через CPU (TOKIO_WORKERS, а также PHP_WORKERS — незаданный, 0 или с 0 по любую сторону MIN:MAX), считает CPU, на которых процессу разрешено выполняться: его маску привязки к CPU (taskset, docker run --cpuset-cpus), уменьшенную до квоты CPU контейнера, если она задана (docker run --cpus; cpu.max в cgroup v2, cpu.cfs_quota_us в v1). Квота округляется вниз и считается не меньше 1, так что --cpus=1.5 считается как 1.

Два вида ограничений не учитываются, и контейнер, сдерживаемый только одним из них, получает размеры по всем CPU, которые видит:

  • Вес CPU: docker run --cpu-shares, cpu.weight в cgroup v2, cpu.shares в v1. Он определяет, как процессорное время делится с соседями при конкуренции. Это не потолок, и никакое число CPU из него не следует.
  • Ограничение, применяемое вне контейнера. Платформа может продавать долю машины и применять ограничение там, где контейнер его не видит. На DigitalOcean App Platform, например, экземпляр, продаваемый как 1 vCPU, по наблюдениям работал в песочнице gVisor без квоты (cpu.cfs_quota_us равно -1) и с 8 видимыми CPU, так что умолчания дали 4 PHP-воркера, 4 потока рантайма и очередь на 512.
Тарифы, применяющие ограничения вне поля зрения

На таком тарифе задайте PHP_WORKERS и TOKIO_WORKERS явно — по тому, что тариф реально предоставляет. В режиме воркеров сверьте также PHP_WORKERS × WORKER_MAX_MEMORY_MIB с памятью тарифа. Этот порог — триггер перезапуска, который воркер проверяет в своём цикле, а не потолок того, что может выделить его куча PHP, поэтому пул может удерживать столько и больше, прежде чем какой-либо воркер будет из-за этого перезапущен, — и сверх того OPcache и сам сервер; при значении по умолчанию 0 порога нет вовсе. Та же песочница может скрывать и ограничение памяти: контейнер App Platform из примера выше его не видел.

При запуске сервер логирует это число, его источник и получившиеся PHP_WORKERS и TOKIO_WORKERS. Вот как это выглядит под docker run --cpus=1 на машине с 14 CPU:

json
{"timestamp":"2026-10-05T18:55:39.801254Z","level":"INFO","fields":{"message":"CPU count for default sizing","cpus":1,"cpu_source":"cgroup quota","host_cpus":14,"php_workers":"1 (auto)","tokio_workers":"1 (auto)"}}
  • cpu_source равно host, когда ни маска, ни квота не уменьшили число, affinity или cgroup quota — когда его уменьшила одна из них, и unknown — когда сервер не смог это определить, потому что не удалось прочитать маску или число активных CPU. Если он вообще не смог посчитать CPU, cpus равно 4.
  • host_cpus — сколько CPU система сообщает как активные (/sys/devices/system/cpu/online в Linux); маска или квота это число не уменьшают. Поле опускается, если его не удаётся прочитать. Среда выполнения, которая переписывает этот файл так, чтобы в нём были перечислены только CPU контейнера, заставляет суженную маску читаться как host.
  • (auto) отмечает значение, целиком оставленное умолчанию: переменная не задана или пуста либо, для TOKIO_WORKERS, не является числом.

cpu_source, равный host, на тарифе, который продал вам меньше CPU, чем cpus, — это как раз описанный выше случай.

Режим воркеров

Переменная По умолчанию Описание
WORKER_MAX_MEMORY_MIB 0 Максимальный объём памяти в MiB на один воркер до его пересоздания. 0 = без ограничения

Установите WORKER_MODE_ENABLED=true и укажите в ENTRY_FILE bootstrap-скрипт воркера (например, ENTRY_FILE=worker.php или ENTRY_FILE=../worker.php). PHP-воркеры тогда остаются живыми между запросами, сохраняя в памяти состояние инициализации (автозагрузчики, соединения с базой данных). Воркеры пересоздаются автоматически, когда превышают WORKER_MAX_MEMORY_MIB или получают три подряд запроса, которые развалились, а также по требованию, когда приложение вызывает Worker::scheduleExit(); что засчитывается в третий случай, см. в разделе Перезапуск. Параметр WORKER_MAX_REQUESTS из ранних выпусков объявлен устаревшим и игнорируется: не задавайте ни его, ни аналог, либо перейдите на Worker::scheduleExit().

Устаревшие: INDEX_FILE и WORKER_FILE

Устаревшие переменные INDEX_FILE и WORKER_FILE по-прежнему разбираются ради обратной совместимости. Если они заданы, при запуске выводится строка лога уровня WARN, и они отображаются на новую модель:

Устаревшая форма Современный эквивалент
INDEX_FILE=index.php ENTRY_FILE=index.php
INDEX_FILE=index.html ENTRY_FILE=index.html
WORKER_FILE=/path/worker.php WORKER_MODE_ENABLED=true ENTRY_FILE=/path/worker.php

Если заданы и старые, и новые переменные, приоритет за ENTRY_FILE / WORKER_MODE_ENABLED. Переходите на них, когда вам удобно; устаревшие формы будут удалены в одном из будущих выпусков.

SAPI / PHP

Переменная По умолчанию Описание
SUPERGLOBALS_ENABLED true Описывает запрос для PHP, чтобы тот построил $_SERVER и $_GET перед выполнением скрипта. Установите ложное значение, чтобы пропустить эту работу в приложении, которое не читает ни ту, ни другую. Действует только на эти две: $_POST, $_FILES и $_COOKIE строятся из тела запроса и заголовка Cookie, которые передаются PHP в любом случае, а php://input — поток, а не суперглобальная переменная. Полная таблица — в разделе SUPERGLOBALS_ENABLED

Таймауты

Переменная По умолчанию Описание
HEADER_TIMEOUT_SECONDS 5 Максимальное число секунд на приём HTTP-заголовков после установки соединения (защита от Slowloris)
DRAIN_TIMEOUT_SECONDS 25 Максимальное число секунд ожидания завершения текущих соединений при корректном завершении работы. Запрос, находящийся к дедлайну внутри блокирующего вызова, может удержать процесс дольше (см. Корректное завершение работы)

Время выполнения PHP ограничивается собственной ini-директивой PHP max_execution_time (и вызовом set_time_limit() во время выполнения), а не переменной окружения OxPHP. До асинхронной задачи это ограничение не доходит: поток пула ASYNC_WORKERS не держит дедлайна выполнения, поэтому ни директива, ни set_time_limit() не ограничивают то, что выполняет задача, отправленная через oxphp_async(). Ограничивайте такую задачу аргументом таймаута oxphp_async_await(); см. Асинхронные промисы.

Ограничение частоты запросов

Переменная По умолчанию Описание
RATE_LIMIT 0 (выкл.) Максимальное число запросов с одного IP за временное окно. 0 отключает ограничение частоты запросов
RATE_WINDOW_SECONDS 60 Длительность окна ограничения частоты запросов в секундах

Безопасность

Переменная По умолчанию Описание
FRAME_OPTIONS SAMEORIGIN Защита от кликджекинга. SAMEORIGIN разрешает встраивание только страницам того же источника, DENY запрещает всякое встраивание, off отключает защиту (используйте, когда управляете встраиванием через собственный CSP). Любое другое значение откатывается к умолчанию SAMEORIGIN с предупреждением при запуске. Устанавливает одновременно X-Frame-Options и Content-Security-Policy: frame-ancestors на каждом ответе. Значения выдаваемых заголовков, то, как серверные заголовки уступают заданным приложением, и рекомендации по выбору значения — в разделе Защита от кликджекинга ниже
TRUSTED_PROXIES (не задано) Сети доверенных обратных прокси: список через запятую, в котором можно смешивать CIDR, отдельные IP и ключевые слова private и cloudflare (например, private,cloudflare). Если задано, OxPHP извлекает реальный IP клиента из заголовков Forwarded (RFC 7239) или X-Forwarded-For по алгоритму «крайний правый недоверенный»; с cloudflare запрос, цепочка перенаправления которого показывает, что он пришёл с пограничного узла Cloudflare, вместо этого берёт его из CF-Connecting-IP. Также обрабатывает X-Forwarded-Proto и X-Forwarded-Host для $_SERVER['HTTPS'], REQUEST_SCHEME, SERVER_NAME и SERVER_PORT. Не задано = функция отключена
PHP_DENY_PATHS (не задано) Glob-шаблоны через запятую, чьи .php-файлы никогда не должны выполняться по прямому URI (например, /uploads/**,/cache/**,/admin/legacy.php). Шаблоны могут указывать на целые каталоги или отдельные файлы. Применяется в режимах прямого отображения — Traditional и SPA; в режимах Framework и Worker, которые никогда не выполняют произвольные .php-файлы напрямую, игнорируется с предупреждением при запуске. Также охватывает скрипты, доступные через разрешение индексного файла каталога (/uploads/ → uploads/index.php). Для прямых .php-URI сопоставление происходит до обращения к диску, поэтому запрещённые пути дают одинаковый ответ независимо от того, существует файл или нет (без «оракула существования»). Устаревшее имя PHP_DENY_DIRS принимается как устаревший псевдоним и вызывает WARN при запуске. См. Дени-лист исполнения PHP
PHP_DENY_FALLBACK 404 Что возвращать при совпадении с PHP_DENY_PATHS или с правилом запрета .oxphpdeny. Либо HTTP-статус 400–599 (в паре с ERROR_PAGES_DIR для собственного HTML), либо URI-путь с ведущим / к PHP-скрипту-фолбэку внутри DOCUMENT_ROOT. Скрипт-фолбэк получает OXPHP_DENIED_PATH и OXPHP_DENIED_PATTERN в $_SERVER. Проверяется при запуске: скрипт должен существовать, канонизироваться внутри DOCUMENT_ROOT и сам не должен совпадать с PHP_DENY_PATHS (для предотвращения зацикливания). Правило .oxphpdeny может покрывать сам скрипт; в режиме воркеров правила запрета .oxphpdeny принимают только код статуса. См. Правила запрета путей
SYMLINK_ALLOW_PATHS (не задано) Список абсолютных путей через запятую, под которыми символическим ссылкам разрешено выходить за пределы DOCUMENT_ROOT. Каждый элемент должен уже существовать на диске; относительные и несуществующие пути прерывают запуск. Не задано = выход по символическим ссылкам запрещён. См. Разрешённые пути для симлинков

Специальное значение private разворачивается во все частные сети RFC-1918, loopback- и link-local-адреса (IPv4 и IPv6): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7, fe80::/10.

Защита от кликджекинга

Кликджекинг — атака, при которой враждебная страница встраивает ваш сайт в невидимый <iframe> и обманом заставляет пользователя нажать то, чего он не видит: кнопку «да, удалить мой аккаунт», покупку в один клик, запрос авторизации OAuth. Защита — сказать браузеру, кому (и разрешено ли кому-либо вообще) встраивать ваши страницы во фреймы. Этим управляет FRAME_OPTIONS.

Фреймингом управляют два заголовка — устаревший X-Frame-Options (понимаемый всеми браузерами) и современный Content-Security-Policy: frame-ancestors (вытесняющий первый там, где присутствуют оба), — поэтому OxPHP выдаёт оба, и политика действует одинаково в старых и новых браузерах. Одно значение FRAME_OPTIONS отображается в согласованную пару:

FRAME_OPTIONS X-Frame-Options Content-Security-Policy Кто может встраивать ваши страницы
SAMEORIGIN (по умолчанию) SAMEORIGIN frame-ancestors 'self' Только страницы того же источника
DENY DENY frame-ancestors 'none' Никто, даже ваши собственные страницы
off (не отправляется) (не отправляется) Кто угодно — сервер не ограничивает встраивание

Выбор значения. SAMEORIGIN — умолчание: он блокирует межсайтовое встраивание, на которое кликджекинг и опирается, но позволяет вашим собственным страницам встраивать друг друга — что многие приложения законно делают (предпросмотры в админке, виджеты дашбордов, платёжные компоненты на том же источнике). Выбирайте DENY, когда ничто на вашем сайте не должно встраиваться никогда, даже им самим, — для самой строгой позиции. Выбирайте off только тогда, когда управляете фреймингом сами через полный Content-Security-Policy, устанавливаемый вашим приложением, — см. ниже.

Встраивание с внешних источников. Ни одно значение X-Frame-Options не может назвать конкретный разрешённый источник (ALLOW-FROM удалён из стандарта). Чтобы позволить именованной третьей стороне встраивать ваши страницы, установите FRAME_OPTIONS=off и заставьте приложение выдавать собственный Content-Security-Policy с явным списком frame-ancestors, например header("Content-Security-Policy: frame-ancestors 'self' https://partner.example.com");.

Заголовки приложения побеждают. Серверные заголовки — фолбэки, применяемые только когда в ответе такого заголовка нет: приложение, устанавливающее собственный X-Frame-Options или Content-Security-Policy через PHP header(), сохраняет его нетронутым. Два заголовка фрейминга трактуются как одна политика, поэтому сервер никогда не противоречит приложению:

  • Если приложение установило X-Frame-Options, OxPHP пропускает свой фолбэк frame-ancestors (серверный CSP переопределил бы выбор приложения в современных браузерах).
  • Если приложение установило Content-Security-Policy, содержащий директиву frame-ancestors, OxPHP пропускает свой фолбэк X-Frame-Options (более строгий серверный X-Frame-Options заблокировал бы лишнее в устаревших браузерах, игнорирующих CSP).

Тот же приоритет действует для X-Content-Type-Options, который OxPHP устанавливает в nosniff на каждом ответе: заданное приложением значение сохраняется дословно. Учтите, что nosniff — единственное значение, которое что-то делает: приложение, переопределившее его чем-то другим, молча отключает защиту от MIME-сниффинга.

TLS

Переменная По умолчанию Описание
TLS_CERT (не задано) Путь к TLS-сертификату в кодировке PEM. Для включения TLS должны быть заданы и TLS_CERT, и TLS_KEY
TLS_KEY (не задано) Путь к закрытому TLS-ключу в кодировке PEM
TLS_MIN_VERSION 1.2 Минимальная принимаемая версия протокола TLS: 1.2 или 1.3. Проверяется при запуске (и командой oxphp config --check) даже когда TLS не включён — любое другое значение, включая последовательности байтов вне UTF-8, приводит к жёсткой ошибке при запуске. Пустое значение трактуется как неустановленное

HTTP/2

Переменная По умолчанию Описание
H2_MAX_CONCURRENT_STREAMS PHP_WORKERS_MAX × 4 (мин. 32) Максимальное число одновременно открытых потоков на одно HTTP/2-соединение
H2_MAX_PENDING_RESET 20 Максимальное число кадров RST_STREAM в очереди до закрытия соединения (защита от Rapid Reset)
H2_MAX_HEADER_LIST_BYTES 65536 Максимальный суммарный размер декодированных заголовков на один запрос в байтах
H2_KEEPALIVE_INTERVAL_SECS 20 Число секунд между HTTP/2-кадрами PING; 0 отключает
H2_KEEPALIVE_TIMEOUT_SECS 10 Число секунд ожидания ответа на PING до закрытия соединения

Статические файлы

Переменная По умолчанию Описание
STATIC_MAX_AGE 30d Cache-Control: max-age для статических файлов. Принимает: 30s, 5m, 2h, 30d, 1w, 1y, число секунд без суффикса (3600) или off для отключения заголовка. Заменяет устаревшую STATIC_CACHE_TTL.
STATIC_REVALIDATE off Булево — см. Булевы значения. Установите истинное значение, чтобы включить ревалидацию по mtime для кэша содержимого в памяти: время изменения файла перепроверяется не чаще одного раза в 3 секунды на файл (а не на запрос), а устаревшие записи вытесняются автоматически, так что изменения становятся видны в течение 3 секунд. Заменяет устаревшую STATIC_CACHE (где off имело обратный смысл).

Сжатие

Переменная По умолчанию Описание
COMPRESSION_ENCODINGS br,zstd,gzip Кодирования содержимого, которые предлагает сервер, через запятую: br (или brotli), zstd, gzip. Неизвестное имя — ошибка при запуске. Чтобы полностью отключить сжатие, задайте всё значение как off (или none). Это не элемент списка, так что br,off — ошибка, а не переключатель. Пустое присваивание возвращает умолчание. Порядок, в котором значения записаны, игнорируется: кэшированные статические файлы предпочитают Brotli, чья цена платится один раз, а всё остальное — Zstandard. См. Сжатие
COMPRESSION_BROTLI_LEVEL 5 Качество Brotli (от 0 до 11). Заменяет устаревшую COMPRESSION_LEVEL
COMPRESSION_ZSTD_LEVEL 6 Уровень сжатия Zstandard (от 0 до 19)
COMPRESSION_GZIP_LEVEL 6 Уровень сжатия gzip (от 0 до 9)
COMPRESSION_LEVEL (не задано) Устаревшее имя COMPRESSION_BROTLI_LEVEL; выдаёт WARN при запуске. COMPRESSION_LEVEL=0 сохраняет прежний смысл и отключает все кодирования, а не только Brotli. Явно заданная COMPRESSION_BROTLI_LEVEL побеждает ненулевое значение, но не 0, который выключает сжатие, что бы ещё ни было задано

Кодирование предлагается, когда COMPRESSION_ENCODINGS его перечисляет и его уровень не равен 0; любое из двух условий по отдельности его отзывает. Уровень вне своего диапазона или не являющийся числом прерывает запуск с ошибкой, называющей переменную. Значения не подгоняются к границам.

Логирование

Переменная По умолчанию Описание
LOG_LEVEL info Уровень детализации логов: trace, debug, info, warn, error
ACCESS_LOG (не задано) Журнал доступа по каждому запросу: all = каждый запрос, error = только 4xx/5xx, не задано = выключено
Note

ACCESS_LOG принимает all или error. Оставьте её неустановленной, чтобы полностью отключить журнал доступа.

Клиент, закрывший соединение раньше, чем сервер ответил, логируется на уровне debug, а не error. Это самое частое событие, которое видит публичный слушатель (отменённый fetch, закрытая вкладка, прокси, у которого сработал таймаут чтения), и оно не является сбоем сервера, так что при LOG_LEVEL по умолчанию его не видно. Считайте такие случаи через oxphp_request_cancelled_total{reason="client_abort"} или поднимите LOG_LEVEL=debug, чтобы увидеть отдельные соединения. Таймауты, которые сервер соблюдает сам (HEADER_TIMEOUT_SECONDS), остаются на уровне warn, а все прочие сбои соединений — на уровне error.

LOG_LEVEL не фильтрует журнал доступа

Эти две настройки независимы. LOG_LEVEL задаёт детализацию собственной диагностики сервера, поэтому LOG_LEVEL=warn при ACCESS_LOG=error сообщает предупреждения и ошибки и логирует каждый запрос с 4xx/5xx. Чтобы заглушить только журнал доступа, нужна директива RUST_LOG, называющая его цель: RUST_LOG=warn,access_log=off, где warn заменяет тот LOG_LEVEL, с которым вы работали. RUST_LOG, если задана и разбирается, заменяет LOG_LEVEL, а не накладывается на него. См. Журнал доступа.

Наблюдаемость

Переменная По умолчанию Описание
INTERNAL_ADDR (не задано) Адрес внутреннего сервера (/health, /metrics, /config). Если не задано, внутренний сервер не запускается. Значение только с портом (:9090) привязывается к 127.0.0.1; чтобы открыть его наружу, укажите явный 0.0.0.0:9090
INTERNAL_ALLOW_IPS (не задано) Список разрешений в формате CIDR/IP через запятую для внутреннего сервера. Узел вне списка получает 403 на /metrics, /config и путях плагинов; проверки работоспособности (/health, /healthz, /readyz, /startupz и их длинные формы) остаются доступны. Не задано/пусто = разрешены все. Loopback не подразумевается неявно — укажите 127.0.0.1/32, чтобы сохранить доступ с localhost. Некорректный список прерывает запуск
ERROR_PAGES_DIR (не задано) Каталог с пользовательскими страницами ошибок с именами {status}.html (например, 404.html, 503.html)
MAX_QUERY_BODY 524288 Максимальный размер тела запроса в байтах для внутренних query-эндпоинтов (512 KiB)
TRACE_CONTEXT false Булево — см. Булевы значения. Если истинно, включает распространение W3C Trace Context: читает заголовки traceparent/tracestate и передаёт их в PHP через $_SERVER

OpenTelemetry

Переменная По умолчанию Описание
OTEL_ENABLED false Включает экспорт спанов OpenTelemetry. Автоматически устанавливает TRACE_CONTEXT=true. Булево — см. Булевы значения
OTEL_EXPORTER_OTLP_PROTOCOL grpc Протокол экспорта: grpc или http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317 (gRPC) или http://localhost:4318 (HTTP) Эндпоинт OTLP-коллектора. С http/protobuf — базовый URL: спаны отправляются на /v1/traces под ним
OTEL_EXPORTER_OTLP_TIMEOUT 10000 Таймаут экспорта в миллисекундах. При gRPC он также ограничивает подключение к коллектору, включая TLS-рукопожатие, поэтому держите его больше времени, которое занимает подключение; попытка, которой сначала нужно подключиться, может длиться до двух таймаутов. При gRPC в Linux он также задаёт, как скоро закрывается соединение, переставшее принимать данные, — см. Ошибки экспорта спанов в логе
OTEL_EXPORTER_OTLP_HEADERS (не задано) Заголовки аутентификации: key=value,key2=value2
OTEL_SERVICE_NAME oxphp Имя сервиса в экспортируемых спанах
OTEL_SERVICE_VERSION (не задано) Атрибут версии сервиса
OTEL_RESOURCE_ATTRIBUTES (не задано) Дополнительные атрибуты ресурса: env=prod,region=us-east-1
OTEL_TRACES_SAMPLER parentbased_traceidratio Стратегия сэмплирования: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG 1.0 Доля сэмплирования (0.0–1.0) для семплеров на основе отношения
Note

Недопустимые или выходящие за диапазон значения OTEL_TRACES_SAMPLER_ARG ограничиваются интервалом [0.0, 1.0] и логируются на уровне warn. Неизвестные значения OTEL_TRACES_SAMPLER откатываются к parentbased_traceidratio и логируются.

APM

Переменная По умолчанию Описание
OTEL_APM_ENABLED false Включает APM: автоматическую инструментацию, захват ошибок и PHP-SDK трассировки. Требует OTEL_ENABLED=true. Булево — см. Булевы значения
OTEL_APM_SLOW_QUERY_MS 100 Порог медленного запроса в миллисекундах. Запросы к базе данных, превышающие его, получают атрибут спана oxphp.db.slow=true
OTEL_APM_DB_CAPTURE_PARAMS_ENABLED false Записывает связанные параметры в атрибут спана db.params. Отключите в продакшене, если параметры могут содержать чувствительные данные. Булево — см. Булевы значения
OTEL_APM_STACKTRACE_MAX_BYTES 8192 Максимальный размер атрибута exception.stacktrace в байтах. При превышении предела стектрейс обрезается с конца с маркером …(truncated). 0 отключает обрезку
OTEL_APM_MESSAGE_MAX_BYTES 4096 Максимальный размер атрибута exception.message в байтах (значение по умолчанию совпадает с ограничением New Relic на значение одного атрибута). При превышении предела сообщение обрезается с конца с маркером …(truncated). 0 отключает обрезку

Когда APM включён, OxPHP автоматически перехватывает 34 внутренние функции PHP (PDO, mysqli, cURL, Redis, Memcached, файловый ввод-вывод), чтобы создавать дочерние спаны. PHP-функции oxphp_apm_*() регистрируются независимо от того, включён ли APM. Когда он выключен, функции спанов — no-op-заглушки, возвращающие значения-маркеры. Исключение — oxphp_apm_trace(): она по-прежнему выполняет свой колбэк и возвращает его значение, так что отключение APM никогда не меняет того, что вычисляет ваше приложение.

Асинхронные воркеры

Переменная По умолчанию Описание
ASYNC_WORKERS 0 (отключено) Число выделенных потоков асинхронных воркеров. При 0 асинхронные функции (oxphp_async и т. д.) регистрируются, но при вызове выбрасывают OxPHP\Async\AsyncException. Установите положительное значение, чтобы включить фоновое выполнение задач
ASYNC_QUEUE_CAPACITY ASYNC_WORKERS × 64 Максимальное число ожидающих задач в асинхронной очереди. 0 = автоматически (воркеры × 64)
ASYNC_MAX_FIBERS 256 Ограничение на число одновременных файберов асинхронных задач на один воркер. Глобальный для процесса лимит выполняющихся (в очереди + запущенных) равен ASYNC_MAX_FIBERS × ASYNC_WORKERS; диспетчеризация сверх него немедленно отклоняется с OxPHP\Async\AsyncException, чтобы композиция с fan-out не могла привести к взаимоблокировке

Пул асинхронных воркеров обрабатывает фоновые задачи типа «запустил и забыл», диспетчеризуемые из PHP. Он отделён от пула PHP-воркеров и не требуется для обработки обычных запросов.

Некорректное значение любой из этих трёх переменных (например, ASYNC_WORKERS=8x) приводит к ошибке при запуске — откат к значению по умолчанию молча отключил бы или неправильно настроил пул. Строго пустое значение трактуется как неустановленное.

Хуки времени выполнения

Переменная По умолчанию Описание
RUNTIME_HOOKS (отключено) Включаемая по желанию замена блокирующих встроенных функций PHP реализациями, приостанавливающими файбер. 1/true/all включает все категории хуков; список через запятую включает отдельные категории (например, RUNTIME_HOOKS=sleep,streams)
Категория Что перехватывает
sleep Нативные sleep() и usleep() приостанавливают текущий файбер ровно как oxphp_sleep()/oxphp_usleep(), в том числе в тех контекстах, где и эти две приостановиться не могут и вместо этого ждут на потоке воркера (см. Где хуки sleep не приостанавливают)
streams Два ожидания приостанавливают текущий файбер вместо удержания потока воркера: блокирующее чтение из сокет-потока tcp:// и stream_select(). Чтение покрывает клиентов, блокирующихся на одном сокете, — fsockopen(), stream_socket_client(), HTTP-обёртки потоков, mysqlnd (PDO_MySQL, mysqli), phpredis; stream_select() покрывает циклы, ждущие несколько сокетов сразу. В обоих случаях без изменений кода. Клиенты, ждущие каким-то иным способом, не затрагиваются (см. ниже)
net Ожидания ниже уровня PHP-потоков, где их не видит хук чтения streams, приостанавливают текущий файбер вместо удержания потока воркера: установка соединения через stream_socket_client(), fsockopen() и любой клиент, построенный на PHP-потоках (в том числе mysqlnd, а значит PDO_MySQL и mysqli, и phpredis), рукопожатие TLS, чтения из потоков ssl:///tls:// после активации шифрования, записи, которым приходится ждать места в буфере отправки, и чтения из потоков на сокетах unix://, udp:// и udg://, из потоков stream_socket_pair() и из потоков, которые socket_export_stream() делает из не-TCP-сокетов. Требует, чтобы рядом была указана streams, и работает только в Linux (см. Когда категория net остаётся выключенной). Не покрываются: ожидания внутри libcurl, libpq (pgsql, pdo_pgsql) и libmemcached, которые вызывают poll() из собственных библиотек; разрешение DNS; stream_socket_recvfrom(); записи в обычный (не TLS) поток с бесконечным таймаутом (default_socket_timeout=-1); и постоянные подключения (pfsockopen(), STREAM_CLIENT_PERSISTENT и постоянные режимы построенных на них клиентов)

Хуки действуют внутри файберов запросов режима воркеров и файберов асинхронных задач. Вне файбера (контекст запроса traditional/framework/SPA, CLI) сохраняется исходное нативное поведение, включая ошибки валидации аргументов. С включёнными хуками sleep сторонний код, вызывающий sleep(), перестаёт удерживать поток воркера везде, где файбер можно приостановить (а это не везде; см. ниже), — в обоих случаях без изменений кода. Отмена асинхронной задачи во время перехваченного sleep разматывает её через OxPHP\Async\AsyncException, а перехваченный sleep(), который приостановился, возвращает 0; возвращаемое значение нативной функции при прерывании сигналом возникает только на блокирующем пути, описанном ниже.

Проверка того, что настройка подхвачена

Сервер сообщает установленные категории в виде разобранного множества в двух местах. На внутреннем сервере:

Installed hook categories
$ curl -s localhost:9090/config | jq .runtime_hooks [ "sleep", "streams", "net" ]

И из PHP — для развёртывания, чей внутренний сервер недоступен или не включён:

php
<?php echo json_encode(oxphp_server_info()['runtime_hooks']); // ["sleep","streams","net"]

Оба читают одно и то же значение и сообщают множество, а не строку: RUNTIME_HOOKS=all, RUNTIME_HOOKS=1 и RUNTIME_HOOKS=sleep, streams, net все возвращаются как ["sleep","streams","net"]. Пустой список означает, что ни один хук не установлен. Такой ответ получается, когда переменная не задана, при RUNTIME_HOOKS=0, при значении, называющем только категории, которых эта сборка не знает, и при опечатке в имени переменной. Значение, смешивающее одно с другим, — другое дело: RUNTIME_HOOKS=sleep,bogus читается как ["sleep"], так что непустой список сам по себе не доказывает, что каждое имя было понято. Каждое нераспознанное имя получает при запуске собственную строку в журнале сервера, и только там они и называются. Проверяйте после любого изменения настройки: нераспознанная переменная окружения нигде не считается ошибкой, так что «опечатка» иначе проявляется лишь как задержка.

Установлены — не значит действуют

Список сообщает, у каких встроенных функций заменены обработчики, а это происходит при запуске независимо от режима маршрутизации. Там, где файберов нет вовсе, замены делегируют обработчикам, которые заменили, поэтому сервер в традиционном режиме сообщает ["sleep","streams","net"], всё время ведя себя нативно. Процесс php CLI сообщает ["sleep","streams"]: движок входит в сам этот исполняемый файл, поэтому net не может отличить ожидания PHP от собственных ожиданий программы и остаётся выключенной. Читайте список как «этот процесс подготовлен к хукам», а режим маршрутизации — как ответ на вопрос, может ли что-то ими воспользоваться.

Внутри streams всё ещё уже: часть, отвечающая за чтение из сокета, может не установиться сама по себе на платформе, где таблицу операций потоков нельзя сделать записываемой, тогда как stream_select() и входные точки баз данных остаются перехваченными. Категория сообщается в любом случае; единственный сигнал — строка в журнале сервера о том, что чтения из сокетов остаются блокирующими. Эта строка, как и строка о нераспознанной категории и строки, которые пишет net (см. ниже), следует логированию ошибок PHP: при незаданном error_log (по умолчанию) она попадает в собственный журнал сервера как warn, и LOG_LEVEL=error её скрывает.

Когда категория net остаётся выключенной

net сообщается, только когда она действительно установлена. Указанная без streams, она остаётся выключенной, о чём говорит строка в журнале сервера. На платформе, отличной от Linux, там, где при запуске собственный код сервера нельзя отличить от кода PHP, или там, где poll() в PHP не привязан к тому, который перехватывает сервер, она остаётся выключенной со строкой о том, что установка соединений и ожидания TLS остаются блокирующими. Поэтому net, отсутствующая в списке после того, как вы её запросили, — это и есть ответ, а не пробел в отчёте.

Единственное, чего список не говорит, — где находится OpenSSL. Ожидания, которые делает OpenSSL (TLS-рукопожатие, в том числе то, которым завершается подключение ssl://, а также чтения и записи в TLS-потоке после активации шифрования), приостанавливают файбер, только когда OpenSSL встроен в PHP, как в официальных образах. Когда OpenSSL загружен как отдельное разделяемое расширение (extension=openssl), эти ожидания, насколько может судить сервер, не являются собственными ожиданиями PHP, поэтому они удерживают поток, тогда как установка соединений, unix:// и остальные чтения по-прежнему приостанавливают файбер, а net по-прежнему сообщается.

Где хуки sleep не приостанавливают

Там, где переключение файбера запрещено, перехваченные sleep()/usleep() передают вызов встроенной функции, которую заменили; та ждёт на потоке воркера и возвращает то, что возвращает. Поэтому sleep(), прерванный там сигналом, отдаёт недоспанные секунды, как и вне файбера. (Проверка аргумента в любом случае собственная у хука: он отвергает всё выше 4294967295, что неперехваченная встроенная функция принимает и обрезает.) Переключение заблокировано везде, где покинуть текущий фрейм было бы небезопасно, и список ниже не исчерпывающий:

  • внутри обработчика declare(ticks) и внутри диспетчеризации сигналов pcntl (и то и другое — дело самого upstream PHP);
  • пока строятся входные данные запроса;
  • внутри FILTER_CALLBACK, достигнутого из filter_input_array(), которой передан массив определений по полям (см. примечание к $_GET в разделе Суперглобальные переменные);
  • внутри обходов демонтажа, которые запускают деструкторы после того, как запрос развалился (после фатальной ошибки или после записи в запрос, который уже был отменён), так что __destruct(), засыпающий ради отсрочки, делает это, удерживая поток.

Обычное завершение запроса к ним не относится: деструкторы и функции завершения выполняются там с разрешённым переключением. oxphp_sleep() и oxphp_usleep() во всех этих местах тоже идут блокирующим путём, так что обратиться к ним — не способ это обойти.

Файбер, запущенный пользовательским планировщиком (AMPHP, Revolt или голый new Fiber), — другой случай: переключение там не заблокировано, и планировщик нормально приостанавливает и возобновляет собственные файберы, но OxPHP не может возобновить контекст, которым не владеет, поэтому хук отходит в сторону по той же причине, что и хук streams. Речь о файбере, запущенном внутри запроса; запрос режима воркеров сам является файбером, которым управляет OxPHP, так что к нему хуки применяются как обычно. Код внутри файбера, запущенного самим приложением, спит на потоке воркера, и эту долю нагрузки нужно рассчитывать так, будто хук выключен.

Что покрывает хук streams

Кооперативными хук streams делает блокирующее чтение из PHP-сокет-потока и ожидание внутри stream_select(). Прежде чем на него рассчитывать, проверьте, что ваш клиент ждёт одним из этих двух способов или одним из способов, которые добавляет категория net (ниже). Несколько распространённых клиентов ждут иначе, и для них ничего не меняется:

  • ext/curl. curl_exec() и curl_multi_* общаются с сокетами сами, ниже уровня PHP-потоков, поэтому хук их никогда не видит. Это касается каждого HTTP-клиента, построенного на curl, — в том числе Guzzle с его обработчиком по умолчанию. Curl-клиент можно направить на обработчик через обёртку потоков — тот через PHP-потоки проходит.

  • socket_select(). Это ext/sockets, другой API на сырых дескрипторах, и он не перехватывается. stream_select() — перехватывается. То же относится к ожиданию внутри stream_socket_accept(), которое тоже не перехватывается.

  • Потоки unix://, udp:// и udg://, пары сокетов, потоки из socket_export_stream() на не-TCP-сокетах и ssl:///tls:// после активации шифрования. Их таблицы операций приватны для PHP или для OpenSSL, поэтому хук streams до них не дотягивается. Категория net дотягивается: когда она указана, чтение из потока (fread(), fgets() и подобные) на любом из них приостанавливает файбер, как на tcp://. Без net они удерживают поток, как и прежде (пока stream_socket_enable_crypto() не завершилась успешно, чтения SSL-потока делегируются чтению обычного сокета и приостанавливают файбер в любом случае). stream_socket_recvfrom() — не чтение из потока: она вызывает recvfrom() напрямую, без собственного ожидания PHP, которое можно было бы приостановить, поэтому на сокете udp:// или udg:// она удерживает поток до прихода датаграммы даже с net. socket_export_stream() делает поток того транспорта, который использует сокет: UDP-, unix- или датаграммный unix-сокет становится потоком udp://, unix:// или udg://, TCP-сокет — обычным потоком tcp:// (его покрывает уже один streams), а сокет любого другого вида — универсальным потоком, таким же, как у пары сокетов.

  • Установка соединения и разрешение DNS. С net подключение через транспорты потоков PHP приостанавливает файбер: stream_socket_client(), fsockopen() и построенные на них клиенты. ftp_connect() и socket_connect() подключаются сами, вне слоя потоков, и не покрываются. Разрешение DNS тоже не приостанавливает: имя хоста разрешается на потоке воркера до начала подключения, поэтому медленный резолвер его удерживает. Клиент с собственной сетевой библиотекой (pdo_pgsql и pgsql через libpq, расширение memcached через libmemcached) подключается и ждёт внутри этой библиотеки, которую не видит ни одна из категорий. Клиент, держащий соединение открытым, в любом случае выигрывает на каждом запросе.

    Постоянное подключение — исключение, и оно охватывает больше, чем pfsockopen() и STREAM_CLIENT_PERSISTENT: PDO_MySQL с PDO::ATTR_PERSISTENT, mysqli с префиксом хоста p: и phpredis с pconnect() тоже открывают свои потоки как постоянные, и их подключения удерживают поток на время ожидания, как это было до появления net. Причина — список постоянных соединений. Постоянный поток заносится в него под своим ключом ещё до подключения, поэтому второй файбер, запросивший тот же ключ, пока первый ждёт, нашёл бы поток ещё без дескриптора, принял бы его за мёртвое соединение и закрыл бы его под файбером, который всё ещё подключается. Некоторые клиенты дают каждому соединению собственный ключ (mysqlnd так и делает), и так эти потоки никто найти не может, но сервер решает по тому, постоянный ли поток, а не по тому, кто мог бы его найти, поэтому и они удерживают поток. Цена — само подключение, которое постоянное соединение оплачивает один раз, когда его впервые открывают, а не чтения и записи на нём.

  • MySQL, достигаемый через localhost. Клиент MySQL читает localhost как «использовать unix-сокет», а это не tcp://-поток, поэтому хук streams его не видит. Пишите в DSN 127.0.0.1 или укажите net, которая покрывает потоки unix://. Это касается и PDO_MySQL, и mysqli, и это легко упустить, потому что всё продолжает работать — только без выигрыша.

  • Записи. При одном только streams ждёт только чтение. Готовность к чтению сохраняется, пока дескриптором владеет один файбер и никто другой его не вычитывает, тогда как место в буфере отправки выдаётся и отбирается пиром, поэтому файбер, разбуженный по возможности записи, может обнаружить, что окно снова закрылось к моменту выполнения записи, — после чего PHP в любом случае блокирует поток на весь её таймаут. Запись, заполняющая буфер сокета, ведёт себя поэтому ровно так же, как и без хука. На практике это стоит немного: время занимает ожидание ответа, а не передача запроса ядру. (На записи всё же смотрят — ради одного: чей обмен соединение сейчас несёт, — см. примечание об общих соединениях ниже. На соединении, которым не пользуется никакой другой файбер, — а это каждое соединение в обычном случае, — запись идёт нетронутым нативным путём.)

    С указанной net запись, которой приходится ждать места, тоже приостанавливает файбер, а дескриптор проверяется заново после каждого пробуждения, так что окно, закрывшееся тем временем, стоит ещё одной приостановки, а не потока. Исключение — обычный поток с бесконечным таймаутом (default_socket_timeout=-1): PHP отправляет в него блокирующим send() и никогда не ждёт сам, поэтому приостанавливать нечего, и запись удерживает поток, как и без хука. TLS-поток с бесконечным таймаутом — другое дело: собственный цикл OpenSSL ждёт в poll() без ограничения, и это ожидание приостанавливается.

В этих рамках хук сохраняет нативный контракт: таймауты сокетов (stream_set_timeout(), default_socket_timeout) действуют без изменений, чтение, вышедшее по таймауту, по-прежнему сообщает timed_out через stream_get_meta_data(), и идентичность потока не затронута, так что socket_import_stream() и подобные продолжают работать. Одно ограничение на таймаут: дедлайн проверяется лишь раз за тик планировщика, поэтому срабатывает не раньше следующего тика — в лучшем случае 100 мкс в режиме воркеров и 50 мкс в асинхронном пуле, а под нагрузкой дольше, поскольку тик длится столько, сколько выполняется текущая работа воркера. Асинхронный пул, когда ничего не происходит, делает паузы длиннее этого, но не дальше удерживаемого им дедлайна: дедлайн чтения или записи укорачивает паузу так, чтобы она пришлась на него самого, — так что более долгое ожидание не делает таймаут позже. При default_socket_timeout в 60 секунд это не то, что большинство развёртываний способно заметить.

stream_select() перехватывается ожиданием на дескрипторах, названных тремя её массивами, с последующей передачей вызова PHP с нулевым таймаутом, так что всё наблюдаемое по-прежнему решает PHP: возвращаемый счётчик, перезапись массивов до готовых потоков, предупреждения и ошибки аргументов. Ожидание пропускается — и вызов выполняется ровно так, как без хука, — всякий раз, когда в массивах есть что-то, за что хук не будет ручаться: поток на чтение с уже забуференными данными (stream_select() отвечает на такой из буфера, не глядя на дескриптор), поток вовсе без дескриптора, элемент, не являющийся живым потоком (скажем, закрытый, оставленный в массиве, — PHP отвечает на это ошибкой, а не ожиданием), дескриптор, за готовностью которого ядро вообще не следит (обычный файл — типичный случай, и он считается готовым в момент запроса), или дескриптор на границе FD_SETSIZE и дальше, который собственный select() PHP отвергает сразу. Последнее — реальный потолок, а не формальность: занятый воркер может держать больше 1024 открытых дескрипторов, и stream_select(), называющий один из них, падает одинаково с хуком и без.

Общие соединения между конкурентными файберами

Одно соединение, разделяемое конкурентными файберами, безопасно для клиентов, которых OxPHP охраняет, и ничего не выигрывает. Это нормальная форма приложения в режиме воркеров, а не крайний случай: WordPress, Laravel и Symfony открывают клиентов базы данных и кэша один раз при загрузке воркера и выдают их каждому запросу, и, кроме как переписав их слой доступа к данным, от них не добиться одного соединения на файбер.

Протокол клиента — это последовательность обменов: записать команду, прочитать ответ, — и ничто на соединении не отмечает, где заканчивается один обмен. Файбер, вставший на чтении, стоит посреди обмена, и команда второго файбера, приземлившаяся туда, ломает протокол. Два клиента ломаются на этом по-разному, и оба нехорошо: mysqlnd отслеживает состояние своего соединения и отклоняет команду, ничего не отправив, тогда как у phpredis такой проверки нет, и два файбера читают ответы друг друга — данные одного запроса возвращаются другому без единой ошибки.

Поэтому файбер занимает соединение перед использованием — на обоих уровнях, где это должно происходить: в операциях сокета, которые сохраняют порядок байтов, и во входных точках клиентов PDO и mysqli, поскольку отказ mysqlnd происходит до всякого ввода-вывода и на уровне сокета ничего нельзя успеть сделать, чтобы его предотвратить. Другой файбер, добравшийся до того же соединения, ждёт его освобождения на уровне клиента, где не удерживается ничего, кроме идентичности соединения; на уровне сокета он не ждёт, а получает отказ — так же, как отказывает таймаут сокета, — потому что файбер, приостановленный внутри операции над чужим потоком, держал бы указатель, который владелец может освободить. phpredis по той же причине охраняется тоже на уровне клиента, метод за методом. На уровне клиента занимается само соединение, а не PHP-объект, его держащий, поэтому постоянное соединение, достигаемое через несколько объектов PDO, считается одним, а соединение, открытое через PDO::connect() — который возвращает собственный подкласс драйвера, а не PDO, — покрывается как любое другое.

Читайте выигрыш хука, стало быть, как принадлежащий соединениям, которые файбер открывает для себя: асинхронной задаче с собственными HTTP- или БД-вызовами, запросу, открывающему собственного клиента. Что получает общее соединение — это возвращённый на время ожидания поток воркера, чтобы другие запросы могли выполнять работу, не относящуюся к этому соединению; его собственные обмены идут друг за другом, ровно как с выключенным хуком. Четыре границы стоит знать:

  1. Файбер, выполнивший хотя бы один запрос к базе, держит соединение до конца своего HTTP-запроса, потому что конец запроса — первый момент, заведомо лежащий за концом обмена. Он держит его и пока стоит на чём-то другом, поэтому запрос, который обратился к базе и затем ждёт собственной работы, нуждающейся в том же соединении, ждёт сам себя; двое расходятся по границе из следующего пункта, а не продолжаются.

  2. Ожидание всегда ограничено: меньшим из max_execution_time и default_socket_timeout. Не задайте ни того ни другого — и граница составит 30 секунд, поскольку серверный SAPI берёт умолчания движка: 30 у первого и 60 у второго; 60 от default_socket_timeout она составляет только там, где max_execution_time равен 0. max_execution_time читается таким, каким он в данный момент у запроса, поскольку set_time_limit() — это способ запроса заявить, сколько ему можно работать; default_socket_timeout читается таким, с каким процесс стартовал, потому что это дефолтный дедлайн операции с сокетом, и запрос, сузивший его для собственного вызова (обычное дело для библиотеки, как и оставить это после себя), не должен укорачивать эту границу для запросов, следующих за ним на том же воркере. За границей вызов откатывается на неохраняемое поведение и объясняет причину в журнале сервера: для PDO и mysqli он передаётся клиенту, чей собственный отказ от команды, выданной посреди обмена, — та самая ошибка, которую приложение уже обрабатывает, тогда как для phpredis, у которого такого отказа нет и который вместо этого прочитал бы чужой ответ, поднимается RedisException и ничего не отправляется.

    Один вызов PDO — исключение и здесь, и для кода, выполняющегося вне какого-либо файбера (ниже): PDO::rollBack() на постоянном соединении, которое держит другой файбер, обнаружив на нём открытую транзакцию, ничего не отправляет и возвращает false, потому что эта транзакция принадлежит владельцу, и ROLLBACK, отправленный между её выражениями, завершил бы её. Ошибка, о которой он сообщает так, как велит режим ошибок, — та, что соединение уже записало, и она может оказаться ошибкой владельца. Собственный демонтаж объектов PDO тоже привязан к владельцу. Пока запрос или задача держит постоянное соединение, сброс дескриптора на нём откатывает открытую там транзакцию и выполняет очистку драйвера в конце запроса, только если владелец последним пользовался этим дескриптором (построил его или с тех пор вызвал через него один из методов PDO, которые охраняет OxPHP), где бы дескриптор ни уничтожался, в том числе сборщиком циклов PHP, работающим в другом запросе. Любой другой дескриптор пропускает и то и другое. На соединении, которое никто не держит, каждый дескриптор выполняет и то и другое, как прежде.

    У конфликта на уровне сокета собственной границы нет, потому что он никогда не ждёт: операция сразу завершается неудачей, как таймаут, так что stream_get_meta_data() сообщает timed_out. Это может стоить больше, чем сам вызов: клиент вправе прочитать отклонённую операцию как нечто худшее, чем неудавшаяся команда, и mysqlnd так и делает по обе стороны обмена. Запись, которая ничего не отправила, помечает соединение потерянным и закрывает его, чтение, вернувшееся пустым, помечает его потерянным на месте, и каждая последующая команда на нём получает ответ из этого состояния, не касаясь провода. Теряется всё соединение, а не единственный вызов, — пока хоть что-то удерживает дескриптор. Именно занятие на уровне клиента, описанное выше, не даёт командам охраняемого клиента вообще дойти до такого отказа; протоколу, написанному вручную на сыром сокете, следует обращаться с отклонённой операцией любого вида так же, как с закрытым соединением. Два файбера, каждый из которых держит то, чего ждёт другой, расходятся поэтому по этой границе, а не ждут друг друга вечно.

    Код, выполняющийся вне какого-либо файбера, вообще не может ждать занятия, и к нему применяется только уровень сокета. В режиме воркеров с RUNTIME_HOOKS=streams это прежде всего деструктор, который цикл воркера выполняет между запросами, собирая циклы, пока другие запросы стоят. Операция с сокетом, которую он делает и которой пришлось бы ждать, на соединении, чей владелец стоит на нём в ожидании своего ответа, сразу завершается неудачей, как таймаут, и ничего не отправляет; в любой другой момент она выполняется. Уровень клиента там не применяется, потому что его занятие длится до конца запроса владельца: он отказывал бы такому коду в соединении, которым владелец в этот момент не пользуется, а так бывает большую часть времени. Чего стоит отказ, определяет клиент, как описано выше. phpredis проваливает вызов, а владелец сохраняет свой ответ. mysqlnd либо отклоняет команду по собственному состоянию, либо, когда обмен владельца оставил это состояние готовым, получает отказ на запись и закрывает соединение, и запрос владельца завершается, как описано в пункте 4. Поэтому деструктор — вообще плохое место для использования общего клиента.

  3. Некоторые случаи намеренно оставлены непокрытыми. Объект выражения или результата, удерживаемый между запросами, которому не предшествует ни один занимающий вызов: PDOStatement::execute() на выражении, подготовленном в более раннем запросе, ведёт себя как совсем без занятия, и это стоит больше, чем сам вызов: добравшись до сокета, пока какой-либо файбер стоит на соединении, он получает там отказ, а для mysqlnd отказ означает потерю соединения, как описано выше, — как на чтении, получающем следующую строку небуферизованного результата, так и на записи, отправляющей команду. Протокол, написанный вручную на сыром сокете (записать команду, приостановиться на чём-то другом, прочитать ответ позже), поскольку уровень сокета отказывает, только пока владелец стоит на самом ответе, а между этими двумя точками другой файбер перенимает соединение; три клиента выше покрыты здесь своим занятием на уровне клиента, эквивалента которому у рукописного протокола нет.

    Постоянные соединения покрыты; см. Постоянные соединения PDO ниже.

  4. Закрытие общего соединения, пока другой запрос стоит на его чтении, завершает тот запрос — с 500 и строкой в журнале, называющей произошедшее. Занятие разводит два обмена; сохранить соединение живым оно не может, а ответ стоящего запроса — на соединении, которого больше не существует, — так что честный выход — завершить его, а не вернуть то, что теперь лежит в освобождённой памяти. Какие вызовы способны на это, зависит от клиента, и лишь один из них ждёт: mysqli::close() и mysqli_close() — занимающие вызовы, поэтому они ждут владельца и закрывают, когда это ожидание исчерпано, тогда как Redis::close() тоже занимающий, но поднимает RedisException, а не закрывает. Всё остальное закрывает сразу — fclose() на сыром потоке и, что важно, PDO, у которого метода close() нет вовсе: соединение освобождается сбросом последней ссылки на дескриптор (unset($pdo), переприсваивание, выход из области видимости), и этот путь выполняется внутри собственного демонтажа объектов движка, где никакое занятие не проверяется перед закрытием соединения. Так что помощник переподключения, переприсваивающий общий дескриптор PDO, завершает запросы, стоящие на старом соединении, без ограниченного ожидания, которое получает его аналог на mysqli. Запросу сообщают об этом, как только закрытие произошло, а не на его собственном дедлайне чтения, которым для mysqlnd был бы mysqlnd.net_read_timeout — сутки из коробки. Приложениям, закрывающим общее соединение ради переподключения (обычный случай — $wpdb->check_connection() в WordPress), следует ожидать, что запросы, стоящие на нём в этот момент, завершатся неудачей, а не вернут неверные данные.

Ещё одна граница: работа хука streams под пользовательским планировщиком файберов (AMPHP, Revolt) откатывается к блокирующему вводу-выводу. Файбер, запущенный таким планировщиком, выполняется в собственном контексте, который планировщик OxPHP возобновить не может, поэтому хук это обнаруживает и идёт нативным путём, а не повреждает какой-либо из планировщиков. Речь о файбере, который пользовательский планировщик запускает внутри запроса. Собственный файбер запроса — тот, которым управляет OxPHP, и в режиме воркеров это настоящий Fiber (Fiber::getCurrent() внутри запроса возвращает его), так что библиотеки, которым нужно лишь различать конкурентные запросы, работают без всякого отката. Тот же факт — причина, по которой Revolt отказывается запускать свой цикл событий изнутри запроса режима воркеров.

Постоянные соединения PDO

PDO::ATTR_PERSISTENT держит соединение драйвера в пуле, общем для всего воркера, и PDO обращается к этому пулу в два шага, которые не переживают чередования: перед подключением он ищет соединение в пуле, а построенное регистрирует после. Два перекрывающихся конструктора оба промахиваются мимо пула, и вторая регистрация заменяет первую запись, закрывая то соединение под тем запросом, который читает на нём свой ответ. Перекрытие — обычный случай, поскольку до static $pdo ??= new PDO(...) доходит каждый запрос, пришедший до того, как завершилось первое подключение.

Поэтому конструкторы, запрашивающие постоянное соединение к одному источнику данных, выполняются по одному на воркер, и второй находит то, что зарегистрировал первый. Источник данных — тот, который разрешает PDO (псевдоним из pdo.dsn.* в php.ini считается тем DSN, который он называет), и конструкторы к разным источникам, как правило, друг друга не ждут, так что конструктор, застрявший на подключении к серверу, который принимает соединение и никогда не отвечает, не задерживает конструкторы к исправному. Два случая ждут больше, а не меньше:

  • DSN, совпадающие до двоеточия. DSN сравнивается только до первого двоеточия после префикса драйвера (IPv6-хост — один из способов его получить), потому что PDO склеивает DSN, пользователя и пароль в ключ пула через двоеточия и ничего не экранирует, так что DSN, продолженный на :…, может называть ту же запись пула, что и DSN без продолжения. Источники, совпадающие до этого двоеточия, делят одно ожидание, в том числе застрявшее: два IPv6-хоста с одной и той же начальной группой цифр ждут друг друга так же, как раньше ждали друг друга все конструкторы на воркере.
  • DSN вида uri: и DSN, не являющиеся строками. Их нельзя разрешить, не повторив то, что с ними делает PDO, поэтому такой конструктор ждёт, пока на его воркере не перестанет выполняться ни один постоянный конструктор к какому-либо источнику данных, а пока выполняется он, ждут все остальные. Пока он ждёт, он ничего не удерживает, поэтому не задерживает других, но пока выполняется конструктор к серверу, который никогда не отвечает, такого момента он дождаться не может, а когда его ожидание исчерпано, идёт дальше без сериализации. Так что случай с застрявшим сервером выше всё же затрагивает uri: и объектные DSN, а такой DSN, называющий сам застрявший сервер, задерживает все постоянные конструкторы на своём воркере на всё время своего подключения, ровно как прежде.

Это ожидание ограничено так же, как описанные выше, но то, чем оно оборачивается, когда исчерпано, — не ошибка, которую поднимает клиент: конструктор, чей бюджет истёк, идёт дальше без сериализации и сообщает об этом в журнале сервера, а два конструктора, перекрывшиеся таким образом, заменяют записи друг друга в пуле, как и прежде. Это завершает запрос, читающий свой ответ на заменённом соединении, и — поскольку дескриптор из пула освобождается независимо от того, сколько объектов PDO на него ещё указывает, — может завершить процесс воркера, когда один из этих объектов позже будет уничтожен. Внутри собственного файбера пользовательского планировщика этот бюджет расходуется сразу: такой контекст нельзя приостановить, поэтому конструктор, обнаруживший, что выполняется другой, сдаётся немедленно.

Другой шаг — проверка живости, которую PDO делает, прежде чем выдать соединение из пула: пинг по самому соединению. Отправленный, пока владелец стоит в ожидании своего ответа, он получил бы отказ, PDO прочитал бы соединение как мёртвое и открыл второе, и то, что приложение намеревалось разделять, перестало бы быть общим. Поэтому о соединении, которое держит другой файбер, сообщается, что оно живо, без пинга, но только конструктору, который оставил бы его ровно таким, как есть (там, где драйвер не предоставляет проверки живости, как pdo_sqlite, тот же ответ заменяет собственный ответ PDO). PDO записывает режим ошибок, флаг автокоммита и остальные свои опции на выдаваемый дескриптор из пула, а на используемом соединении эти записи легли бы на владельца. Поэтому соединение разделяется только с конструктором, который не записал бы на него ничего нового:

  • каждая передаваемая им опция — из тех, что PDO хранит на самом дескрипторе (PDO::ATTR_ERRMODE, PDO::ATTR_CASE, PDO::ATTR_ORACLE_NULLS, PDO::ATTR_DEFAULT_FETCH_MODE; запись PDO::ATTR_PERSISTENT, называющая пул, не считается), и
  • каждая несёт то значение, которое у соединения уже есть.

Опция, о которой нужно сообщать драйверу, отвергается при любом значении, включая PDO::ATTR_AUTOCOMMIT и PDO::ATTR_STRINGIFY_FETCHES, на каждом драйвере, сторонние тоже. Конструктору, не прошедшему любое из условий, сообщают, что соединение мертво, и он получает собственное, а журнал сервера говорит об этом не чаще раза в секунду. Для ключа пула, чьи опции несут PDO::ATTR_EMULATE_PREPARES или PDO::ATTR_STRINGIFY_FETCHES, как у Laravel, это значит: соединение разделяется между запросами и между файберами, которые конструируют до того, как обращаться к базе, но конструктор, пришедший, пока соединение держит другой запрос, открывает собственное. Занятие длится от первого запроса владельца к базе до конца его HTTP-запроса.

Соединение, которым никто не пользуется, проверяется как прежде, в том числе такое, чей владелец сбросил свой последний дескриптор, так что действительно умершее соединение по-прежнему заменяется. Эта проверка — такая же команда, как любая другая, и занимает соединение так же: запрос к базе, пришедший через охраняемого клиента, пока она выполняется, ждёт её в пределах обычной границы. Непокрытые случаи, перечисленные выше, остаются непокрытыми и здесь.

Включение и цена

1, true и all включают каждую категорию, включая streams и net. Развёртывание, уже задавшее RUNTIME_HOOKS=1 ради хуков sleep, при обновлении начинает перехватывать сокеты без единой собственной правки; перечислите категории явно (RUNTIME_HOOKS=sleep или RUNTIME_HOOKS=sleep,streams, чтобы сохранить то, что включала 0.12.0), если это не то, чего вы хотите. Включение streams также патчит в памяти одну запись таблицы операций потоков PHP при запуске; страница возвращается в то состояние, в каком была найдена, но на платформе, где её исходную защиту определить нельзя, она остаётся записываемой — небольшая потеря харденинга, о которой сообщается в журнале сервера. Включение net патчит такие же записи в таблицах операций остальных транспортов сокетов и OpenSSL на тех же условиях и заставляет библиотеку-мост отвечать на poll() для вызовов, которые делает собственный код PHP; все остальные вызывающие, включая собственный код сервера, передаются напрямую в libc.

Цена, по замерам: около 3–5 мкс на цикл обращения к сокету на воркере, где больше ничего не стоит, около 5–6 мкс с 64 файберами, стоящими на дескрипторах, и около 7–11 мкс с 200. Готовность разрешается через множество, которое ядро хранит между ожиданиями, поэтому цифра отслеживает, сколько дескрипторов стало готовыми, а не сколько ждёт. Простаивающий воркер ждёт на этих дескрипторах, а не спит фиксированный интервал, замечая готовность на следующем тике, и это значит очень много: слепой сон стоил примерно 2 мс на цикл обращения.

Широкий stream_select() несёт накладные расходы, которых нет в узком случае, потому что каждый названный вызовом дескриптор регистрируется перед ожиданием и снимается после. Измерено с вычетом самого ожидания — каждый дескриптор уже читаем, вызов возвращается сразу, остаются одни накладные расходы: около 6 мкс против 5 мкс без хука на одном дескрипторе, 56 мкс против 9 мкс на 64 и 130–150 мкс против 16–21 мкс на 200 — примерно 0,65 мкс на дескриптор.

Читайте это как фиксированную цену за вызов, а не замедление той же работы. stream_select(), который действительно ждёт — а ради этого цикл select и пишут, — затмевает её: миллисекунда ожидания делает даже цифру для 200 дескрипторов примерно десятой долей вызова, а покупает она поток воркера, который неперехваченный вызов держит всё ожидание. Две формы — исключение, и для них категорию лучше не включать: вызов по многим дескрипторам, который почти никогда не ждёт, потому что нечего возвращать — времени потока не выигрывается; и запрос, который сам есть цикл событий, где потоку всё равно больше нечего выполнять. Клиенты, ждущие на одном соединении — mysqlnd, phpredis, HTTP-обёртки потоков, — сидят в верхних строках таблицы, где накладные расходы около микросекунды.

RUNTIME_HOOKS против oxphp_sleep()

Хук не заменяет собственный примитив — они покрывают разные случаи:

  • Берите oxphp_sleep() / oxphp_usleep() в коде, который пишете сами. Они приостанавливают файбер в режиме воркеров без всякого флага окружения, а oxphp_sleep() принимает дробные секунды (oxphp_sleep(0.25)) — точность, которую нативный sleep() (целые секунды) выразить не может. Исключения для них из мест, где файбер не может приостановиться, нет: там они удерживают поток воркера ровно так же, как перехваченные встроенные функции.
  • Включайте RUNTIME_HOOKS=sleep для кода, который править не можете, например фреймворка или вендорной библиотеки, вызывающей нативные sleep()/usleep() напрямую. Он выключен по умолчанию, вне файбера ничего не делает и сохраняет нативный контракт возвращаемого значения: перехваченный sleep() по-прежнему принимает int, возвращает 0, когда приостанавливается, и то, что возвращает встроенная функция, когда откатывается на неё. Проверка аргумента у него собственная, как сказано выше.

Полагаться на RUNTIME_HOOKS в собственных обработчиках — значит привязать их кооперативность к настройке развёртывания, а не к коду; предпочитайте там явный oxphp_sleep().

Разделяемое состояние

Внутрипроцессные примитивы конкурентности (OxPHP\Shared\Counter, Map, Channel, Mutex, Once, Pool, Atomic, Flag, Registry). Обзор API см. в разделе Разделяемое состояние.

Переменная По умолчанию Описание
SHARED_ENABLED true Булево — см. Булевы значения. Главный переключатель всей подсистемы OxPHP\Shared\*. Установите false — и ни один из её классов не регистрируется: new OxPHP\Shared\Counter() выбрасывает Error: Class "OxPHP\Shared\Counter" not found, а /__ox_shared/* и метрики oxphp_shared_* отсутствуют. В коде, который должен работать в обоих случаях, проверяйте class_exists('OxPHP\Shared\Counter'). Не проверяйте interface_exists('OxPHP\Shared\Shareable'): он остаётся истинным, потому что расширение регистрирует этот интерфейс в любом случае
SHARED_MAX_ENTRIES 100000 Глобальное ограничение на число живых объектов Shared (каждый Counter, Map, Channel, … считается за один). Создание объекта сверх него завершается с CapacityException. Ключи внутри Map в него не засчитываются; ограничивайте Map через maxEntries
SHARED_MAX_BYTES 1073741824 (1 GiB) Глобальное ограничение на оценочный объём памяти по всем объектам Shared, проверяемое только при создании объекта (CapacityException). Уже существующие объекты могут продолжать расти сверх него, так что итог может показывать больше предела
SHARED_SOFT_LIMIT_RATIO — Устарела и игнорируется; её установка пишет WARN при запуске. Она никогда ни на что не влияла: по мере приближения использования к SHARED_MAX_BYTES / SHARED_MAX_ENTRIES ничего не сбрасывается, а сами они, будучи достигнуты, лишь отказывают в создании нового объекта Shared. Для раннего предупреждения ставьте алерт на oxphp_shared_capacity_saturation, публикуемую, пока включена SHARED_METRICS_ENABLED (см. Наблюдаемость разделяемого состояния)
SHARED_METRICS_ENABLED true Булево. Включает/выключает экспозицию метрик oxphp_shared_* в Prometheus
SHARED_INTROSPECTION_ENABLED true Булево. Включает/выключает API интроспекции /__ox_shared/* на внутреннем сервере
SHARED_INTROSPECTION_PREVIEW_ENABLED true Булево. Включает/выключает предпросмотр значений в ответах интроспекции (отключите, когда предпросмотр может раскрыть чувствительные данные)
SHARED_CYCLE_DETECT_DEPTH 16 Глубина BFS при проверке на циклы. Увеличьте для глубоких допустимых графов
SHARED_CYCLE_DETECT_EDGES 10000 Число рёбер, обходимых при проверке на циклы. Увеличьте для плотных допустимых графов
SHARED_MAX_VALUE_SIZE 1048576 (1 MiB) Ограничение размера одного значения. Вставка большего значения завершается сразу с ошибкой
SHARED_MAX_CHANNEL_BYTES 67108864 (64 MiB) Ограничение суммарного объёма данных на один канал
SHARED_POISON_STRICT — Устарела и игнорируется; её установка пишет WARN при запуске. Она никогда ни на что не влияла, и глобальной политики «отравления», которую она могла бы задавать, нет: исключение, выброшенное внутри замыкания Shared\Mutex, никогда не отравляет мьютекс, а то, отключает ли упавшая фабрика Shared\Once ячейку навсегда, выбирается для каждого экземпляра; передайте Once\FailureMode::Poison в конструктор (см. Shared\Once)
SHARED_LOCK_DIAGNOSTICS warn (strict в отладочной сборке бинарника) Межпоточный детектор взаимоблокировок для Shared\Mutex: off, warn (логировать и считать циклы) или strict (ещё и разрывать их через DeadlockException). Только в нижнем регистре; любое другое значение молча даёт умолчание. См. Детектор взаимоблокировок
SHARED_LOCK_POLL_INTERVAL_MS 100 Как часто детектор взаимоблокировок выполняет сканирование, в миллисекундах. Не используется при SHARED_LOCK_DIAGNOSTICS=off
SHARED_PREVIEW_STRING_LIMIT 256 Обрезка каждой строки в предпросмотре /__ox_shared/preview, в байтах (по границе символа)
SHARED_PREVIEW_ARRAY_LIMIT 20 Число записей, выбираемых в предпросмотре /entry?id=…

Профилирование

Сэмплирующий профилировщик, который выдаёт трассы xhprof / speedscope. Форматы вывода и интеграцию с просмотрщиками см. в разделе Профилирование.

Переменная По умолчанию Описание
PROFILER_ENABLED false Булево — см. Булевы значения. Главный переключатель. Все остальные переменные PROFILER_* всё равно разбираются при запуске, поэтому опечатки всплывают сразу
PROFILER_SAMPLE_RATE 0.0 Вероятность (0.0–1.0) того, что запрос будет засэмплирован. Значения вне диапазона ограничиваются границами
PROFILER_INTERNAL false Булево. Если истинно, запросы к внутреннему серверу (/health, /metrics, эндпоинты плагинов) также подпадают под сэмплирование
PROFILER_AUTH_TOKEN (не задано) Секрет, который должны нести явные триггеры (заголовок X-OxPHP-Profile, cookie OXPROF, параметр запроса __oxprof), и bearer-токен для маршрутов /__profiler/* на внутреннем сервере. Не задано или пусто = токен не требуется: проходит любое непустое значение триггера, а маршруты /__profiler/* отвечают без bearer. Сэмплирование по PROFILER_SAMPLE_RATE и PHP-функции OxPHP\Profile\* его никогда не проверяют: при PROFILER_ENABLED=true скрипт, вызывающий OxPHP\Profile\start(), профилирует собственный запрос безо всякого токена
PROFILER_MAX_SPANS 50000 Ограничение числа спанов профиля на один запрос. Профили, превышающие лимит, обрезаются
PROFILER_MAX_DEPTH 0 (без ограничения) Максимальная глубина вложенности записываемых вызовов; более глубокий вызов и, кроме случая саморекурсии глубже 32 фреймов, всё, что он вызывает, в профиле отсутствуют. Их время — нет: оно читается как собственное время вызова на границе. Отброшенные вызовы всё равно засчитываются в PROFILER_MAX_SPANS. Ограничивается значением 32 — глубиной, которую отслеживает наблюдатель движка; при PROFILER_ENABLED=true о большем значении при запуске сообщается, что оно ограничено
PROFILER_OUTPUT_DIR /tmp/oxphp-profiles Каталог для файлов профилей на диске
PROFILER_OUTPUT_FORMATS xhprof,speedscope Список форматов вывода через запятую для записи на диск
PROFILER_DISK_MAX_PER_SEC 10 Ограничение частоты записи файлов профилей на диск в секунду
PROFILER_RETENTION_COUNT 100 Максимальное число файлов профилей, хранимых в PROFILER_OUTPUT_DIR. Более старые файлы удаляются
PROFILER_EXPORT_URL (не задано) Удалённый эндпоинт, на который отправляются профили методом POST. Если задан, запись на диск всё равно происходит, если только PROFILER_OUTPUT_FORMATS не пуст
PROFILER_EXPORT_FORMAT xhprof Формат передачи для отправок на PROFILER_EXPORT_URL
PROFILER_EXPORT_AUTH_TOKEN (не задано) Необязательный bearer-токен, отправляемый с каждым запросом экспорта
PROFILER_EXPORT_XHGUI (автоопределение) Булево. Принудительно включает XHGui-совместимую обёртку для экспортируемого тела. Не задано = автоопределение, когда путь PROFILER_EXPORT_URL оканчивается на /run/import (подсказки в хосте/строке запроса не учитываются)
PROFILER_EXPORT_BUGGREGATOR (автоопределение) Булево. Принудительно включает конверт Buggregator. Не задано = автоопределение, когда путь PROFILER_EXPORT_URL оканчивается на /api/profiler/store. Конверт всегда выдаёт xhprof, поэтому PROFILER_EXPORT_FORMAT для него игнорируется (не-xhprof значение вызывает предупреждение, не фатально). Взаимоисключающ с PROFILER_EXPORT_XHGUI — включение обоих приводит к ошибке при запуске
PROFILER_EXPORT_APP_NAME (не задано) Buggregator app_name для группировки по проектам
PROFILER_EXPORT_TAGS (не задано) Buggregator tags в виде key=value,key2=value2; некорректный токен, пустой ключ или дублирующийся ключ приводит к ошибке при запуске

Примеры конфигураций

Разработка

bash
LISTEN_ADDR=127.0.0.1:8080 DOCUMENT_ROOT=./public LOG_LEVEL=debug ACCESS_LOG=all PHP_WORKERS=1 INTERNAL_ADDR=127.0.0.1:9090

Продакшен (Framework)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php PHP_WORKERS=8 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error MAX_CONNECTIONS=10000 INTERNAL_ADDR=127.0.0.1:9090 RATE_LIMIT=100 RATE_WINDOW_SECONDS=60 TRUSTED_PROXIES=private HEADER_TIMEOUT_SECONDS=5 DRAIN_TIMEOUT_SECONDS=25 COMPRESSION_BROTLI_LEVEL=5 COMPRESSION_ZSTD_LEVEL=6 COMPRESSION_GZIP_LEVEL=6 STATIC_MAX_AGE=30d

Продакшен (режим воркеров)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public WORKER_MODE_ENABLED=true ENTRY_FILE=../worker.php PHP_WORKERS=8 WORKER_MAX_MEMORY_MIB=128 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error INTERNAL_ADDR=127.0.0.1:9090

TLS

bash
LISTEN_ADDR=0.0.0.0:443 TLS_CERT=/etc/ssl/oxphp/cert.pem TLS_KEY=/etc/ssl/oxphp/key.pem DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php

Проверка активной конфигурации

Когда внутренний сервер запущен, обратитесь к эндпоинту /config, чтобы увидеть итоговую конфигурацию:

bash
curl -s http://localhost:9090/config | jq .
json
{ "access_log": "all", "async_in_flight_cap": 0, "async_max_fibers": 256, "async_queue_capacity": 0, "async_workers": 0, "brotli_level": 5, "deny_file": {"loaded": false}, "document_root": "/var/www/html/public", "drain_timeout_seconds": 25, "entry_file": "/var/www/html/public/index.php", "executor_type": "sapi", "gzip_level": 6, "header_timeout_seconds": 5, "listen_addr": "0.0.0.0:443", "log_level": "warn", "max_connections": 10000, "max_query_body": 524288, "php_workers": "8", "plugins": { "apm": { "db_capture_params": false, "enabled": true, "hooks_registered": 34, "slow_query_ms": 100 }, "otel": { "enabled": true, "protocol": "grpc", "service_name": "oxphp" } }, "queue_capacity": 1024, "queue_max_waiting": 1024, "queue_max_waiting_bytes": 67108864, "queue_wait_timeout_ms": 1000, "rate_limit": 100, "rate_window_seconds": 60, "runtime_hooks": [], "static_max_age": 2592000, "static_revalidate": false, "superglobals_enabled": true, "tls_enabled": true, "tls_min_version": "1.2", "tokio_workers": 4, "trace_context": true, "trusted_proxies": false, "worker_max_memory_mib": 0, "worker_mode_enabled": false, "zstd_level": 6 }
Note

Отдаваемый ответ /config вычищает несколько ключей, которые несёт внутреннее представление Config: пути к TLS-сертификату и ключу никогда не выводятся (tls_enabled показывает, активен ли TLS), а internal_addr и error_pages_dir удаляются — это топология развёртывания и пути в файловой системе, которые помогают злоумышленнику и не нужны сборщикам метрик.

Промпты для вашего агента

Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.

prompt
Настрой OxPHP под мой проект. Прочитай страницу `operations/configuration` через MCP-сервер документации OxPHP. Посмотри, что работает в этом репозитории — фреймворк, сколько воркеров он может себе позволить, какие запросы он обслуживает, — и дай мне блок переменных окружения для продакшена с обоснованием каждого выбранного значения.
prompt
Проверь мою конфигурацию OxPHP. Прочитай страницу `operations/configuration` через MCP-сервер документации OxPHP. Сравни переменные окружения, которые задаёт этот репозиторий, со значениями по умолчанию, и скажи мне, какие из моих переопределений ничего не дают, какие опасны и какие умолчания мне стоило переопределить, но я этого не сделал.

См. также

Нашли ошибку? Сообщите →