Why PHP Workers Never Release Memory: The Hidden Block Allocation Pattern
When moving from PHP‑FPM to long‑running processes like RoadRunner or Laravel queue workers, memory usage climbs and never drops, a pattern caused by PHP’s Zend memory manager block allocation, reference counting, and GC behavior, which can be mitigated by redesigning code and worker strategies.
Pattern You’ll Keep Seeing
If you switch from PHP‑FPM to long‑running processes such as RoadRunner, Laravel or Symfony Messenger workers, you will encounter a confusing pattern: memory only goes up and never comes down.
Memory only rises. Never falls.
A worker may start at ~40 MB, process a heavy task and jump to 200 MB, then another batch to 350 MB, and stay at 350 MB forever until the process is restarted.
Typical causes like memory leaks, gc_collect_cycles() or adding unset() calls do not change the situation.
In production we saw an unoptimized ORM query load ~100 000 rows, pushing memory from ~40 MB to 360 MB, after which it never decreased.
Why This Pattern Exists: PHP’s DNA
PHP was designed for short‑lived request‑response cycles: a request arrives, PHP processes it, sends a response, then the process dies, releasing all memory automatically. The Zend Memory Manager (ZMM) is optimized for:
Fast allocation during request handling
Zero cleanup cost when the process terminates
Predictable, millisecond‑scale lifetimes
Modern PHP workloads (RoadRunner workers, Laravel queues, custom daemons) keep processes alive for thousands of tasks, exposing the memory model.
How the Zend Memory Manager Actually Works
PHP does not call malloc() and free() directly. Instead it uses ZMM, which allocates large blocks (typically 2–4 MB) and carves out space for strings, arrays, and objects.
Block System
Think of the OS memory as a warehouse renting whole floors, PHP blocks as the floors you rent, and your variables as the furniture on those floors.
One‑Way Memory Flow
$data = fetchHugeDataset(); // allocates 300 MB in a block
processData($data);
unset($data); // PHP marks it free internally
// OS still sees 300 MB allocatedWhen unset() is called, PHP marks the memory as free in its internal pool, but the OS never reclaims the block; it stays allocated to the process.
PHP marks the memory as free internally.
The memory returns to PHP’s free pool.
The OS never takes the block back.
The block remains assigned to the process.
This is intentional: reusing already‑allocated blocks is far faster than repeatedly requesting and releasing memory from the OS.
Why Blocks Stay Forever
Performance – OS allocation is expensive.
Fragmentation – mixed allocations prevent a block from becoming completely empty.
No shrink mechanism – PHP does not automatically return blocks.
Assumption – PHP assumes you will need the block again soon.
For traditional PHP‑FPM this is irrelevant because the process dies after each request; for long‑running processes it becomes a reality.
Staircase Pattern
Memory │
380MB ┤─────────────────
│
│ (peak operation)
│ ╱
60MB ┤─────╯
│
└─────────────────────► TimeEach peak becomes the new baseline, and the worker’s memory never goes down.
Identifying the Pattern in Your Code
Bulk ORM Operations
// This will peak memory and never release it
$users = User::all(); // loads 50K users
foreach ($users as $user) {
processUser($user);
}Large File Handling
// Loads the whole file into memory
$content = file_get_contents('huge-file.csv');
$lines = explode("
", $content);Growing Result Arrays
$results = [];
foreach ($items as $item) {
$results[] = expensiveOperation($item);
}Nested Hydration
$orders = Order::with('customer.address.country')
->with('items.product.category')
->get();Each of these patterns creates a permanent memory peak that accumulates after a few iterations.
Designing Around the Pattern
Think Streams, Not Collections
// Keep memory stable – no peaks
Record::where('status', 'pending')
->chunk(100, function ($records) {
foreach ($records as $record) {
processRecord($record);
}
});Laravel’s lazy collections in PHP 8+ work perfectly:
User::lazy()->each(function ($user) {
processUser($user);
});Doctrine iterators also help:
$query = $em->createQuery('SELECT u FROM User u');
foreach ($query->toIterable() as $user) {
process($user);
$em->detach($user); // crucial
}Scope Isolation
class JobHandler {
public function handle($job) {
$this->processLargeDataset($job);
// all local variables are released here
}
private function processLargeDataset($job) {
$data = fetchData(); // ~100 MB peak
transform($data);
save($data);
// peak ends when function exits
}
}Embrace Worker Rotation
Configure your worker pools to restart after a certain number of jobs:
# .rr.yaml
pool:
max_jobs: 1000 # refresh worker every 1K tasks php artisan queue:work --max-jobs=1000 php bin/console messenger:consume --limit=1000Rotation is not a workaround; it is a pattern‑aware solution.
Prevent Real Leaks
Pattern Violation: Growing Static State
class Cache {
private static array $data = [];
public static function store($key, $value) {
self::$data[$key] = $value; // forever accumulates
}
}Pattern‑Aware Bounded Cache
class BoundedCache {
private array $data = [];
private int $maxSize = 1000;
public function store($key, $value) {
if (count($this->data) >= $this->maxSize) {
array_shift($this->data); // prevent unbounded growth
}
$this->data[$key] = $value;
}
}Practical Takeaways
Accept the pattern – memory peaks are permanent until a restart.
Design for the peak, not the average; a worker will always consume its highest peak.
Keep peaks small: batch operations, stream data, isolate scopes.
Rotate workers; it is a solution, not a hack.
Monitor actively to catch unexpected peaks before they accumulate.
Avoid real leaks by eliminating unbounded static/global state.
Developers who fight the pattern struggle; those who embrace it redesign their long‑running PHP applications for predictable, low‑memory usage.
Open Source Tech Hub
Sharing cutting-edge internet technologies and practical AI resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
