Prozess-Parallelisierung mit pcntl: Fork, Wait und Signal-Handling
AI generated
<?php
8.4
PHP · pcntl · Prozesse · CLI
Prozess-Parallelisierung mit pcntl
Fork, Wait und Signal-Handling in der Praxis

Prozess-Parallelisierung mit pcntl erlaubt es PHP CLI-Skripten, echte Betriebssystem-Prozesse zu erzeugen und CPU-gebundene Arbeit tatsächlich gleichzeitig auf mehreren Kernen auszuführen. Wer pcntl_fork, pcntl_wait und Signal-Handling beherrscht, baut Worker-Pools, die Warteschlangen leeren, Batch-Jobs verarbeiten und dabei sauber herunterfahren, statt Zombie-Prozesse zu hinterlassen.

17 Min. Lesezeit pcntl_fork · pcntl_wait · Signale · Worker-Pool PHP 8.4 CLI · Linux

1. Warum pcntl für Prozess-Parallelisierung existiert

Die Prozess-Parallelisierung mit der PHP Erweiterung pcntl löst ein Problem, das weder Fibers noch ReactPHP adressieren: echte parallele Ausführung auf mehreren CPU Kernen gleichzeitig. Fibers und Event Loops sind kooperativ und laufen in einem einzigen Thread, was für I/O gebundene Aufgaben ausreicht, aber bei CPU intensiver Arbeit wie Bildverarbeitung, Verschlüsselung oder komplexen Berechnungen keinen Geschwindigkeitsvorteil bringt. pcntl erzeugt stattdessen echte, unabhängige Betriebssystem Prozesse, die der Kernel parallel auf verschiedenen Kernen einplant.

pcntl steht für Process Control und ist nur unter Unix artigen Systemen verfügbar, unter Windows fehlt die Erweiterung komplett. Das ist eine wichtige Einschränkung für die Prozess-Parallelisierung: Code, der auf pcntl_fork aufbaut, funktioniert nicht auf Windows Entwicklungsmaschinen, muss also entweder auf Linux Produktionsserver beschränkt bleiben oder mit einer Fallback Strategie für Windows kombiniert werden. In der Praxis betrifft das vor allem CLI Skripte, Cron Jobs und Queue Worker, die ohnehin auf Linux Servern laufen.

Der zentrale Baustein der Prozess-Parallelisierung mit pcntl ist pcntl_fork, das den aktuellen Prozess dupliziert. Nach dem Aufruf laufen zwei fast identische Prozesse weiter, jeweils an derselben Stelle im Code, unterschieden nur durch den Rückgabewert von pcntl_fork. Diese Denkweise unterscheidet sich fundamental von Threads oder Fibers, bei denen nur ein Teil des Speichers dupliziert wird. Ein Fork dupliziert den gesamten Adressraum, wenn auch effizient über Copy on Write.

2. pcntl_fork: wie ein Kindprozess entsteht

Der Aufruf pcntl_fork() ist der Kern jeder Prozess-Parallelisierung mit pcntl. Er dupliziert den aufrufenden Prozess und gibt in beiden entstehenden Prozessen einen unterschiedlichen Wert zurück: im ursprünglichen Elternprozess die Prozess ID des neu erzeugten Kindes, im Kindprozess selbst die Zahl 0. Schlägt der Fork fehl, etwa weil das Betriebssystem die maximale Prozessanzahl erreicht hat, gibt die Funktion minus 1 zurück. Diese drei Fälle müssen in jedem pcntl_fork Aufruf explizit unterschieden werden.

Direkt nach dem Fork existieren zwei komplett unabhängige Prozesse mit eigenem Speicher, eigener Prozess ID und eigenem Dateideskriptor Satz, wobei geöffnete Dateideskriptoren zunächst geteilt werden. Änderungen an Variablen im Kindprozess wirken sich nicht auf den Elternprozess aus und umgekehrt, weil der Kernel bei der ersten Schreiboperation eine private Kopie der betroffenen Speicherseite anlegt, das sogenannte Copy on Write Prinzip. Für die Prozess-Parallelisierung bedeutet das: geteilte Zustände zwischen Prozessen erfordern explizite Mechanismen wie Shared Memory, nicht einfach globale Variablen.

