Жизненный цикл запроса и модель shared-nothing

Вопрос, который проверяет, понимаете ли вы, почему PHP устроен не как Node.js и не как Java.

Shared-nothing — модель выполнения, при которой каждый запрос обрабатывается в чистом окружении: переменные, объекты и подключения не переживают завершение скрипта.

Вопрос 1: «Что происходит с момента прихода запроса до отдачи ответа?»

Что проверяет интервьюер

Знаете ли вы, кто именно исполняет ваш код в продакшене. Ответ «Apache запускает PHP» звучит как знание 2010 года; сегодня в 9 случаях из 10 это связка nginx + PHP-FPM. Разложите ответ по шагам:

  1. nginx принимает HTTP-запрос и по правилу location ~ \.php$ отдаёт его по протоколу FastCGI менеджеру процессов PHP-FPM.
  2. FPM выбирает свободный воркер — отдельный процесс из заранее созданного пула.
  3. Воркер собирает суперглобальные массивы ($_GET, $_POST, $_SERVER, $_COOKIE), находит входной файл (index.php) и запускает его.
  4. PHP компилирует исходники в опкоды (или берёт их готовыми из OPcache) и исполняет виртуальной машиной Zend Engine.
  5. Скрипт формирует ответ, который через FastCGI возвращается в nginx, а оттуда — клиенту.
  6. Наступает shutdown: выполняются функции, зарегистрированные через register_shutdown_function, вызываются деструкторы, освобождается вся память запроса. Воркер очищается и берёт следующий запрос.
<?php
register_shutdown_function(function () {
    echo "3. shutdown: соединения закрыты\n";
});
$counter = 0;
echo "1. обработка запроса\n";
$counter++;
echo "2. ответ отправлен, счётчик = $counter\n";

Вывод:

1. обработка запроса
2. ответ отправлен, счётчик = 1
3. shutdown: соединения закрыты

Вопрос 2: «Почему статическая переменная не сохраняется между запросами?»

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

<?php
class Counter {
    public static int $count = 0;
}
Counter::$count++;
Counter::$count++;
echo "внутри одного запроса: ", Counter::$count, "\n";
echo "после завершения скрипта это состояние исчезнет\n";

Вывод:

внутри одного запроса: 2
после завершения скрипта это состояние исчезнет

Отсюда практические следствия, которые и хочет услышать интервьюер:

  • Общее состояние нужно выносить наружу — в Redis, Memcached или базу. Кэш «в статическом свойстве» живёт ровно один запрос.
  • Утечки памяти почти не страшны. Процесс всё равно очистится; поэтому PHP прощает то, за что в долгоживущем приложении пришлось бы расплачиваться.
  • Нет состояния гонки между запросами внутри PHP-кода: параллельные запросы — это разные процессы с разной памятью. Гонки возникают только на общих ресурсах — в базе, файлах, Redis.
  • Соединения с базой не переиспользуются по умолчанию — на каждый запрос новое (либо persistent-соединение или внешний пулер вроде PgBouncer).

Вопрос 3: «Как настраивается пул FPM и на что влияет?»

; /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic            ; статический / динамический пул воркеров
pm.max_children = 20    ; потолок одновременных запросов
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500   ; перезапуск воркера после N запросов (лечит утечки)
request_terminate_timeout = 30s

Главное число здесь — pm.max_children: это максимум одновременно обрабатываемых запросов. Считают его от памяти: доступная память делится на средний расход одного воркера. Поставите слишком много — сервер уйдёт в swap; слишком мало — запросы встанут в очередь FPM и клиенты получат таймаут. На собеседовании ценят, если вы упомянете, что медленный внешний API легко «съедает» весь пул: воркер занят ожиданием, а не вычислениями.

Стоит знать и про альтернативы классической модели: долгоживущие рантаймы (RoadRunner, Swoole, FrankenPHP) поднимают приложение один раз и держат его в памяти между запросами. Это быстрее, но требования меняются радикально — утечки, глобальное состояние и незакрытые ресурсы начинают накапливаться, как в Java.

Типичные ошибки кандидатов

  • Считают, что PHP — многопоточный, и переживают за состояние гонки между запросами внутри кода.
  • Уверены, что static-кэш переживает запрос, и строят на этом «оптимизации».
  • Путают max_children с числом ядер — это про память и характер нагрузки, а не про CPU.
  • Не знают, что происходит после echo: забывают про фазу shutdown, деструкторы и fastcgi_finish_request(), который позволяет отдать ответ и продолжить работу.
  • Говорят «PHP медленный, потому что интерпретируемый», не упоминая компиляцию в опкоды и OPcache.

Как ответить кратко

«nginx передаёт запрос в PHP-FPM, свободный воркер собирает суперглобальные массивы, PHP компилирует код в опкоды (обычно берёт их из OPcache) и исполняет; после ответа идёт shutdown — деструкторы и полная очистка памяти. Это модель shared-nothing: между запросами не сохраняется ничего, поэтому общее состояние живёт в Redis или базе, а параллельные запросы — это отдельные процессы, а не потоки».

Проверьте себя
1. Что произойдёт со значением статического свойства класса после завершения HTTP-запроса в классической связке nginx + PHP-FPM?
AОно сохранится в том же воркере и будет доступно следующему запросу
BОно автоматически сохранится в сессии
CОно будет уничтожено вместе со всей памятью запроса
DОно сохранится, если включён OPcache
2. На что в первую очередь влияет параметр pm.max_children в конфигурации PHP-FPM?
AНа число потоков внутри одного воркера
BНа размер кэша опкодов
CНа максимальное время выполнения скрипта
DНа максимальное число одновременно обрабатываемых запросов
3. Почему в PHP-приложении обычно не возникает состояния гонки между двумя параллельными HTTP-запросами на уровне кода?
AПотому что запросы обрабатываются разными процессами с изолированной памятью
BПотому что PHP выполняет запросы строго по очереди
CПотому что Zend Engine использует глобальную блокировку
DПотому что все переменные в PHP неизменяемые