Infrastructure
PHP Workers, OPcache and Autoloading
A fixed number of PHP workers serve every request. What happens when they are all busy, what OPcache keeps in shared memory, and what 400 autoloaded classes cost.
Traffic arrives in bursts, and your web server has to decide how many PHP processes may be working at once. That number is not a tuning preference. It is the ceiling on how much of the application can be in flight, and when the ceiling is reached the extra requests do not fail. They wait.
A request that waits is a request that is slow, and the visitor cannot tell a slow plugin from a slow queue. If four workers exist and sixteen requests are in flight, an endpoint that takes 300 ms instead of 30 ms puts every other request behind it. That is the mechanism behind the outage where the home page is fine when you test it and down when a hundred people arrive.
The numbers below come from a lab box running PHP 8.5.6, WordPress 7.0.7, MySQL 9.6 and Redis 8.10. The pool measurements use a fixed pool of PHP CGI worker processes rather than FPM, because FPM on that host accepted connections and returned responses with no body. The pool size is fixed either way and the queueing behaviour is the same shape.
An FPM pool is a fixed number of processes, and pm.max_children is the concurrency ceiling
PHP-FPM starts a master process and a set of workers. The master owns the listening socket and hands each incoming connection to an idle worker. When every worker is busy, the master keeps the connection open and does nothing with it. Every tuning decision follows from that.
The pool settings that decide behaviour are these:
| Directive | What it does |
|---|---|
pm |
How the pool is managed. static keeps a fixed number of workers alive. dynamic adds and removes them between bounds. ondemand forks workers only when a request arrives, which is what makes the first request after an idle period slower. |
pm.max_children |
The hard cap on workers. This is the maximum number of requests your site can be executing at one time, no matter how many visitors are waiting. |
pm.max_requests |
How many requests a single worker serves before it exits and is replaced. It exists to bound memory leaks in code you cannot fix; every replacement starts cold. |
pm.max_requests is the one people set and then forget. If eight workers
each recycle after 500 requests, the whole pool has been replaced every 4,000 requests:
at twenty requests per second, once every three minutes. Each new worker compiles the
files it needs before it can answer.
When every worker is busy, the request waits, and the wait is visible
The lab pool has four worker slots. Sending sixteen requests at once to an endpoint that sleeps for 300 ms produces four waves:
slots=4, concurrency=16, endpoint sleep=300ms
request 1..4 wait=0.0ms service=374ms
request 5..8 wait=360ms service=371ms
request 9..12 wait=722ms service=373ms
request 13..16 wait=1087ms service=372ms
client side: min=0.426s median=0.999s p95=1.566s max=1.566s
Separate the two columns. Service time is flat: each request takes about 370 ms of work whether it arrived first or last. Wait time grows in steps of one service time. No error appears, no connection is refused, and the only evidence is latency.
Raising the pool to eight slots and repeating the same sixteen requests gives
median=0.668s p95=0.872s. Doubling the workers halved the median wait,
because the queue is mechanical: ceil( concurrent requests / workers ) waves
of service. If you can count your workers and your peak concurrency, you can predict the
worst case without a load test.
A slow plugin turns into a site-wide outage, not one slow page
A slow endpoint makes fast endpoints slow. The lab ran four requests to the 300 ms endpoint and eight to the home page at once, against four slots. The home page takes about 350 ms on its own:
slots=4, 4 x slow(300ms) + 8 x homepage in flight together
homepage alone median 0.35s
homepage under this load min 0.99s median 3.03s max 3.52s
homepage queue wait 0.1ms .. 2769ms
slow endpoint queue wait 0.1ms .. 3138ms
The home page did no extra work; its own service time was unchanged. It spent up to 2.8 seconds waiting for a slot while four workers were occupied by a plugin that sleeps. That is how a search endpoint, a stock sync, or an unindexed query becomes an outage for the checkout page: the pool has no notion of which request deserves a worker.
Three consequences follow. Cache the expensive path instead of optimising it. Keep the number of requests that can occupy a worker low on anything that touches a remote service. Size the pool against the slowest thing you allow, because it is shared by every page.
OPcache keeps compiled files in shared memory, and it can fill
PHP compiles a file every time it includes it, unless OPcache is enabled, in which case the compiled opcodes live in shared memory and the next request skips compilation. A lab process that includes 60 files reports this:
php -d opcache.enable_cli=1 op_settle.php
start cached=1 hits=0 misses=1 used_mb=8.746
after-pass-1 cached=61 hits=0 misses=61 used_mb=8.809
php -d opcache.enable_cli=0 op_settle.php
start cached=0 hits=0 misses=0 used_mb=0.000
after-pass-1 cached=0
cached is num_cached_scripts from
opcache_get_status( true ). With OPcache off there is no cache and every
include is recompiled on every request. With it on, 60 files cost about 63 KB above the
8.7 MB baseline, and a fresh process in the same configuration starts from scratch rather
than from the previous process's cache. Sharing across processes is the property that
matters in production, and the reason FPM keeps a pool rather than a process per
request.
Two settings decide whether the cache fits:
| Setting | What happens when it is too small |
|---|---|
opcache.memory_consumption |
Bytes of shared memory for compiled files. When it fills, OPcache restarts itself and throws the whole cache away, so every worker goes cold at once. Read oom_restarts from the status output: any non-zero value means the cache is too small for the working set. |
opcache.max_accelerated_files |
The number of files the hash table can hold. Under the 10,000 default, a large plugin set can push the count over the limit and cause repeated restarts. Read hash_restarts, and compare num_cached_scripts against the limit. |
A hit rate near zero on a long-lived pool means OPcache is not doing its job, and the two usual causes are a restart loop or a warm-up that never happened because a page cache answered first. After a deploy the first requests compile everything, which is why deploys should warm the cache before taking traffic.
opcache.validate_timestamps decides whether a deploy needs a restart
With opcache.validate_timestamps=1, PHP checks the modification time of a
cached file and recompiles it if it changed, subject to
opcache.revalidate_freq. Deploying a file is enough for the change to take
effect within that interval. With the setting off, PHP never checks, and a deployment that
only writes files keeps serving the old compiled code until the pool restarts or the cache
is cleared. That is a correctness bug with a performance setting's name, which is why hosts
that turn validation off also reload PHP-FPM on deploy. If you turn it off, reload and warm
the pool after every deploy.
A plugin with 400 autoloaded classes costs about 13 ms and 450 KB per request
Composer's autoloader resolves a class name to a file path and includes it. The
surprise is not the path lookup, it is the includes. A lab plugin with 400 classes in
src/M5, autoloaded through PSR-4, measured like this when a request asked for
every class:
400 classes, one request, OPcache not available to the pool
load400_us: min=11532 median=12928 p95=15937
memory_kb: 451
included_files: 405
Roughly 13 ms and 451 KB, for classes the request may not even use. The autoloader's own
resolution work is small by comparison: 28 lookups for class names that do not exist took
a median of 0.88 ms, about 2 microseconds per failed lookup. When the classes exist, the
require dominates.
Two commands change the shape of that cost. composer dump-autoload -o
builds a class map, so a known class name is an array lookup instead of a directory
search:
cd wp-content/plugins/m5-autoload
php composer.phar dump-autoload -o # class map, PSR-4 fallback kept
php composer.phar dump-autoload -o --classmap-authoritative # no fallback
On the same machine, loading the same resident classes through the map versus through
PSR-4 came out at median 1738 us against 1799 us. The map is
about 3% faster, which is the honest size of the win: it removes the filesystem lookup,
not the include.
Authoritative classmaps are a different trade. They answer every unknown class with "not found" instead of falling back to the filesystem, which prevents a lookup for each missing name. The cost is that every class the plugin loads has to be in the map at build time, so a class added after the dump is not found at runtime. Use it when the build is part of the deploy, not as a flag somebody set once on a live site.
What could not be measured here
FPM's pm.max_requests recycling could not be measured, because FPM on
this host returned empty responses, so the pool used in the lab forks a new PHP process
per request and has no recycling to observe. The queueing numbers are real measurements
of a fixed-size pool; the FPM directive names and their meanings are from the FPM
documentation, not from the lab.
Find out what your own host gives you, then prove the queue exists.
First, the pool. Ask your host for pm,
pm.max_children and pm.max_requests, or read them from the
pool configuration if you have access. Write down the worker count: that is your
maximum concurrency.
Second, the queue. Add a probe endpoint to a staging copy that sleeps for 300 ms, and drive it with a concurrent client while a second client requests your home page. If you do not have a load tool, a shell loop with background jobs is enough:
for i in $(seq 1 16); do
curl -s -o /dev/null "https://staging.example/slow-probe.php" &
done
wait
Third, OPcache. Put this in a must-use plugin and load it once:
$st = opcache_get_status( true );
if ( is_array( $st ) ) {
$s = $st['opcache_statistics'];
error_log( sprintf(
'opcache: cached=%d hits=%d misses=%d rate=%.1f%% oom=%d hash=%d used=%.1fMB free=%.1fMB',
$s['num_cached_scripts'], $s['hits'], $s['misses'], $s['opcache_hit_rate'],
$s['oom_restarts'], $s['hash_restarts'],
$st['memory_usage']['used_memory'] / 1048576,
$st['memory_usage']['free_memory'] / 1048576
) );
}
Then answer these, with the numbers written down. How many workers does the site
have, and what is the worst-case wait for twenty concurrent requests? Did the home
page get slower when the probe was busy, and by how much? What was
num_cached_scripts on the first read and on a read after twenty page
loads, and is opcache.validate_timestamps on or off, and what does your
deploy process do about that?
Show the solution and the reasoning
The point of the lab is to replace two assumptions with numbers. The first is that the site can serve unlimited concurrent requests; it cannot, and the worker count is the limit. The second is that OPcache is working; it is working only if the counters say so.
For the pool, the arithmetic is the useful part. With w workers and
c concurrent requests to an endpoint that takes s seconds,
the last request waits about ( ceil( c / w ) - 1 ) * s seconds before it
starts. Four workers, sixteen requests, 300 ms of work predicts 900 ms of wait, and the
lab measured 1087 ms. The prediction is close enough to plan with, and it explains why
a fix that shortens s helps disproportionately: it shrinks both the
service and the wait.
For the home page, the honest answer is that its own code did not get slower and its measured latency did. Anyone reading only the application logs will conclude the home page is fine. Anyone reading user-facing latency will see a site that is down. That gap is why this lesson comes before the load-testing module: the queue is a real component of your response time even though no line of your code implements it.
For OPcache, the two counters to read are oom_restarts and
hash_restarts. Zero on both means the cache has never had to throw itself
away. A rising num_cached_scripts that settles below
opcache.max_accelerated_files and below
opcache.memory_consumption in megabytes is a cache that fits. A hit rate
near zero on a pool that has served hundreds of requests means the requests are
arriving cold, which usually means a page cache is answering before PHP runs, or that
workers are recycling faster than they can warm up.
One reading that is easy to get wrong: if the probe endpoint is behind a page cache,
the second request never reaches PHP and the OPcache counters do not move. Check
opcache_get_status() from an endpoint the page cache excludes, or from
WP-CLI, where the page cache is not involved. The same trap applies to
validate_timestamps: if you turn it off and never reload the pool, your
next deploy will appear to have had no effect, which looks exactly like a broken
deploy.
Progress
Mark this lesson done and it stays marked in this browser.