Memory and GC

Memory Limits, Copy-on-Write and Garbage Collection

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:

memory.php: copy-on-write and the cycle collector, measuredPHP
<?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());
Output
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.