Echtes Multithreading in PHP mit der parallel Extension
AI generated
<?php
8.4
PHP · parallel Extension · Threads · ZTS
Echtes Multithreading in PHP
mit der parallel Extension

Die parallel Extension bringt echtes Multithreading nach PHP: Runtime, Future und Channel erzeugen isolierte Betriebssystem-Threads statt kooperativer Nebenläufigkeit. Wer die strikte Datenisolation zwischen Threads versteht und die Grenzen von Closures und Autoloading kennt, kann CPU-intensive Aufgaben spürbar beschleunigen, ohne auf externe Erweiterungen wie Swoole umzusteigen.

16 Min. Lesezeit Runtime · Future · Channel · ZTS PHP 8.4 ZTS · ext-parallel

1. Was die parallel Extension anders macht als pcntl

Die parallel Extension, entwickelt von Joe Watkins, ist die einzige verbreitete Möglichkeit, echte POSIX Threads direkt aus PHP heraus zu starten. Anders als pcntl_fork, das komplette Betriebssystem Prozesse mit eigenem Speicher dupliziert, erzeugt die parallel Extension Threads innerhalb desselben Prozesses, die sich denselben virtuellen Speicherraum teilen. Das senkt den Overhead beim Start erheblich, verlangt aber im Gegenzug eine viel strengere Trennung der Daten zwischen den Threads, um Race Conditions zu verhindern.

Der zentrale Designentscheid der parallel Extension lautet: Es gibt keinen impliziten geteilten Zustand zwischen Threads. Jeder Thread bekommt seinen eigenen isolierten Interpreter Zustand, eigene Objekte und eigene Variablen, selbst wenn der Code auf denselben physischen Speicher zugreift. Diese Entscheidung unterscheidet sich fundamental von klassischem Multithreading in Sprachen wie Java oder C, wo Threads standardmäßig denselben Heap teilen und Entwickler explizit synchronisieren müssen. PHP geht den umgekehrten Weg: Isolation ist die Voreinstellung, geteilte Daten müssen explizit über Channel ausgetauscht werden.

Für viele CPU intensive Aufgaben ist diese Isolation kein Nachteil, sondern ein Sicherheitsgewinn. Bildverarbeitung, Hashing, komplexe mathematische Berechnungen oder das Parsen großer Dateien lassen sich in unabhängige, parallel laufende Einheiten aufteilen, ohne dass jemals zwei Threads gleichzeitig dieselbe Variable verändern könnten. Die parallel Extension macht diese Art der Parallelisierung in PHP praktikabel, wo sie vorher nur über pcntl_fork mit deutlich höherem Startaufwand möglich war.

2. Voraussetzung: PHP im ZTS Modus kompilieren

Die parallel Extension funktioniert ausschließlich mit einer PHP Installation, die im Thread Safe Modus, kurz ZTS, kompiliert wurde. Die meisten Standard PHP Pakete aus Distributions Repositories sind Non ZTS Builds, optimiert für den klassischen PHP FPM Betrieb ohne Threads. Vor dem Einsatz der parallel Extension muss also entweder ein ZTS Build installiert oder PHP selbst mit dem Flag --enable-zts neu kompiliert werden.

Der Thread Safe Modus fügt an vielen internen Stellen zusätzliche Synchronisationsmechanismen ein, damit interne Zend Engine Strukturen nicht von mehreren Threads gleichzeitig inkonsistent verändert werden. Das kostet minimal Performance im Vergleich zu Non ZTS Builds bei rein sequenzieller Ausführung, ist aber die zwingende Voraussetzung, um die parallel Extension überhaupt sicher nutzen zu können. Wer bereits Swoole oder andere Thread basierte Erweiterungen einsetzt, hat meist schon einen ZTS Build im Einsatz.

In Docker basierten Setups lässt sich ein ZTS Build über offizielle PHP Images mit dem Tag zts beziehen, etwa php:8.4-zts-cli. Für lokale Entwicklung empfiehlt sich, dieselbe ZTS Variante zu verwenden wie in Produktion, um Unterschiede im Verhalten der parallel Extension zwischen Entwicklungs und Produktionsumgebung von vornherein auszuschließen.


# Verify whether the installed PHP is ZTS-enabled
php -i | grep "Thread Safety"
# Thread Safety => enabled   <- required for ext-parallel

# Install ext-parallel via PECL against a ZTS build
pecl install parallel

