PHP e memory management: come evitare i memory leak nelle applicazioni di lunga esecuzione

PHP e memory management: come evitare i memory leak nelle applicazioni di lunga esecuzione

PHP è stato progettato per il modello request-response: il processo nasce, elabora la richiesta, muore. In quel modello, i memory leak non sono un problema - qualsiasi memoria allocata viene liberata automaticamente alla fine del processo, che vive per qualche centinaio di millisecondi. Ma il PHP moderno non vive più solo nel modello request-response: i worker di Laravel Horizon processano migliaia di job senza riavviarsi, i daemon con Octane e Swoole mantengono l'applicazione in memoria tra le richieste, gli script di importazione batch girano per ore elaborando milioni di record, e i consumer Kafka o RabbitMQ restano attivi 24/7. In tutti questi scenari, un memory leak che in una singola richiesta sarebbe invisibile (10 KB di memoria non liberata) diventa catastrofico dopo migliaia di iterazioni: 10 KB × 100.000 job = 1 GB di RAM consumata, e il processo viene terminato dall'OOM killer del kernel con perdita di dati in elaborazione.

A marzo 2025, i worker Laravel Horizon di un cliente del settore logistico consumavano 500 MB di RAM dopo 6 ore di esecuzione - partendo da 45 MB al boot - e venivano killati automaticamente dal parametro --memory=512 di Horizon, causando la perdita dei job in elaborazione al momento del restart. Il team aveva "risolto" il problema aumentando il limite a 1 GB, ma dopo 12 ore i worker raggiungevano anche quel limite. Il memory leak non era ovvio: non era un array che cresceva all'infinito, non era una query che caricava troppi dati in memoria, e non era un file aperto e mai chiuso. Era un event listener registrato globalmente nel service provider che, ad ogni job processato, aggiungeva il job completato a una collection interna per il logging aggregato - senza mai rimuovere i riferimenti ai job precedenti. Dopo 100.000 job, quella collection conteneva 100.000 oggetti con tutti i loro payload serializzati.

Come si identifica un memory leak in un processo PHP di lunga esecuzione?

Il primo passo non è cercare il leak - è misurare il consumo di memoria nel tempo per confermare che il leak esiste e per quantificarne la velocità. Un processo PHP che usa 200 MB di RAM costanti non ha un leak - ha un consumo alto ma stabile. Un processo che parte da 45 MB e cresce di 5 MB ogni ora ha un leak di 5 MB/ora che va trovato e corretto.

Lo strumento più semplice per il monitoraggio della memoria in un worker PHP è memory_get_usage(true) chiamato periodicamente durante l'esecuzione. Per i worker Laravel Horizon, aggiungo un middleware di job che registra il consumo di memoria prima e dopo ogni job:

// app/Jobs/Middleware/MemoryMonitor.php
class MemoryMonitor
{
    public function handle(object $job, callable $next): void
    {
        $before = memory_get_usage(true);
        $next($job);
        $after = memory_get_usage(true);

        $delta = $after - $before;

        // Se il delta è positivo dopo il GC, c'è un leak
        if ($delta > 0) {
            Log::channel('memory')->info('memory_delta', [
                'job' => get_class($job),
                'before_mb' => round($before / 1024 / 1024, 2),
                'after_mb' => round($after / 1024 / 1024, 2),
                'delta_kb' => round($delta / 1024, 2),
                'total_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
            ]);
        }
    }
}

Il parametro true in memory_get_usage(true) è fondamentale: senza di esso, la funzione restituisce la memoria usata dallo script PHP, che non include la memoria allocata ma non ancora riempita nei blocchi interni di Zend Engine. Con true, restituisce la memoria realmente allocata dal sistema operativo - il valore che conta per l'OOM killer e per il monitoraggio reale.

