0

PHP-FPM process model — worker pools, max_children, pm.dynamic

Advanced5 min read·eng-09-002
interviewperformance

Concept

PHP-FPM (FastCGI Process Manager) is the process model that separates PHP execution from your web server. The web server (Nginx, Caddy) passes HTTP requests over a FastCGI socket to a pool of PHP worker processes. Each worker is a fully isolated OS process — not a thread — with its own memory space. When a worker finishes processing a request, it resets its globals and waits for the next one. It does not share heap memory with sibling workers, which is why PHP's "shared-nothing" model is fundamentally different from Node.js or Java.

The pool manager (pm) setting controls how workers are spawned and reaped. pm = static keeps a fixed number of workers alive — simple but wastes memory during quiet periods. pm = dynamic starts with pm.start_servers workers, always keeps at least pm.min_spare_servers idle, and scales up to pm.max_children. pm = ondemand starts with zero workers and spawns on demand up to pm.max_children, killing idle workers after pm.process_idle_timeout. In practice, dynamic is the default and best for most web apps; ondemand suits low-traffic services where you want to free RAM between bursts.

Tuning pm.max_children is one of the most impactful production levers you have. The formula is: max_children = available_RAM / average_worker_memory. If you set it too low, requests queue and latency spikes. If you set it too high, the OS starts swapping, which is catastrophic. You measure average worker memory with ps --no-headers -o rss -p $(pgrep php-fpm) | awk '{sum += $1} END {print sum/NR/1024 " MB"}'.

Code Example

php
<?php
declare(strict_types=1);

// This is the lifecycle of a single PHP-FPM worker request:

// 1. Worker is forked from master at startup (or on demand).
//    All extensions are loaded, OPcache shared memory is mapped.

// 2. Worker signals READY to the master via the FastCGI accept loop.

// 3. Nginx sends the request over the unix socket / TCP port.
//    The worker calls accept(), reads the FastCGI envelope,
//    and populates $_SERVER, $_GET, $_POST, php://input, etc.

// 4. Your application runs. Any static variables or globals
//    are isolated to THIS process only.

// 5. At the end of the request:
//    - Output buffers are flushed
//    - register_shutdown_function() callbacks fire
//    - fastcgi_finish_request() can be called to release the connection
//      while continuing work (common for deferred tasks)
//    - The Zend Engine runs gc_collect_cycles() if thresholds are met
//    - Global state is torn down and the worker loops back to accept()

// pm.max_requests = 500 means after 500 requests the worker exits
// and the master forks a fresh one — this guards against memory leaks.

// Check real-time pool status:
// curl --unix-socket /run/php-fpm/www.sock http://localhost/status

Interview Q&A

Q: A production server under load shows PHP-FPM workers at 100% utilisation and requests are queuing. Walk me through how you diagnose and fix this.

Start with systemctl status php-fpm and cat /var/log/php-fpm/www.error.log to rule out crashes or OOM kills. Then check pm.max_children against available RAM. If RAM is not the bottleneck, the workers are spending too long per request — profile with tideways or Blackfire to find slow DB queries or blocking I/O. If the requests are legitimately fast but volume is high, raise max_children (after confirming RAM headroom) and consider adding more application servers behind a load balancer. Also check pm.max_requests: if it is set very low and traffic is high, the master is constantly forking, which adds latency. I would also look at listen.backlog — if it is too small, connections are being rejected at the socket level rather than queued, which manifests as 502s rather than slow responses.


Q: What is fastcgi_finish_request() and when would you use it?

fastcgi_finish_request() flushes all output to the client and closes the FastCGI connection, but keeps the PHP process alive to continue executing. This means the user's browser receives the HTTP response immediately while your script continues doing work — sending emails, writing to a log, queuing a job — without blocking the HTTP response. It is a lightweight alternative to a proper queue for deferred work that is acceptable to lose if the process crashes. The caveat is the worker is still occupied during the post-response work, so it cannot accept another request until it finishes. For work that takes more than a few milliseconds, a real queue (Redis + Horizon, SQS) is the right answer. fastcgi_finish_request() is most useful for things like firing off a webhook notification or appending to an analytics log where you want sub-10ms overhead on the response without setting up a full queue infrastructure.


Q: How does PHP-FPM's process model affect the use of persistent database connections (pconnect)?

With persistent connections, PHP keeps a database connection open inside the worker process across requests rather than tearing it down at the end of each request. Since each FPM worker is a separate OS process, you get max_children persistent connections in the database's connection pool simultaneously. If max_children = 50 and you have 3 app servers, you have up to 150 persistent connections to MySQL whether requests are happening or not. This can exhaust max_connections on the database. Persistent connections also carry state risk: if the previous request left the connection in a transaction or set a session variable, the next request inherits that. MySQL's wait_timeout will close idle connections on the DB side without telling PHP, causing the next use to throw a "MySQL server has gone away" error. For these reasons most Laravel deployments use non-persistent connections and offload connection pooling to PgBouncer (Postgres) or ProxySQL (MySQL) instead.