# Enable the extension explicitly in php.ini
echo "extension=parallel.so" >> /usr/local/etc/php/conf.d/parallel.ini

# Docker: use an official ZTS image as base
# FROM php:8.4-zts-cli

3. Runtime und Future: einen Thread starten und Ergebnisse abholen

Die Kernklasse der parallel Extension ist parallel\Runtime. Ein Runtime Objekt repräsentiert einen eigenen PHP Interpreter, der in einem separaten Thread läuft. Der Konstruktor akzeptiert optional einen Bootstrap Pfad, eine Datei, die in jedem neuen Thread vor der eigentlichen Arbeit geladen wird, typischerweise der Composer Autoloader. Die Methode run nimmt eine Closure entgegen und führt sie im zugehörigen Thread aus, wobei sie sofort ein Future Objekt zurückgibt, ohne auf das Ergebnis zu warten.

Das Future Objekt repräsentiert das noch ausstehende Ergebnis der Thread Ausführung. Die Methode value blockiert den aufrufenden Thread, bis der Zielthread fertig ist, und liefert dann den Rückgabewert der Closure. Dieses Muster erlaubt es, mehrere Runtime Instanzen parallel zu starten, ohne auf jeden einzelnen sofort zu warten, und erst am Ende alle Future Objekte einzusammeln, ganz ähnlich dem Promise Muster aus asynchronem JavaScript, nur mit echter Parallelität statt kooperativem Multitasking.

Wichtig für die parallel Extension: Jeder Runtime Thread bleibt aktiv, bis er explizit mit close beendet wird oder das Objekt außer Scope gerät. Für kurzlebige, einmalige Berechnungen genügt das automatische Aufräumen, für Worker Pools mit wiederkehrender Arbeit sollten Runtime Instanzen wiederverwendet und mehrfach mit run aufgerufen werden, um den Overhead des Thread Starts nicht bei jeder einzelnen Aufgabe erneut zu bezahlen.


<?php

declare(strict_types=1);

use parallel\Runtime;
use parallel\Future;

/** @var Future[] $futures */
$futures = [];

for ($i = 1; $i <= 4; $i++) {
    // Each Runtime spawns a real OS thread with its own interpreter state
    $runtime = new Runtime();

    $futures[] = $runtime->run(function (int $chunkId): string {
        // CPU-bound work: hashing a large data chunk
        $data = str_repeat((string) $chunkId, 500_000);
        return hash('sha256', $data);
    }, [$i]);
}

foreach ($futures as $index => $future) {
    // value() blocks until this specific thread has finished
    $hash = $future->value();
    echo "Chunk {$index}: {$hash}\n";
}

4. Datenisolation: warum Closures nicht einfach Variablen einfangen

Ein Detail, das PHP Entwickler bei ihrem ersten Kontakt mit der parallel Extension überrascht: Closures, die an run übergeben werden, dürfen keine Variablen aus dem umgebenden Scope per use einfangen, sofern diese Objekte, Ressourcen oder Closures selbst enthalten. Der Grund liegt in der strikten Isolation zwischen Threads: Ein Objekt, das im Hauptthread erzeugt wurde, kann nicht einfach im Speicher eines anderen Threads existieren, weil beide Threads unabhängige Kopien der Zend Engine internen Strukturen besitzen.

Stattdessen werden alle Argumente, die eine Closure benötigt, explizit als zweites Array Argument an run übergeben. Die parallel Extension serialisiert diese Argumente, kopiert sie in den Zielthread und übergibt sie dort als Parameter der Closure. Nur einfache Datentypen wie Strings, Integers, Floats, Booleans und Arrays daraus lassen sich zuverlässig auf diese Weise übertragen, komplexe Objekte mit internen Ressourcen wie Datenbankverbindungen oder Dateihandles funktionieren nicht.

Diese Einschränkung ist kein Bug, sondern eine bewusste Sicherheitsmaßnahme der parallel Extension. Sie verhindert, dass zwei Threads versehentlich denselben Datenbank Socket oder dieselbe Dateiressource gleichzeitig verwenden und dadurch unvorhersehbare Fehler produzieren. Jeder Thread muss eigene Ressourcen wie Datenbankverbindungen innerhalb seiner eigenen Closure neu öffnen, ganz ähnlich wie bei pcntl_fork, wo Verbindungen ebenfalls erst nach dem Fork geöffnet werden sollten.

5. Channel: Nachrichten sicher zwischen Threads austauschen