Ein häufiger Fehler bei der ersten Berührung mit Prozess-Parallelisierung: Entwickler vergessen, dass auch offene Datenbankverbindungen dupliziert werden. Eine PDO Verbindung, die vor dem Fork geöffnet wurde, existiert in beiden Prozessen als derselbe zugrunde liegende Socket. Schreiben beide Prozesse gleichzeitig darauf, entstehen unvorhersehbare Protokollfehler. Die sichere Praxis: Datenbankverbindungen erst im Kindprozess nach dem Fork öffnen, niemals vorher.


<?php

declare(strict_types=1);

function processTask(int $taskId): void
{
    // Simulate CPU-bound work, e.g. image resizing or hashing
    $result = hash('sha256', (string) $taskId . random_bytes(1024));
    echo "Child " . posix_getpid() . " finished task {$taskId}: {$result}\n";
}

$taskIds = [1, 2, 3, 4];
$childPids = [];

foreach ($taskIds as $taskId) {
    $pid = pcntl_fork();

    if ($pid === -1) {
        // Fork failed — handle gracefully, do not silently continue
        fwrite(STDERR, "Fork failed for task {$taskId}\n");
        continue;
    }

    if ($pid === 0) {
        // We are in the child process now
        processTask($taskId);
        exit(0);
    }

    // We are in the parent process, $pid holds the child's PID
    $childPids[] = $pid;
}

echo "Parent " . posix_getpid() . " spawned " . count($childPids) . " children\n";

3. pcntl_wait und pcntl_waitpid: Zombies vermeiden

Wenn ein Kindprozess beendet ist, verbleibt sein Eintrag in der Prozesstabelle des Kernels, bis der Elternprozess dessen Beendigungsstatus explizit abgeholt hat. Diesen Zustand nennt man Zombie Prozess. Bei kurzlebigen Skripten fällt das kaum auf, aber bei einer lang laufenden Prozess-Parallelisierung mit vielen erzeugten Kindern über Stunden oder Tage sammeln sich Zombies an, bis das Betriebssystem keine neuen Prozess IDs mehr vergeben kann.

pcntl_wait blockiert den Elternprozess, bis irgendein Kindprozess beendet wird, und gibt dessen PID zurück. pcntl_waitpid erlaubt zusätzlich, gezielt auf einen bestimmten Kindprozess zu warten, und akzeptiert das Flag WNOHANG, das die Funktion sofort mit 0 zurückkehren lässt, falls noch kein Kind beendet ist, statt zu blockieren. Für die Prozess-Parallelisierung in einem Worker Pool ist WNOHANG essenziell, weil der Elternprozess weiterarbeiten muss, während er periodisch prüft, ob Kinder fertig sind.

Der Beendigungsstatus, den pcntl_wait als Referenzparameter liefert, muss mit Hilfsfunktionen ausgewertet werden: pcntl_wifexited prüft, ob der Prozess normal beendet wurde, pcntl_wexitstatus liefert den tatsächlichen Exit Code. Ohne diese Auswertung bleibt unklar, ob ein Kindprozess erfolgreich war oder mit einem Fehler abgestürzt ist, was bei Batch Verarbeitung mit Prozess-Parallelisierung zu unbemerkten Datenverlusten führen kann.


<?php

declare(strict_types=1);

$runningPids = [/* ... populated by pcntl_fork calls ... */];
$failedTasks = [];

// Non-blocking reap loop: check for finished children without stalling the parent
while (count($runningPids) > 0) {
    foreach ($runningPids as $index => $pid) {
        $status = 0;
        $result = pcntl_waitpid($pid, $status, WNOHANG);

        if ($result === 0) {
            // Child still running, check again later
            continue;
        }

        if ($result === $pid) {
            if (pcntl_wifexited($status)) {
                $exitCode = pcntl_wexitstatus($status);
                if ($exitCode !== 0) {
                    $failedTasks[] = $pid;
                    fwrite(STDERR, "Child {$pid} exited with code {$exitCode}\n");
                }
            } elseif (pcntl_wifsignaled($status)) {
                $signal = pcntl_wtermsig($status);
                fwrite(STDERR, "Child {$pid} killed by signal {$signal}\n");
            }

            unset($runningPids[$index]);
        }
    }

    // Avoid a busy loop that saturates a CPU core
    usleep(50_000);
}

