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.
Inhaltsverzeichnis
- 1. Warum pcntl für Prozess-Parallelisierung existiert
- 2. pcntl_fork: wie ein Kindprozess entsteht
- 3. pcntl_wait und pcntl_waitpid: Zombies vermeiden
- 4. Einen Worker-Pool mit fester Prozessanzahl bauen
- 5. Signal-Handling: SIGTERM, SIGCHLD und pcntl_async_signals
- 6. Kommunikation zwischen Prozessen: was pcntl NICHT löst
- 7. Speicherisolation: Copy-on-Write und geteilte Ressourcen
- 8. Fehlerbehandlung in Kindprozessen richtig gestalten
- 9. pcntl im Vergleich zu Fibers, Swoole und Queue-Workern
- 10. Zusammenfassung
- 11. FAQ
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.