Für Fälle, in denen Threads mehr als nur ein einmaliges Ergebnis austauschen müssen, bietet die parallel Extension die Klasse parallel\Channel. Ein Channel funktioniert wie eine threadsichere Warteschlange: Ein Thread sendet Werte mit send, ein anderer empfängt sie mit recv, wobei beide Operationen blockieren, bis ein Kommunikationspartner bereit ist, sofern der Channel nicht gepuffert wurde. Gepufferte Channels, erzeugt mit Channel::make($name, $capacity), erlauben eine begrenzte Anzahl ausstehender Nachrichten, ohne dass der Sender sofort blockiert.

Channels der parallel Extension eignen sich hervorragend für Producer Consumer Muster: ein Hauptthread erzeugt fortlaufend Aufgaben und sendet sie über einen Channel an mehrere Worker Threads, die Ergebnisse wiederum über einen zweiten Channel zurückschicken. Dieses Muster skaliert deutlich besser als das reine Future basierte Modell, wenn die Anzahl der zu verarbeitenden Aufgaben im Voraus nicht bekannt ist oder kontinuierlich neue Aufgaben eintreffen.

Ein Channel muss mit close explizit geschlossen werden, sobald keine weiteren Nachrichten mehr gesendet werden. Empfangende Threads, die auf recv warten, erhalten dann eine parallel\Channel\Error\Closed Exception, die als Signal für das Ende der Verarbeitung dient. Ohne dieses explizite Schließen blockieren wartende Threads unbegrenzt, ein häufiger Grund für scheinbar hängende Skripte bei der ersten Arbeit mit der parallel Extension.


<?php

declare(strict_types=1);

use parallel\Runtime;
use parallel\Channel;

$tasks = Channel::make('tasks', 10);
$results = Channel::make('results', 10);

// Start three worker threads that consume tasks and produce results
$workers = [];
for ($i = 0; $i < 3; $i++) {
    $runtime = new Runtime();
    $workers[] = $runtime->run(function (Channel $tasks, Channel $results): void {
        while (true) {
            try {
                $task = $tasks->recv();
            } catch (\parallel\Channel\Error\Closed) {
                break; // Producer finished, no more tasks
            }

            $results->send(hash('sha256', (string) $task));
        }
    }, [$tasks, $results]);
}

// Producer: send work items, then close the channel
foreach (range(1, 9) as $item) {
    $tasks->send($item);
}
$tasks->close();

// Collect the expected number of results
for ($i = 0; $i < 9; $i++) {
    echo $results->recv() . "\n";
}
$results->close();

6. Autoloading und geteilte Klassen in jedem Thread

Jeder Runtime Thread der parallel Extension startet mit einem leeren Interpreter Zustand, in dem noch keine Klassen des Anwendungscodes bekannt sind. Ohne einen Bootstrap Pfad im Konstruktor kennt der Thread weder Composer Autoloading noch eigene Klassendefinitionen, was jeden Aufruf einer nicht eingebauten Funktion oder Klasse mit einem Fehler enden lässt. Der Konstruktor new Runtime(__DIR__ . '/vendor/autoload.php') lädt den Composer Autoloader in jedem neuen Thread, bevor die eigentliche Closure ausgeführt wird.

Diese Notwendigkeit bringt einen messbaren Overhead pro Thread mit sich: Der Autoloader Bootstrap kostet Zeit, die bei sehr kurzlebigen Aufgaben ins Gewicht fallen kann. Für die parallel Extension gilt deshalb die Faustregel, Threads eher wiederzuverwenden als für jede einzelne kleine Aufgabe eine neue Runtime Instanz zu erzeugen. Ein Thread Pool mit wenigen, langlebigen Threads, die viele Aufgaben nacheinander über run oder Channels erhalten, amortisiert diesen Bootstrap Aufwand über die gesamte Laufzeit.

Klassen, die eine Closure innerhalb der parallel Extension verwendet, müssen entweder über den Autoloader nachladbar sein oder bereits vor dem Start des Threads in den Bootstrap Prozess eingebunden werden. Anonyme Klassen und dynamisch zur Laufzeit definierte Closures mit komplexen Abhängigkeiten sind ein häufiger Stolperstein, weil die Serialisierung des Closure Bytecodes zwischen Threads striktere Regeln hat als normales PHP Closure Verhalten im selben Prozess.

7. Typische Einsatzfälle: wann sich echte Threads lohnen