echo count($failedTasks) . " tasks failed\n";

4. Einen Worker-Pool mit fester Prozessanzahl bauen

In der Praxis will man selten unbegrenzt viele Kindprozesse gleichzeitig starten. Eine effektive Prozess-Parallelisierung begrenzt die Anzahl gleichzeitiger Worker auf eine feste Zahl, meist orientiert an der verfügbaren Kernanzahl über nproc oder Composer\XdebugHandler ähnliche Erkennung. Das Muster: eine Warteschlange von Aufgaben abarbeiten, dabei nie mehr als N Kindprozesse gleichzeitig aktiv halten, und sobald ein Kind fertig ist, sofort das nächste starten.

Diese Art der Prozess-Parallelisierung nennt man einen Worker Pool mit fester Größe. Der Elternprozess fungiert dabei als Supervisor: er verteilt Aufgaben, überwacht den Zustand aller Kinder über pcntl_waitpid mit WNOHANG und startet bei Bedarf neue Kinder nach, etwa wenn ein Worker unerwartet abgestürzt ist. Dieses Supervisor Muster ist praktisch identisch zu dem, was PHP FPM selbst intern für seine Worker Prozesse implementiert.

Ein wichtiger Designaspekt: Die Anzahl paralleler Prozesse sollte nicht blind maximiert werden. Mehr Prozesse als physische Kerne führen zu Kontextwechseln, die den Durchsatz senken statt zu erhöhen. Für CPU gebundene Arbeit ist die Kernanzahl die sinnvolle Obergrenze für die Prozess-Parallelisierung, für I/O gebundene Arbeit, bei der Prozesse viel Zeit wartend verbringen, kann eine höhere Anzahl durchaus sinnvoll sein.

5. Signal-Handling: SIGTERM, SIGCHLD und pcntl_async_signals

Ein produktionstauglicher Worker Pool mit Prozess-Parallelisierung muss auf Betriebssystemsignale reagieren, insbesondere SIGTERM, das etwa Docker beim Container Stopp sendet, und SIGCHLD, das der Kernel automatisch sendet, sobald ein Kindprozess endet. Mit pcntl_signal(SIGTERM, $handler) registriert man einen Callback, der beim Empfang des Signals ausgeführt wird, muss aber sicherstellen, dass Signale tatsächlich verarbeitet werden.

Vor PHP 7.1 mussten Entwickler regelmäßig pcntl_signal_dispatch() manuell aufrufen, damit registrierte Signal Handler überhaupt ausgeführt wurden, ein häufiges Fehlerquelle bei Prozess-Parallelisierung. Seit PHP 7.1 aktiviert declare(ticks=1) oder besser pcntl_async_signals(true) die asynchrone Signalverarbeitung, sodass registrierte Handler sofort ausgeführt werden, sobald ein Signal eintrifft, ohne manuelles Dispatching in der Haupt Schleife.

Ein sauberer Shutdown bei Prozess-Parallelisierung bedeutet: Der Elternprozess fängt SIGTERM ab, leitet das Signal an alle noch laufenden Kindprozesse weiter über posix_kill($pid, SIGTERM), wartet eine begrenzte Zeit auf deren geordnetes Ende über pcntl_waitpid, und beendet Kinder, die nicht rechtzeitig reagieren, hart mit SIGKILL. Ohne diese Kaskade können Kindprozesse als verwaiste, nicht mehr überwachte Prozesse weiterlaufen, nachdem der Elternprozess bereits beendet wurde.


<?php

declare(strict_types=1);

pcntl_async_signals(true);

$childPids = [/* ... populated by pcntl_fork calls ... */];
$shuttingDown = false;

pcntl_signal(SIGTERM, function (int $signal) use (&$shuttingDown, $childPids): void {
    $shuttingDown = true;
    fwrite(STDERR, "SIGTERM received, forwarding to " . count($childPids) . " children\n");

    foreach ($childPids as $pid) {
        posix_kill($pid, SIGTERM);
    }
});