Nel caso del cliente logistico, il log di memory ha mostrato che il delta era costantemente positivo per ogni job - circa 4-8 KB per job, indipendentemente dal tipo. Questo suggeriva un leak nel framework layer (qualcosa che si attiva per ogni job), non nel codice applicativo del job specifico. Nel mio profilo professionale trovi il dettaglio dell'esperienza nel profiling e nel debugging di applicazioni PHP in produzione - i memory leak nei processi di lunga esecuzione sono uno dei problemi più insidiosi perché il sintomo (crash del worker) appare ore dopo la causa (l'allocazione di memoria non liberata).

Le cinque cause più comuni di memory leak in PHP

Dopo anni di debugging di memory leak in worker PHP per clienti PMI, ho catalogato cinque cause che coprono il 90% dei casi che incontro.

Causa 1: event listener con stato accumulato. È il caso del cliente logistico: un listener che si registra nel boot del service provider e accumula dati ad ogni evento senza mai liberarli. In Laravel, i service provider vengono eseguiti una volta all'avvio dell'applicazione, e se un listener memorizza riferimenti a dati di ogni richiesta o job, quei riferimenti restano in memoria per tutta la vita del processo. La fix è usare listener senza stato, o implementare un meccanismo di pulizia esplicita (un metodo flush() chiamato alla fine di ogni job).

Causa 2: query Eloquent con lazy loading in loop. Un loop che itera su 10.000 record con Ordine::all() carica tutti i record in memoria contemporaneamente. Ma il problema più subdolo è il lazy loading di relazioni dentro il loop: $ordine->righe->each(...) esegue una query per ogni ordine (pattern N+1) e mantiene tutti gli oggetti in memoria. La fix è Ordine::with('righe')->chunk(500, function($ordini) { ... }) che processa 500 ordini alla volta e libera la memoria dopo ogni chunk. Il metodo chunk() è il singolo strumento più importante per gli script batch in Laravel - senza di esso, un'importazione di 100.000 record consuma tutta la RAM disponibile indipendentemente da quanta ne hai.

Causa 3: cache locale in singleton service. Un service registrato come singleton nel container Laravel vive per tutta la durata del processo. Se il service ha una proprietà private array $cache = [] che memorizza risultati per evitare query duplicate, quell'array cresce ad ogni richiesta o job senza mai essere svuotato. In un processo request-response il singleton viene distrutto alla fine della richiesta; in un worker Horizon o in Octane, il singleton vive per sempre e il suo cache cresce indefinitamente. La fix è usare Redis o file cache invece di cache in memoria, o implementare un LRU cache con dimensione massima.

Causa 4: riferimenti circolari che impediscono il garbage collector. PHP usa un garbage collector reference-counting con rilevamento dei cicli, ma i cicli vengono raccolti solo quando il GC viene attivato esplicitamente (gc_collect_cycles()) o quando il buffer dei cicli potenziali è pieno (10.000 oggetti di default). In un worker che processa job veloci (10-50 ms ciascuno), il buffer può non riempirsi mai tra un job e l'altro, e i riferimenti circolari si accumulano. La fix è chiamare gc_collect_cycles() esplicitamente alla fine di ogni N job - un $this->forceGc() nel middleware di job ogni 100 iterazioni.

Causa 5: estensioni PECL con leak nativi. Alcune estensioni PHP scritte in C hanno memory leak nel codice nativo che non sono visibili al garbage collector PHP. L'estensione imagick nelle versioni precedenti alla 3.7 aveva un leak noto quando si processavano immagini in loop senza chiamare $imagick->clear() e $imagick->destroy() esplicitamente. L'estensione rdkafka in alcune configurazioni mantiene buffer interni che crescono con il numero di messaggi consumati. Per queste cause, la fix è l'aggiornamento dell'estensione alla versione più recente, o il workaround con pm.max_requests in FPM (che ricicla il processo dopo N richieste, liberando anche la memoria nativa).

Profiling avanzato con Blackfire: trovare il leak con precisione chirurgica

Il middleware di memory monitoring identifica che c'è un leak e quanto cresce per job, ma non dice dove nel codice la memoria viene allocata e non liberata. Per la localizzazione precisa del leak, uso Blackfire in modalità profiling timeline - uno strumento che registra ogni allocazione di memoria durante l'esecuzione di un job o di una richiesta e la attribuisce alla funzione PHP che l'ha effettuata.

