Speicherlecks in lang laufenden PHP-Prozessen finden und vermeiden
AI generated
<?php
8.4
PHP · Worker-Prozesse · Speicherlecks · Monitoring
Speicherlecks in lang laufenden PHP-Prozessen
Finden, verstehen und dauerhaft vermeiden

Ein klassischer PHP-FPM-Request gibt seinen kompletten Speicher am Ende automatisch frei, ein Swoole- oder RoadRunner-Worker dagegen lebt über tausende Requests hinweg im selben Prozess. Genau dieser Unterschied macht statischen Zustand, vergessene Closures und wachsende Caches zu einem echten Speicherleck, das über Stunden hinweg langsam den verfügbaren Arbeitsspeicher aufbraucht.

15 Min. Lesezeit Swoole · RoadRunner · memory_get_usage PHP 8.x

1. Warum lang laufende Prozesse anders auf Speicher reagieren

In einem klassischen PHP-FPM-Setup startet jeder Request in einem frischen oder zumindest weitgehend zurückgesetzten Ausführungskontext und der komplette angeforderte Speicher wird beim Request-Ende vom Betriebssystem zurückgefordert. Ein kleines Speicherleck, das pro Request ein paar Kilobyte verliert, fällt in diesem Modell praktisch nie auf, weil es mit jedem neuen Request wieder bei null beginnt.

Bei lang laufenden Prozessen mit Swoole, RoadRunner, Amp oder einfachen langlebigen CLI-Kommandos ändert sich dieses Bild fundamental. Der Prozess bleibt über tausende oder zehntausende Requests hinweg am Leben, und jedes Byte, das nicht wieder freigegeben wird, bleibt dauerhaft belegt. Ein Speicherleck von wenigen Kilobyte pro Request summiert sich über einen Tag Betrieb schnell zu mehreren hundert Megabyte, bis der Worker-Prozess entweder an sein konfiguriertes Speicherlimit stößt und abstürzt, oder das gesamte System durch Speicherdruck destabilisiert wird.

Ein Speicherleck in diesem Kontext bedeutet nicht zwingend einen klassischen Bug im Sinne verlorener Pointer wie in C, sondern fast immer Speicher, der von PHP aus Sicht des Referenzzählers korrekt verwaltet wird, aber aus fachlicher Sicht nie wieder benötigt würde und trotzdem referenziert bleibt. Genau diese Unterscheidung macht das Debugging von Speicherlecks in PHP anders als in Sprachen mit manueller Speicherverwaltung.

2. Statische Properties und globaler Zustand als häufigste Ursache

Die mit Abstand häufigste Ursache für ein Speicherleck in lang laufenden PHP-Prozessen sind statische Klassen-Properties oder globale Variablen, die über die Lebensdauer des Prozesses hinweg unkontrolliert wachsen. Ein Registry-Pattern, ein statischer Cache oder ein Logger, der jede Log-Nachricht in einem statischen Array sammelt, statt sie sofort auszugeben, führt in einem PHP-FPM-Request nie zu Problemen, weil der Request nach kurzer Zeit endet. In einem Worker-Prozess dagegen wächst dieses Array über die gesamte Prozesslaufzeit unbegrenzt weiter.

Besonders tückisch sind statische Properties, die scheinbar sinnvolles Caching implementieren, etwa das Zwischenspeichern von Konfigurationswerten oder Übersetzungen pro verarbeiteter Entität, ohne jemals eine Obergrenze oder eine Bereinigungsstrategie vorzusehen. Was in einem kurzlebigen Request wie eine harmlose Optimierung aussieht, wird im Worker-Kontext über tausende verschiedene Entitäten hinweg zu einem stetig wachsenden Speicherleck, weil der Cache niemals schrumpft.


declare(strict_types=1);

// LEAK: unbounded static cache grows for the entire worker lifetime
final class TranslationCache
{
    /** @var array<string, string> */
    private static array $cache = [];

    public static function translate(string $key, string $locale): string
    {
        $cacheKey = "{$locale}:{$key}";
        // Never evicted, grows with every unique key/locale combination
        return self::$cache[$cacheKey] ??= self::loadFromDatabase($key, $locale);
    }

    private static function loadFromDatabase(string $key, string $locale): string
    {
        return "translated:{$key}";
    }
}

// FIXED: bounded cache with explicit size limit and eviction
final class BoundedTranslationCache
{
    private const MAX_ENTRIES = 5_000;

    /** @var array<string, string> */
    private static array $cache = [];

    public static function translate(string $key, string $locale): string
    {
        $cacheKey = "{$locale}:{$key}";
        if (!isset(self::$cache[$cacheKey]) && count(self::$cache) >= self::MAX_ENTRIES) {
            array_shift(self::$cache); // evict oldest entry, keep bounded
        }
        return self::$cache[$cacheKey] ??= self::loadFromDatabase($key, $locale);
    }

