DEV Community

Cover image for Understanding PHP-FPM's process manager (by actually watching it)
kevariable
kevariable

Posted on

Understanding PHP-FPM's process manager (by actually watching it)

I thought I understood pm = dynamic until I sat down and watched it work in real time. It turns out the whole process manager is one tiny loop, and once you see the loop, every number in fpm-status suddenly makes sense.

This is what I learned, with real outputs from a real server.

The config

pm = dynamic
pm.max_children = 300        ; hard ceiling on total workers
pm.start_servers = 5         ; workers created at boot
pm.min_spare_servers = 5     ; never fewer than 5 idle
pm.max_spare_servers = 50    ; never more than 50 idle
pm.max_requests = 1000       ; recycle each worker after 1000 requests
Enter fullscreen mode Exit fullscreen mode

The whole algorithm is one loop

FPM's master process runs one check, roughly every second:

How many workers are idle? Fewer than min_spare_servers? Fork one. More than max_spare_servers? Kill one. Otherwise, do nothing.

That is it. Everything below falls out of this one rule.

The mystery of the 6th worker

I booted FPM with start_servers = 5 and immediately saw 6 workers. Where did the extra one come from?

t=0  boot
     5 workers, all idle
     check: 5 idle. fine.

t=1  a request comes in
     1 busy + 4 idle = 5 total
     check: 4 idle < 5 min -> FORK ONE

t=2  1 busy + 5 idle = 6 total
     check: fine.

t=3  request done
     0 busy + 6 idle = 6 total
     check: 6 > 50 max? no -> do nothing
Enter fullscreen mode Exit fullscreen mode

And there it stays: 6.

The part everyone misses is t=3. FPM never shrinks back to start_servers. The kill rule only fires above max_spare_servers. Between the two bounds, the pool simply stays wherever demand pushed it. It ratchets up easily and trims lazily. start_servers only matters for the first second of the pool's life.

Watching it happen

Two seconds after a restart:

start since:          2
idle processes:       4
active processes:     1
total processes:      5
Enter fullscreen mode Exit fullscreen mode

The 1 active worker is my own curl. The status request is itself served by a worker, so checking the counter increments the counter. And 4 idle is already below min_spare_servers = 5, which means the fork is about to happen. Thirty seconds later:

start since:          34
accepted conn:        99
idle processes:       5
active processes:     1
total processes:      6
Enter fullscreen mode Exit fullscreen mode

There is the 6th worker. The 99 accepted connections in 34 seconds are my metrics exporter polling /fpm-status a few times per second. That constant trickle is what kept one worker busy and triggered the fork. (accepted conn is a lifetime odometer, by the way. It never resets while the master lives, only on restart or reload.)

What happens under load

I ran a k6 benchmark with 100 virtual users and watched the pool breathe:

quiet:   50 total (49 idle + 1 active)     <- parked at max_spare_servers
burst:   72 total (~70 busy)               <- grew on demand, ceiling is 300
after:   glides back down to 50 over ~a minute
Enter fullscreen mode Exit fullscreen mode

So the worker count floats between three levels:

  • floor: ~5-6 on a truly idle server (min_spare keeps 5 idle alive)
  • parked after traffic: ~50, because trimming stops once idle workers are down to max_spare_servers
  • under load: whatever demand needs, up to max_children

One sentence to remember: the spare settings bound the idle workers, max_children bounds all workers, and the actual count floats between them based on traffic.

Also worth knowing: when load stops, active collapses instantly, but total shrinks slowly on purpose, roughly one worker per second. A traffic dip followed by another spike should not cause a fork-storm.

How much RAM is that?

Each worker holds the framework and its request data in its own memory. You can see it live:

ps -ylC php-fpm --sort:rss
Enter fullscreen mode Exit fullscreen mode

The RSS column is bytes-in-use per process, in KB. Measured on my Laravel app, warm, under load:

workers + master: 51
total RSS:        1866 MB
avg per process:  36.6 MB
Enter fullscreen mode Exit fullscreen mode

The distribution was remarkably tight: nearly every worker sat within 1 MB of the average. Fresh workers start around 14 MB and grow to ~37 MB once they have served requests, so always measure warm and under load, or you will size your pool from the wrong number. A heavy CMS with many packages can run 150-250+ MB per worker.

Which brings us to the sizing formula:

max_children = (RAM for PHP x 0.9) / avg process size
Enter fullscreen mode Exit fullscreen mode

Plugging in my own numbers: 7.7 GB x 0.9 / 37 MB is about 187 children. My config says 300. That means 300 x 37 MB is ~11 GB, more RAM than the box has. If a spike ever pushed the pool to its configured ceiling, the OOM killer would start shooting workers mid-request. I got away with it because my real peak was 72 workers, but strictly speaking my own config flunks my own math. Do the division.

What actually happens at the ceiling

A detail that confused me: max_children = 300 is not a connection limit. It is a concurrency limit. Each worker processes exactly one request at a time, so 300 is how many requests can be in progress simultaneously. Everything else waits:

requests 1-300      -> each gets a worker, processed now
requests 301-4396   -> parked in the kernel's listen queue (backlog 4096)
beyond that         -> the kernel drops new connections, nginx returns 502
Enter fullscreen mode Exit fullscreen mode

Which gives an overloaded FPM box a very recognizable degradation ladder:

workers free           -> fast responses      (healthy)
workers full, queue ok -> slow responses      (latency, silent)
queue full             -> 502s from nginx     (visible failure)
Enter fullscreen mode Exit fullscreen mode

Note who fails: when the queue is full, the kernel rejects the connection before FPM ever sees it. PHP never runs for that request, which is why saturated PHP shows up as gateway errors, not PHP errors.

And 300 concurrent is not 300 requests per second. If each request takes 50 ms, 300 workers can push ~6,000 requests per second. Concurrency times speed equals throughput, which is why my 723 rps benchmark never needed more than ~70 workers.

The status page, decoded

curl localhost:8088/fpm-status
Enter fullscreen mode Exit fullscreen mode
  • total / active / idle processes: workers alive now, working vs waiting
  • max active processes: high-score counter, the most workers ever busy at the same moment. Compare it to max_children to see how close you have come to the ceiling
  • max children reached: how many times ALL workers were busy and a new request had nowhere to go. This is the single most important number on the page. Anything above 0 means real users waited because the pool was too small
  • listen queue: requests waiting for a free worker right now. The queue is where latency hides before errors appear
  • listen queue len: the queue's capacity (min of listen.backlog and the kernel's somaxconn)
  • all of these reset on every restart or reload, which is also why Prometheus samples them into rates instead of trusting raw counters

Takeaways

  1. start_servers only matters for the first second. After that, the spare bounds own the pool.
  2. The pool never returns to its boot size. It parks at max_spare_servers after traffic.
  3. max_children is a memory budget, not a guess: RAM for PHP divided by measured process size.
  4. It is a concurrency ceiling, not a throughput ceiling. Faster requests raise throughput without more workers.
  5. Watch max children reached and the listen queue. Too low a ceiling means queueing while CPU idles; too high means swap or the OOM killer. The queue length itself fixes nothing, it only chooses how long users suffer before the 502.

Top comments (0)