pcntl_signal(SIGCHLD, function (int $signal) use (&$childPids): void {
    // Reap any child that finished, non-blocking
    while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
        $childPids = array_filter($childPids, fn (int $p): bool => $p !== $pid);
        fwrite(STDERR, "Child {$pid} reaped via SIGCHLD handler\n");
    }
});

while (!$shuttingDown || count($childPids) > 0) {
    usleep(100_000);
}

echo "All children terminated, parent exiting cleanly\n";

6. Kommunikation zwischen Prozessen: was pcntl NICHT löst

Ein zentraler Unterschied zur Thread basierten Prozess-Parallelisierung anderer Sprachen: PHP Kindprozesse teilen sich keinen Speicher automatisch. pcntl selbst liefert keine eingebaute Möglichkeit, Daten zwischen Eltern und Kindprozess auszutauschen, nachdem der Fork stattgefunden hat. Wer Ergebnisse aus Kindprozessen zurückbekommen will, braucht einen expliziten Kommunikationskanal.

Gängige Optionen für diese Kommunikation innerhalb der Prozess-Parallelisierung: Named Pipes oder Unix Domain Sockets für schnellen, bidirektionalen Datenaustausch, eine gemeinsame Datei oder Datenbanktabelle für einfache Fälle, oder die shmop beziehungsweise sysvshm Erweiterungen für echten Shared Memory zwischen Prozessen. Für die meisten Anwendungsfälle, in denen Kindprozesse Ergebnisse zurückmelden sollen, ist eine Datenbanktabelle oder eine Message Queue wie Redis der pragmatischste Weg, weil sie ohnehin bereits vorhanden ist und keine zusätzliche IPC Logik erfordert.

Ein alternativer, oft einfacherer Ansatz: Kindprozesse schreiben ihre Ergebnisse einfach in eine eigene Datei oder einen eigenen Datenbankeintrag, identifiziert durch die Task ID, und der Elternprozess liest diese Ergebnisse nach dem pcntl_wait Aufruf ein. Das vermeidet komplexe IPC Mechanismen vollständig und nutzt Infrastruktur, die in den meisten PHP Projekten bereits existiert.

7. Speicherisolation: Copy-on-Write und geteilte Ressourcen

Das Copy on Write Prinzip macht pcntl_fork überraschend günstig, obwohl scheinbar der gesamte Prozessspeicher dupliziert wird. Der Kernel teilt zunächst dieselben physischen Speicherseiten zwischen Eltern und Kindprozess und kopiert eine Seite erst, wenn einer der beiden Prozesse tatsächlich in sie schreibt. Für die Prozess-Parallelisierung bedeutet das: ein Fork direkt nach dem Laden großer, unveränderlicher Datenstrukturen wie Konfigurationsdaten oder Nachschlagetabellen ist praktisch kostenlos, solange diese Strukturen im Kindprozess nur gelesen werden.

Anders sieht es bei Ressourcen aus, die keine reinen Speicherbereiche sind. Dateideskriptoren, Datenbankverbindungen und Netzwerk Sockets werden zwar dupliziert, zeigen danach aber auf denselben zugrunde liegenden Kernel Objekten. Schreibt der Elternprozess nach dem Fork in eine Datei, deren Deskriptor auch das Kind besitzt, sehen beide denselben Dateizeiger, was zu Interferenzen führt. Die sichere Praxis bei Prozess-Parallelisierung: Ressourcen, die nicht geteilt werden sollen, entweder vor dem Fork schließen oder erst nach dem Fork neu öffnen.

OPcache verdient hier besondere Aufmerksamkeit: Der kompilierte Bytecode wird ebenfalls über Copy on Write geteilt, was bedeutet, dass Kindprozesse vom bereits aufgewärmten OPcache des Elternprozesses profitieren, ohne selbst neu kompilieren zu müssen. Das macht Prozess-Parallelisierung mit vielen kurzlebigen Kindprozessen in PHP deutlich günstiger, als man beim ersten Hinsehen annehmen würde.

8. Fehlerbehandlung in Kindprozessen richtig gestalten