    private static function loadFromDatabase(string $key, string $locale): string
    {
        return "translated:{$key}";
    }
}

3. Event-Listener und Closures, die Objekte binden

Ein zweites häufiges Speicherleck entsteht durch Event-Listener oder Callback-Registrierungen, die niemals wieder entfernt werden. Registriert ein Request-spezifisches Objekt sich selbst als Listener an einem lang lebenden Event-Dispatcher, ohne sich am Ende der Verarbeitung wieder abzumelden, hält der Dispatcher über seine Listener-Liste eine dauerhafte, starke Referenz auf dieses Objekt, obwohl es fachlich längst nicht mehr gebraucht wird.

Closures verschärfen dieses Problem zusätzlich, weil sie implizit alle über use erfassten Variablen einschließlich enthaltener Objekte referenzieren, selbst wenn im Code selbst keine offensichtliche Objektreferenz zu sehen ist. Eine Closure, die versehentlich $this aus einer Request-spezifischen Klasse mit erfasst und an einen lang lebenden Dispatcher übergeben wird, hält indirekt das gesamte Objekt inklusive all seiner Properties am Leben, solange die Closure selbst referenziert bleibt.


declare(strict_types=1);

final class EventDispatcher
{
    /** @var array<string, list<callable>> */
    private array $listeners = [];

    public function on(string $event, callable $listener): void
    {
        $this->listeners[$event][] = $listener;
    }

    // LEAK: no way to remove a listener once added, list grows forever
    // if request-scoped objects keep registering new listeners
}

// LEAK-prone pattern: request handler registers itself on every request
final class OrderProcessor
{
    public function __construct(private readonly EventDispatcher $dispatcher) {}

    public function process(): void
    {
        // Captures $this implicitly, keeps the whole OrderProcessor alive
        $this->dispatcher->on('order.completed', function () {
            $this->notifyCustomer();
        });
    }

    private function notifyCustomer(): void {}
}

// FIXED: explicit unsubscribe via a returned token, or use WeakReference
// inside the dispatcher's listener storage to avoid extending lifetime

4. Speicherverbrauch systematisch messen und Trends erkennen

memory_get_usage(true) liefert den vom Betriebssystem tatsächlich allozierten Speicher des PHP-Prozesses, memory_get_peak_usage(true) den bisherigen Höchstwert. Für die Diagnose von Speicherlecks in Workern ist entscheidend, diese Werte nicht einmalig, sondern kontinuierlich über viele Requests hinweg zu protokollieren, um einen Trend statt einer einzelnen Momentaufnahme zu erhalten. Ein stabiler Speicherverbrauch, der nach anfänglichem Anstieg ein Plateau erreicht, ist unproblematisch, ein linear oder gar exponentiell wachsender Wert über hunderte Requests hinweg ist das eindeutige Signal für ein echtes Speicherleck.

Ein einfacher, aber wirkungsvoller Diagnoseansatz: Nach jedem N-ten Request innerhalb eines Workers den aktuellen Speicherwert loggen und die Differenz zum vorherigen Messpunkt bilden. Bleibt diese Differenz über viele Messintervalle hinweg konstant nahe null, arbeitet der Worker stabil. Wächst die Differenz dagegen kontinuierlich, lässt sich anhand des Zeitpunkts, an dem der Anstieg beginnt, häufig bereits auf den auslösenden Codepfad schließen, insbesondere wenn der Anstieg mit bestimmten Request-Typen korreliert.


declare(strict_types=1);

/**
 * Simple worker-level memory trend logger, call after every request.
 */
final class MemoryTrendLogger
{
    private int $lastUsage = 0;
    private int $requestCount = 0;

    public function recordRequest(): void
    {
        $this->requestCount++;
        $current = memory_get_usage(true);
        $delta = $current - $this->lastUsage;

        if ($this->requestCount % 100 === 0) {
            error_log(sprintf(
                '[memory] request=%d usage=%d delta_since_last_100=%d',
                $this->requestCount,
                $current,
                $delta
            ));
        }

        $this->lastUsage = $current;
    }
}

5. Worker-Recycling als pragmatisches Sicherheitsnetz

Auch bei sorgfältig geschriebenem Code lassen sich subtile Speicherlecks in komplexen Anwendungen, insbesondere mit vielen Abhängigkeiten und Drittanbieter-Bibliotheken, nicht immer vollständig ausschließen. Deshalb konfigurieren praktisch alle produktiven Setups mit Swoole oder RoadRunner ein automatisches Worker-Recycling: Nach einer festgelegten Anzahl verarbeiteter Requests oder nach Überschreiten eines Speicherlimits wird ein Worker-Prozess sauber beendet und durch einen frischen ersetzt.