Il processo è: configuro Blackfire sul worker (l'estensione Blackfire si installa come qualsiasi estensione PHP), eseguo un profiling su 100 job consecutivi, e confronto il profilo del job numero 1 con quello del job numero 100. Le funzioni che mostrano un aumento di memoria tra il primo e il centesimo job sono i candidati per il leak. Nel caso del cliente logistico, il profiling Blackfire ha mostrato che la funzione Illuminate\Events\Dispatcher::listen manteneva un riferimento a ogni closure registrata come listener - e la closure catturava per riferimento l'intero payload del job, impedendo al garbage collector di liberarlo.

La correzione specifica ha richiesto la modifica del service provider: invece di registrare un listener che accumulava dati in una collection statica, ho implementato un logger che scriveva direttamente nel database (insert batch ogni 100 job) senza mantenere riferimenti in memoria. Il consumo di memoria del listener è passato da "cresce di 4 KB per job indefinitamente" a "costante a 2 KB indipendentemente dal numero di job processati" - la differenza tra un leak e un consumo stabile.

Un'alternativa a Blackfire per il profiling della memoria è php-meminfo - un'estensione open source che permette di analizzare il contenuto della memoria PHP in un punto specifico dell'esecuzione, listando tutti gli oggetti allocati con la loro dimensione e i riferimenti che li tengono in vita. A differenza di Blackfire (che richiede un abbonamento per le funzionalità avanzate di profiling), php-meminfo è gratuito e sufficiente per la maggior parte dei casi di memory leak - anche se l'interfaccia è meno ergonomica e richiede più lavoro manuale per l'analisi dei risultati. Per i clienti PMI con budget limitato, php-meminfo è lo strumento che raccomando come prima opzione; Blackfire è il livello successivo per i casi più complessi dove il leak non è localizzabile con gli strumenti gratuiti.

La strategia difensiva: pm.max_requests e memory monitoring

Anche dopo aver trovato e corretto tutti i memory leak nel codice applicativo, implemento sempre una strategia difensiva per i processi di lunga esecuzione. Per i worker FPM, il parametro pm.max_requests nel pool FPM limita il numero di richieste servite da un singolo worker prima di essere riciclato - un valore di 1.000 è un buon compromesso tra stabilità e costo del riciclo. Per i worker Horizon, il parametro --memory=256 nel supervisore di Horizon termina il worker quando il consumo supera la soglia e lo riavvia automaticamente. Per i daemon custom, un timer interno che misura memory_get_usage(true) ogni 60 secondi e termina il processo con exit code 0 (per permettere a systemd di riavviarlo) quando il consumo supera il 70% della RAM disponibile.

Questa strategia difensiva non sostituisce la correzione dei leak - la complementa. Un leak non corretto continuerà a consumare memoria, ma il riciclo periodico impedisce al consumo di raggiungere livelli critici. È la stessa filosofia del pm.max_requests che ho descritto nel mio articolo sull'ottimizzazione di PHP-FPM per carichi elevati - il riciclo è una rete di sicurezza che protegge dal prossimo leak che non hai ancora trovato.

Nel caso del cliente logistico, la correzione dell'event listener ha ridotto il consumo di memoria dei worker da 500 MB (dopo 6 ore) a 52 MB stabili (anche dopo 72 ore di esecuzione). Il parametro --memory=256 di Horizon è rimasto come protezione - un paracadute che in 6 mesi di produzione non è mai stato attivato, il che conferma che il leak era davvero quell'unico listener. Se i tuoi worker PHP mostrano un consumo di memoria che cresce nel tempo, contattami per una sessione di profiling: in una giornata identifichiamo la sorgente del leak con Blackfire o con il memory monitor descritto in questo articolo, correggiamo la causa, e implementiamo la strategia difensiva per prevenire leak futuri.

Ultima modifica: