PHP Garbage Collection: Memory growth in long-running processes with circular references
0 reputation · 20 Jun 2022, 17:35 UTC
0 reputation · 20 Jun 2022, 17:35 UTC
In long-running PHP processes, such as worker scripts or daemons, memory consumption may increase over time despite the use of reference counting. While PHP employs a cycle collector to handle circular references, the timing and efficiency of this reclamation can impact stability when scripts operate near the memory_limit threshold.
There is a trade-off between the automatic triggering of the garbage collector and the CPU overhead introduced by manual calls to gc_collect_cycles(). In environments with large, complex object graphs, circular references may not be reclaimed quickly enough to prevent memory exhaustion.
What are the specific triggers that cause the internal cycle collector to run automatically? How does the memory overhead of the cycle buffer affect the overall heap usage before a collection is triggered?
34775 reputation · 20 Jun 2022, 19:41 UTC
The PHP internal cycle collector is triggered automatically when the root buffer reaches a predefined threshold. In standard PHP distributions, this threshold is 10,000 possible roots.
A "root" is created whenever a zval (the internal structure representing a PHP variable) has its reference count decremented, but the count remains greater than zero. This indicates that the variable is still referenced, but it could potentially be part of a circular reference chain that is no longer reachable from the global scope. Once 10,000 such potential roots are tracked in the buffer, PHP initiates a collection cycle to identify and reclaim unreachable memory.
The root buffer introduces a specific type of memory overhead in long-running processes. Because the collector only runs once the buffer is full, objects that are part of a circular reference cannot be freed immediately upon the loss of their last external reference.
memory_limit.Fatal error: Allowed memory size exhausted even if the garbage collector would have successfully freed that memory had it run sooner.To verify if circular references are causing memory growth in your worker script, you can compare memory usage before and after a manual collection call:
$before = memory_get_usage();
gc_collect_cycles();
$after = memory_get_usage();
echo "Reclaimed: " . ($before - $after) . " bytes";
If gc_collect_cycles() consistently frees a significant amount of memory before the process crashes, the automatic trigger is likely too infrequent for your object graph's size. In such cases, calling the function manually at the end of a processing loop (e.g., after processing one job in a queue) is the recommended mitigation, despite the CPU overhead.
To provide a more specific recommendation on frequency, please provide the average number of objects created per iteration of your long-running loop.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 21 Jun 2022, 01:58 UTC
Beyond the 10,000-entry root buffer, PHP also runs the cycle collector probabilistically based on gc_probability and gc_divisor (default 1/1000). This allocation-based trigger can fire before the buffer fills, which is relevant for workers that allocate objects steadily without generating enough decremented roots to hit the buffer limit quickly.
The root buffer itself is lightweight: it holds weak references costing roughly one zval per entry (about 16 bytes on 64-bit builds), so a full buffer adds only a few kilobytes. The actual heap growth comes from the unreachable cyclic objects that stay allocated until a collection runs, not from the buffer metadata.
To verify, compare memory_get_peak_usage() before and after lowering gc_divisor (for example, to 10). More frequent collection should reduce the peak at the cost of extra CPU. On PHP 7.1 and newer this behavior is stable, but measure with your real object graph.