Eine unbehandelte Exception in einem Kindprozess kann bei Prozess-Parallelisierung leicht unbemerkt bleiben, weil der Elternprozess standardmäßig nur den numerischen Exit Code sieht, nicht die eigentliche Fehlermeldung. Jeder Kindprozess sollte deshalb einen eigenen try catch Block um seine gesamte Arbeit legen, Fehler explizit protokollieren, etwa über error_log mit einem Präfix, das die Prozess ID enthält, und mit einem klar definierten, von Null verschiedenen Exit Code beenden.

Ein Detail, das häufig übersehen wird: Stirbt ein Kindprozess durch ein Signal statt durch einen regulären exit Aufruf, etwa weil das Betriebssystem ihn wegen Speichermangel über den Out Of Memory Killer beendet hat, liefert pcntl_wifexited false zurück. Der Elternprozess muss in der Prozess-Parallelisierung deshalb sowohl pcntl_wifexited als auch pcntl_wifsignaled prüfen, um zwischen normaler Beendigung, Fehlerbeendigung und externer Terminierung zu unterscheiden.

Für Batch Verarbeitung empfiehlt sich zusätzlich ein Retry Mechanismus auf Ebene des Elternprozesses: Schlägt eine Aufgabe in einem Kindprozess fehl, wird sie in eine Retry Liste verschoben und mit einem neuen Kindprozess erneut versucht, statt die gesamte Prozess-Parallelisierung beim ersten Fehler abzubrechen. Diese Widerstandsfähigkeit ist besonders bei Aufgaben wichtig, die externe Ressourcen wie APIs oder Netzwerkdateisysteme berühren, die gelegentlich transient fehlschlagen.

9. pcntl im Vergleich zu Fibers, Swoole und Queue-Workern

Die Prozess-Parallelisierung mit pcntl ist nur eines von mehreren Werkzeugen für nebenläufige Verarbeitung in PHP. Die Wahl hängt maßgeblich davon ab, ob die Arbeit CPU gebunden oder I/O gebunden ist, und ob echte parallele Ausführung auf mehreren Kernen benötigt wird oder kooperative Nebenläufigkeit in einem Thread ausreicht.

Werkzeug Echte Parallelität Geeignet für Einschränkung
pcntl_fork Ja, mehrere Kerne CPU-gebundene Batch-Jobs Nur Unix, kein geteilter Speicher
Fibers Nein, ein Thread I/O-gebundene Nebenläufigkeit Kein CPU-Geschwindigkeitsgewinn
Swoole Coroutines Ja, mit Worker-Prozessen High-Throughput Server Erweiterung, eigene Laufzeitumgebung
ext-parallel Ja, echte Threads Isolierte CPU-Berechnungen Strikte Datenisolation zwischen Threads
Queue-Worker (mehrere PHP-Prozesse) Ja, per Betriebssystem Verteilte, langfristige Jobs Zusätzliche Infrastruktur nötig

Für viele Projekte ist ein simples pcntl_fork basiertes Skript ausreichend, insbesondere für einmalige Batch Verarbeitung oder CLI Tools, die auf einem einzelnen Server laufen. Sobald die Verarbeitung über einen einzelnen Server hinaus skalieren muss, ist eine Message Queue mit mehreren unabhängigen PHP FPM oder CLI Worker Prozessen meist die robustere, wenn auch aufwendigere Lösung, weil sie horizontale Skalierung über mehrere Maschinen erlaubt, was Prozess-Parallelisierung mit pcntl innerhalb eines einzelnen Prozessbaums naturgemäß nicht kann.

Mironsoft

CLI-Automatisierung und Batch-Verarbeitung mit PHP

Batch-Jobs, die alle Kerne wirklich nutzen?

Wir entwerfen robuste Worker-Pools mit pcntl, inklusive sauberem Signal-Handling, Zombie-Vermeidung und Retry-Logik, damit eure Batch-Verarbeitung zuverlässig alle verfügbaren CPU-Kerne ausschöpft.

Worker-Pool Design

Feste Prozessanzahl, Supervisor-Logik und geordnetes Herunterfahren

Signal-Handling