Die parallel Extension entfaltet ihren Vorteil bei rein CPU gebundener Arbeit, die sich in unabhängige Teilaufgaben zerlegen lässt: Bildthumbnails in großer Zahl generieren, große CSV oder JSON Dateien parsen und transformieren, kryptografisches Hashing für Passwort Migrationen, oder komplexe mathematische Simulationen, deren Teilergebnisse am Ende zusammengeführt werden. In all diesen Fällen skaliert die Verarbeitungszeit nahezu linear mit der Anzahl verfügbarer CPU Kerne.

Für I/O gebundene Aufgaben wie HTTP Requests oder Datenbankabfragen bringt die parallel Extension hingegen keinen Vorteil gegenüber einfacheren Alternativen wie Fibers oder ReactPHP. Ein Thread, der auf eine Netzwerkantwort wartet, blockiert zwar nur sich selbst und nicht die anderen Threads, aber der Aufwand für Thread Erzeugung und die strikte Datenisolation überwiegt den Nutzen gegenüber einem kooperativen Event Loop deutlich, der denselben Effekt mit erheblich weniger Ressourcenverbrauch erzielt.

Eine sinnvolle Kombination in der Praxis: ReactPHP für die I/O gebundene Netzwerkschicht einer Anwendung, kombiniert mit der parallel Extension für gelegentliche, klar abgegrenzte CPU intensive Teilaufgaben, die aus dem Event Loop heraus an einen Thread Pool ausgelagert werden, damit sie den Event Loop selbst nicht blockieren. Diese hybride Architektur nutzt jedes Werkzeug für die Aufgabe, für die es tatsächlich konzipiert wurde.

8. Fehlerbehandlung und Thread-Pool-Größe richtig wählen

Wirft eine Closure innerhalb eines Runtime Threads der parallel Extension eine Exception, wird diese beim Aufruf von value auf dem Future Objekt im aufrufenden Thread erneut geworfen, mit vollständigem Stacktrace des ursprünglichen Fehlers. Das erleichtert Debugging erheblich gegenüber pcntl_fork, wo Fehler in Kindprozessen nur über den Exit Code sichtbar werden, es sei denn, sie werden explizit protokolliert.

Die Wahl der Thread Pool Größe für die parallel Extension folgt derselben Faustregel wie bei pcntl_fork: Für CPU gebundene Arbeit orientiert man sich an der Anzahl physischer Kerne, ermittelbar über nproc auf Linux Systemen. Mehr Threads als Kerne bringen keinen zusätzlichen Durchsatz, weil das Betriebssystem ohnehin nur so viele Threads gleichzeitig tatsächlich rechnen lassen kann, wie physische Kerne vorhanden sind, zusätzliche Threads erzeugen nur mehr Kontextwechsel Overhead.

Ein robustes Muster: einen festen Thread Pool beim Start der Anwendung erzeugen, Aufgaben über einen gepufferten Channel verteilen, und bei Bedarf abgestürzte oder fehlerhafte Threads durch neue Runtime Instanzen ersetzen. Für Anwendungen mit stark schwankender Last kann die Poolgröße auch dynamisch angepasst werden, wobei die parallel Extension selbst keine eingebaute Autoscaling Logik mitbringt, diese muss auf Anwendungsebene implementiert werden.

9. parallel im Vergleich zu pcntl und Fibers

Die parallel Extension ist eines von mehreren Werkzeugen für Nebenläufigkeit in PHP, mit einem klaren Schwerpunkt auf CPU gebundene Parallelität bei geringerem Speicher Overhead als vollständige Prozesse.

Kriterium pcntl_fork parallel Extension Fibers
Isolationseinheit Kompletter Prozess Thread im selben Prozess Kein separater Thread
Startaufwand Hoch, ganzer Adressraum Mittel, Autoloader Bootstrap Sehr gering
Echte CPU Parallelität Ja Ja Nein
Voraussetzung Unix Betriebssystem ZTS Build erforderlich Ab PHP 8.1 überall
Datenaustausch Explizite IPC nötig Channel eingebaut Direkte Variablen

Die parallel Extension liegt in dieser Gegenüberstellung zwischen den beiden anderen Optionen: schneller im Start als pcntl_fork, aber mit strengeren Regeln für Datenaustausch als kooperative Fibers. Für Projekte, die bereits mit einem ZTS Build arbeiten und häufig kleine, klar abgegrenzte CPU Aufgaben parallelisieren müssen, ist sie oft die pragmatischste Wahl unter den dreien.