Dieses Recycling ist kein Ersatz für die Behebung tatsächlicher Speicherlecks, sondern ein pragmatisches Sicherheitsnetz, das verhindert, dass ein bisher unentdecktes Leck den gesamten Prozess zum Absturz bringt. Wichtig ist, das Recycling-Intervall so zu wählen, dass es die Symptome eines Lecks abfängt, ohne durch zu häufige Worker-Neustarts selbst zur Performance-Bremse zu werden, da jeder neue Worker-Start Initialisierungskosten wie das erneute Aufbauen von Datenbankverbindungen verursacht.

6. Debugging mit Xdebug, Blackfire und eigenen Snapshots

Für die tiefergehende Analyse eines bestätigten Speicherlecks liefert Xdebugs xdebug_debug_zval() Einblick in den Referenzzähler einzelner Variablen und hilft zu klären, ob ein Objekt tatsächlich noch von irgendwoher referenziert wird. Für produktionsnahe Analysen sind Profiler wie Blackfire besser geeignet, weil sie den Speicherverbrauch nach Funktionsaufruf aufschlüsseln, ohne den drastischen Performance-Overhead eines vollständigen Xdebug-Traces in Kauf nehmen zu müssen.

Ein praktischer Eigenbau-Ansatz für schwer reproduzierbare Lecks: An mehreren Stellen im Code, verteilt über einen längeren Zeitraum, einen Snapshot mit gc_collect_cycles() gefolgt von memory_get_usage() und optional der Anzahl aktiver Objekte pro Klasse über get_declared_classes() kombiniert mit Reflection erstellen. Der Vergleich mehrerer solcher Snapshots über die Zeit zeigt häufig zuverlässig, welche Klasse ungewöhnlich viele Instanzen ansammelt, was den Suchraum für die eigentliche Ursache erheblich eingrenzt.

7. Frameworks und ORMs: typische Leak-Quellen

ORMs wie Doctrine oder Eloquent führen häufig einen internen Identity Map beziehungsweise Unit-of-Work-Mechanismus, der geladene Entitäten über die Dauer eines Requests hinweg zwischenspeichert, um mehrfaches Laden derselben Entität zu vermeiden. In einem PHP-FPM-Request wird dieser Cache automatisch mit dem Request-Ende verworfen, in einem Worker-Prozess muss der Entity Manager oder die entsprechende Unit of Work dagegen explizit nach jedem verarbeiteten Request zurückgesetzt werden, sonst wächst diese Identity Map unbegrenzt mit jeder neu geladenen Entität.

Ähnliches gilt für Dependency-Injection-Container mit falsch konfiguriertem Objekt-Scope: Ein Service, der versehentlich als Singleton statt als Request-Scoped registriert wird, aber intern Zustand über einzelne Requests hinweg akkumuliert, etwa eine Liste verarbeiteter IDs für Debugging-Zwecke, wird zu einem klassischen Speicherleck im Worker-Kontext. Die Grundregel: Jeder Zustand, der in einem klassischen PHP-FPM-Request implizit durch das Request-Ende bereinigt würde, muss in einem Worker-Prozess explizit zurückgesetzt werden.

8. Präventive Architekturmuster für lang laufende Prozesse

Der wirksamste präventive Ansatz ist, Request-spezifischen Zustand konsequent in einem neu erzeugten Objekt pro Request zu kapseln, statt ihn in statischen Properties oder Singletons zu halten, die über die gesamte Worker-Lebensdauer existieren. Frameworks mit expliziter Unterstützung für lang laufende Prozesse bieten dafür häufig einen Reset-Hook, der nach jedem Request definierte Zustände zurücksetzt, unabhängig davon, ob der Entwickler jede einzelne Stelle im Code manuell im Blick behält.

Zusätzlich empfiehlt sich der bewusste Einsatz von WeakMap für Caches, die zwar über mehrere Requests hinweg wiederverwendet werden sollen, aber nicht unbegrenzt wachsen dürfen, sowie eine klare Trennung zwischen Prozess-weitem Zustand, der bewusst über die gesamte Worker-Lebensdauer bestehen bleiben soll, etwa eine Datenbankverbindung, und Request-spezifischem Zustand, der nach jedem Request vollständig verworfen werden muss.

9. Gegenmaßnahmen im direkten Vergleich

Ein direkter Vergleich zeigt, welche Gegenmaßnahme welches Problem tatsächlich löst.

Maßnahme Löst die Ursache? Aufwand Empfehlung
Worker-Recycling Nein, nur Symptom Gering, reine Konfiguration Immer als Sicherheitsnetz aktivieren
Beschränkte statische Caches Ja Mittel, Code-Änderung nötig Zentrale Präventionsmaßnahme
WeakMap statt Array-Cache Ja Mittel, gezielter Umbau Für objektbasierte Caches
Reset-Hook nach jedem Request Ja Höher, Framework-abhängig Für komplexe Anwendungen unverzichtbar