SIGTERM, SIGCHLD und geordnete Weitergabe an Kindprozesse

Monitoring

Zombie-Prozesse erkennen, Retry-Strategien und Logging pro Prozess

10. Zusammenfassung

Die Prozess-Parallelisierung mit pcntl ist das richtige Werkzeug, wenn PHP Code echte, CPU gebundene Arbeit auf mehreren Kernen gleichzeitig ausführen soll, ein Ziel, das weder Fibers noch ReactPHP erreichen können. pcntl_fork erzeugt echte Betriebssystem Prozesse mit isoliertem Speicher über Copy on Write, pcntl_wait und pcntl_waitpid verhindern Zombie Prozesse, und Signal Handling mit pcntl_async_signals ermöglicht ein sauberes, geordnetes Herunterfahren des gesamten Worker Pools.

Die größten Fallstricke bei Prozess-Parallelisierung liegen in geteilten Ressourcen, die nicht wirklich geteilt werden können, in vergessener Zombie Bereinigung und in fehlendem Signal Handling für den Produktionsbetrieb. Wer diese drei Punkte konsequent adressiert, erhält mit reinem PHP und ohne zusätzliche Erweiterungen einen robusten Mechanismus für parallele Batch Verarbeitung, der sich nahtlos in bestehende CLI Skripte und Cron Jobs einfügt.

Prozess-Parallelisierung mit pcntl — Das Wichtigste auf einen Blick

Fork erzeugen

pcntl_fork() gibt im Elternprozess die Kind-PID zurück, im Kind 0, bei Fehler minus 1. Datenbankverbindungen erst danach öffnen.

Zombies vermeiden

pcntl_waitpid mit WNOHANG in einer nicht blockierenden Schleife holt beendete Kinder zuverlässig ab.

Signal-Handling

pcntl_async_signals(true) plus SIGTERM/SIGCHLD Handler ermöglichen geordnetes Herunterfahren.

Grenzen kennen

Nur Unix, kein automatisch geteilter Speicher, Prozessanzahl an Kernanzahl orientieren.

11. FAQ: Prozess-Parallelisierung mit pcntl

1Was macht pcntl_fork genau?
Dupliziert den aktuellen Prozess vollständig. Elternprozess erhält die Kind-PID, Kindprozess erhält 0, bei Fehler minus 1.
2Läuft pcntl unter Windows?
Nein, nur unter Unix artigen Systemen verfügbar. Code muss auf Linux oder macOS getestet und betrieben werden.
3Was ist ein Zombie-Prozess?
Ein beendeter Kindprozess, dessen Status noch nicht abgeholt wurde. pcntl_wait/pcntl_waitpid regelmäßig aufrufen, um Zombies zu vermeiden.
4Teilen sich Kindprozesse Variablen?
Nein, nach dem Fork sind beide Prozesse unabhängig. Für Datenaustausch sind explizite Mechanismen wie Shared Memory oder eine Datenbank nötig.
5Warum DB-Verbindungen erst nach dem Fork?
Eine vorher geöffnete Verbindung wird als derselbe Socket dupliziert. Gleichzeitiges Schreiben verursacht Protokollfehler.
6Was macht pcntl_async_signals?
Aktiviert asynchrone Signalverarbeitung, Handler laufen sofort bei Signal-Eintreffen ohne manuelles Dispatching.
7Wie viele Kindprozesse gleichzeitig?
An der Anzahl physischer Kerne orientieren. Mehr Prozesse senken den Durchsatz durch Kontextwechsel eher, als ihn zu erhöhen.
8Wie erkenne ich Beendigung durch Signal?
pcntl_wifsignaled prüft das, pcntl_wtermsig liefert die Signalnummer. pcntl_wifexited liefert in diesem Fall false.
9Ist pcntl schneller als Fibers?
Für CPU-Arbeit ja, da pcntl echte Prozesse auf mehreren Kernen parallel ausführt. Fibers laufen kooperativ in einem Thread.
10Wann Message Queue statt pcntl?
Sobald über einen einzelnen Server hinaus skaliert werden muss. pcntl bleibt innerhalb eines Prozessbaums auf einem Server.