Mironsoft

CPU-intensive PHP Verarbeitung und ZTS Deployments

Rechenintensive PHP Jobs, die alle Kerne wirklich auslasten?

Wir bringen die parallel Extension in eure ZTS Umgebung, entwerfen Thread-Pools mit Runtime, Future und Channel und sorgen für saubere Datenisolation ohne Race Conditions.

ZTS Setup

Docker Images und Deployment für Thread Safe PHP konfigurieren

Thread-Pool Architektur

Runtime-Wiederverwendung, Channel-Kommunikation und Fehlerbehandlung

Performance-Analyse

CPU-Auslastung messen und Thread-Pool-Größe evidenzbasiert festlegen

10. Zusammenfassung

Die parallel Extension bringt echtes Multithreading nach PHP, mit Runtime und Future als Grundbausteinen für die Ausführung von Closures in separaten Threads und Channel für sicheren Nachrichtenaustausch zwischen ihnen. Die Voraussetzung eines ZTS Builds und die strikte Datenisolation zwischen Threads sind keine Einschränkungen, sondern bewusste Designentscheidungen, die Race Conditions von vornherein verhindern, statt sie durch manuelle Synchronisation nachträglich zu vermeiden.

Der größte Gewinn zeigt sich bei rein CPU gebundener Arbeit, die sich in unabhängige Teilaufgaben zerlegen lässt, während I/O gebundene Aufgaben besser mit Fibers oder ReactPHP gelöst werden. Wer die parallel Extension gezielt für Bildverarbeitung, Hashing oder große Datei Transformationen einsetzt und Threads sinnvoll wiederverwendet, statt sie für jede Kleinstaufgabe neu zu starten, erhält spürbare Geschwindigkeitsgewinne, die mit rein sequenziellem PHP Code nicht erreichbar wären.

Echtes Multithreading mit der parallel Extension — Das Wichtigste auf einen Blick

Voraussetzung

Nur mit ZTS Build von PHP nutzbar. php -i | grep "Thread Safety" vor dem Einsatz prüfen.

Runtime & Future

new Runtime(autoload) startet einen Thread, run() gibt ein Future zurück, value() blockiert bis zum Ergebnis.

Datenisolation

Keine geteilten Objekte per use. Argumente explizit übergeben, Ressourcen im Thread neu öffnen.

Channel

Threadsichere Warteschlange für Producer Consumer Muster. Immer explizit mit close() beenden.

11. FAQ: Echtes Multithreading in PHP mit der parallel Extension

1Was ist die parallel Extension?
Eine Erweiterung für echte POSIX Threads in einem einzigen Prozess, über Runtime, Future und Channel, im Gegensatz zu vollständigen Prozessen bei pcntl_fork.
2Warum ein ZTS Build nötig?
Der Thread Safe Modus synchronisiert interne Zend Engine Strukturen, ohne die mehrere Threads inkonsistente Zustände erzeugen könnten.
3Können Closures Variablen einfangen?
Nur einfache Typen wie Strings, Zahlen und Arrays, übergeben als Array-Argument an run(). Objekte und Ressourcen funktionieren nicht.
4Wofür der Bootstrap Pfad?
Jeder Thread startet leer. Der Bootstrap Pfad, meist der Composer Autoloader, macht Anwendungsklassen dort verfügbar.
5Was ist ein Channel?
Eine threadsichere Warteschlange für Producer Consumer Muster mit send() und recv() zwischen Threads.
6Was passiert beim Schließen eines Channels?
Wartende recv() Aufrufe werfen eine Closed Exception als Signal für das Verarbeitungsende. Ohne close() blockieren Threads unbegrenzt.
7Wie viele Threads gleichzeitig?
An der Anzahl physischer Kerne orientieren. Mehr Threads erhöhen nur den Kontextwechsel-Overhead ohne Durchsatzgewinn.
8Für HTTP Requests geeignet?
Nicht ideal. Fibers oder ReactPHP erreichen dieselbe Nebenläufigkeit für I/O-Aufgaben mit weniger Ressourcenverbrauch.
9Wie werden Fehler sichtbar?
Eine Exception wird bei value() auf dem Future Objekt erneut geworfen, mit vollständigem Stacktrace des Originalfehlers.
10Unterschied zu pcntl_fork?
pcntl_fork dupliziert ganze Prozesse, die parallel Extension erzeugt Threads im selben Prozess mit strikterer Isolation über Channel statt freier IPC.