Der Vergleich zeigt: Worker-Recycling gehört in jedes produktive Setup, ersetzt aber niemals die eigentliche Behebung der Ursache. Nachhaltig wirksam sind ausschließlich Maßnahmen, die den unkontrolliert wachsenden Zustand selbst beschränken oder nach jedem Request bewusst zurücksetzen.

Mironsoft

Swoole- und RoadRunner-Betrieb, Speicheranalyse und Worker-Architektur

Wachsenden Speicherverbrauch in euren PHP-Workern stoppen?

Wir identifizieren statischen Zustand, vergessene Listener und ORM-Identity-Maps, die eure lang laufenden PHP-Prozesse belasten, und richten Monitoring sowie sichere Recycling-Strategien für stabilen Dauerbetrieb ein.

Leak-Diagnose

Systematisches Messen von memory_get_usage-Trends über viele Requests hinweg

Architektur-Refactoring

Umbau von unbeschränktem statischem Zustand auf WeakMap und Reset-Hooks

Worker-Betriebskonfiguration

Sinnvolle Recycling-Intervalle und Speicherlimits für Swoole und RoadRunner

10. Zusammenfassung

Speicherlecks in lang laufenden PHP-Prozessen entstehen fast immer durch Zustand, der in einem klassischen PHP-FPM-Request implizit am Request-Ende bereinigt würde, in einem Worker-Prozess aber explizit zurückgesetzt werden muss. Statische Caches ohne Obergrenze, vergessene Event-Listener und Closures, die versehentlich Objekte binden, sowie ORM-Identity-Maps ohne expliziten Reset sind die mit Abstand häufigsten Ursachen.

Systematisches Messen mit memory_get_usage() über viele Requests hinweg macht ein wachsendes Speicherleck sichtbar, bevor es zum Prozessabsturz führt. Worker-Recycling gehört als pragmatisches Sicherheitsnetz in jedes produktive Setup, ersetzt aber nie die eigentliche Behebung der Ursache durch beschränkte Caches, WeakMap-basierte Strukturen und konsequente Reset-Hooks nach jedem Request.

Speicherlecks in lang laufenden PHP-Prozessen, das Wichtigste auf einen Blick

Statischer Zustand ist die Hauptursache

Unbeschränkte statische Caches und globaler Zustand wachsen ohne Obergrenze mit jedem Request.

Trends messen, nicht Momentaufnahmen

memory_get_usage() über viele Requests hinweg protokollieren, um echte Lecks von normalem Wachstum zu unterscheiden.

Worker-Recycling als Sicherheitsnetz

Automatischer Neustart nach N Requests fängt unentdeckte Lecks ab, ersetzt aber nicht die Ursachenbehebung.

Request-Zustand explizit zurücksetzen

Was PHP-FPM implizit bereinigt, muss in Workern per Reset-Hook oder WeakMap aktiv gelöst werden.

11. FAQ: Speicherlecks in lang laufenden PHP-Prozessen

1Was ist ein Speicherleck im Worker?
Speicher, der referenziert bleibt, obwohl er fachlich nicht mehr gebraucht wird, summiert sich über viele Requests.
2Warum kaum bei PHP-FPM?
Jeder Request gibt seinen Speicher automatisch frei, kleine Lecks fallen dadurch nie auf.
3Häufigste Ursache?
Statische Properties oder globaler Zustand ohne Obergrenze, die über die Prozesslaufzeit wachsen.
4Wie verursachen Closures Lecks?
Sie erfassen implizit $this, gebunden an einen lang lebenden Dispatcher halten sie das ganze Objekt am Leben.
5Wie messe ich ein Leck?
memory_get_usage() über viele Requests loggen, ein linearer Anstieg ist das Signal.
6Löst Worker-Recycling das Problem?
Nein, nur ein Sicherheitsnetz, ersetzt nicht die Ursachenbehebung im Code.
7Warum ORM-Reset in Workern nötig?
Die Identity Map wird nicht automatisch geleert wie bei PHP-FPM, sonst wächst sie unbegrenzt.
8Hilft WeakMap gegen alle Lecks?
Nur gegen Objekt-Caches, nicht gegen Skalar-Arrays oder Referenzzyklen.
9Welche Tools für Produktion?
Blackfire und eigene Snapshot-Vergleiche mit memory_get_usage() ohne Xdebug-Overhead.
10Wachstum vs. echtes Leck?
Ein Plateau nach anfänglichem Anstieg ist normal, kontinuierliches Wachstum ohne Plateau ist ein Leck.