Memory decides how many FPM workers fit in RAM (Sizing pm.max_children). PHP frees a value the instant its reference count reaches zero, copy-on-write (References and Scope) delays copies until a write, and reference cycles escape refcounting until the cycle collector finds them:
<?php
$mb = fn(): string => sprintf('%5.1f MB', memory_get_usage() / 2 ** 20);
$a = range(1, 500_000);
$b = $a; // shares one buffer (Section 4.4.10)
echo "two names, one array ", $mb(), "\n";
$b[] = 1; // the first write separates the copy
echo "after one write ", $mb(), "\n";
unset($a, $b);
class Node { public ?Node $peer = null; }
gc_disable();
for ($i = 0; $i < 100_000; $i++) {
[$x, $y] = [new Node(), new Node()];
[$x->peer, $y->peer] = [$y, $x]; // a cycle: refcounts never reach zero alone
}
echo "100k cycles, no gc ", $mb(), "\n";
gc_enable();
printf("gc_collect_cycles() freed %d, now %s\n", gc_collect_cycles(), $mb());two names, one array 8.4 MB after one write 16.5 MB 100k cycles, no gc 13.1 MB gc_collect_cycles() freed 199998, now 2.4 MB
The collector, on by default, runs once 10,001 possible cycle roots pile up (gc_status() shows this), so cycles cost time rather than growing forever. memory_limit caps one request: 128M in Ubuntu 225 's FPM php.ini, none in the CLI. Raise it per pool with php_admin_value[memory_limit] only where needed, stream big results with generators (Generators and yield), and set pm.max_requests = 500 to recycle a worker that leaks inside a C extension.