Paralleles PHP mit Swoole Coroutines: Der komplette Praxisleitfaden
AI generated
<?php
8.4
PHP · Swoole · Nebenläufigkeit · Performance
Paralleles PHP mit Swoole Coroutines
Channels, WaitGroup und Connection-Pooling im Detail

Swoole Coroutines erlauben es, in PHP tausende gleichzeitige Aufgaben effizient zu bearbeiten, ohne Threads oder zusätzliche Prozesse zu starten. Wer versteht, wie der Coroutine-Scheduler kooperativ arbeitet und wie Channels und WaitGroup Ergebnisse synchronisieren, kann mit Swoole Coroutines echte Parallelität in PHP nutzen, ohne die klassischen Fallstricke von Threading zu erben.

19 Min. Lesezeit Swoole 5.x · PHP 8.4 · Channels · WaitGroup Coroutine-Scheduler · Connection-Pool

1. Was Swoole Coroutines wirklich sind

Eine Swoole Coroutine ist ein leichtgewichtiger Ausführungskontext innerhalb eines einzigen PHP-Prozesses, der bei blockierenden Operationen freiwillig die Kontrolle abgibt, statt den ganzen Prozess anzuhalten. Anders als ein Betriebssystem-Thread benötigt eine Coroutine keinen eigenen Kernel-Stack in voller Größe und keinen Kontextwechsel durch den Scheduler des Betriebssystems. Ein einzelner Swoole-Worker-Prozess kann so problemlos zehntausende gleichzeitig laufende Coroutines verwalten, während klassisches PHP-FPM für dieselbe Last hunderte separate Prozesse bräuchte.

Der entscheidende Unterschied zu klassischem synchronem PHP liegt darin, dass I/O-Operationen wie Datenbankabfragen, HTTP-Requests oder Datei-Zugriffe innerhalb einer Swoole Coroutine automatisch non-blocking ausgeführt werden, sofern die entsprechenden Hooks aktiviert sind. Während eine Coroutine auf die Antwort einer MySQL-Abfrage wartet, kann der Scheduler eine andere Coroutine ausführen, die inzwischen bereit ist. Das Ergebnis ist eine deutlich höhere Durchsatzrate pro Prozess, ohne dass der Entwickler explizit Callbacks oder Promises schreiben muss.

Wichtig für das Verständnis: Swoole Coroutines laufen niemals parallel auf mehreren CPU-Kernen innerhalb desselben Prozesses. Echte Parallelität über mehrere Kerne entsteht erst durch mehrere Swoole-Worker-Prozesse, die das Betriebssystem auf unterschiedliche Kerne verteilt. Innerhalb eines Prozesses handelt es sich um kooperative Nebenläufigkeit, nicht um Parallelität im strengen Sinn, ein Unterschied, der für die Kapazitätsplanung entscheidend ist.

2. Der Coroutine-Scheduler: kooperativ statt präemptiv

Der Scheduler von Swoole arbeitet kooperativ. Das bedeutet, eine Coroutine gibt die Kontrolle nur an bestimmten Punkten ab, nämlich immer dann, wenn sie auf ein I/O-Ereignis wartet, das über die von Swoole gehookten Funktionen läuft. Ruft eine Swoole Coroutine hingegen eine CPU-intensive Schleife ohne I/O auf, blockiert sie den gesamten Worker-Prozess, bis die Schleife fertig ist. Das ist der häufigste Denkfehler bei Einsteigern: Sie erwarten präemptives Multitasking wie bei Threads, bekommen aber kooperatives Multitasking, bei dem lange CPU-Arbeit ohne Yield-Punkt alle anderen Coroutines im selben Prozess blockiert.

Die Funktion Coroutine::create() startet eine neue Coroutine und übergibt die Kontrolle sofort an den Scheduler, sobald der Code auf eine gehookte I/O-Funktion trifft. Intern verwaltet Swoole eine Warteschlange von wartenden Coroutines und einen Event-Loop auf Basis von epoll unter Linux, der signalisiert, sobald ein Socket lesbar oder schreibbar ist. Für den Anwendungsentwickler bleibt dieser Mechanismus meist unsichtbar, solange er versteht, dass er niemals manuell auf einen Kontextwechsel warten muss, Swoole übernimmt das transparent bei jedem gehookten Aufruf.

Ein zweiter wichtiger Aspekt des Schedulers: Coroutines innerhalb desselben Prozesses teilen sich den Speicher, ähnlich wie Threads. Das macht die Kommunikation zwischen ihnen potenziell schnell, erzeugt aber dieselben Race-Condition-Risiken wie bei geteiltem Speicher in anderen Sprachen. Wer Swoole Coroutines produktiv einsetzt, muss deshalb genauso diszipliniert mit gemeinsamem Zustand umgehen wie bei klassischem Multithreading, auch wenn der Scheduler kooperativ statt präemptiv arbeitet.


<?php

declare(strict_types=1);

use Swoole\Coroutine;
use function Swoole\Coroutine\run;

// run() starts a top-level coroutine context and blocks until all
// child coroutines created inside it have finished.
run(function (): void {
    Coroutine::create(function (): void {
        echo "Coroutine A started" . PHP_EOL;
        Coroutine::sleep(1.0); // yields control, does not block the process
        echo "Coroutine A finished after 1s" . PHP_EOL;
    });

    Coroutine::create(function (): void {
        echo "Coroutine B started" . PHP_EOL;
        Coroutine::sleep(0.5);
        echo "Coroutine B finished after 0.5s" . PHP_EOL;
    });

    echo "Both coroutines scheduled, main context continues" . PHP_EOL;
});

3. Die erste Swoole Coroutine starten

Bevor eine Swoole Coroutine irgendeinen praktischen Nutzen bringt, muss die Erweiterung installiert sein, üblicherweise per pecl install swoole oder über die Vorkompilierung mit dem passenden PHP-Build. Danach lässt sich jede Coroutine-Funktion entweder innerhalb eines Swoole-HTTP-Servers verwenden, wo Coroutines automatisch pro eingehendem Request aktiviert werden, oder in einem eigenständigen CLI-Skript über Swoole\Coroutine\run(). Letzteres ist der einfachste Einstieg, um das Verhalten von Swoole Coroutines ohne Server-Overhead zu testen.

Ein häufiger Anwendungsfall ist das parallele Abrufen mehrerer HTTP-Ressourcen. Statt fünf HTTP-Requests nacheinander mit Guzzle synchron abzuarbeiten, was die Gesamtlaufzeit summiert, startet man fünf Swoole Coroutines, die alle gleichzeitig auf die jeweilige Antwort warten. Die Gesamtlaufzeit entspricht dann annähernd der langsamsten einzelnen Anfrage statt der Summe aller Anfragen, ein Effekt, der bei I/O-lastigen Anwendungen wie Aggregations-APIs erhebliche Latenzgewinne bringt.


<?php

declare(strict_types=1);

use Swoole\Coroutine\Http\Client;
use function Swoole\Coroutine\run;
use function Swoole\Coroutine\batch;

// Fetch several API endpoints concurrently instead of sequentially.
run(function (): void {
    $hosts = ['api-a.internal', 'api-b.internal', 'api-c.internal'];

    $callables = array_map(
        static fn (string $host): callable => function () use ($host): array {
            $client = new Client($host, 443, true);
            $client->get('/status');
            $body = $client->body;
            $client->close();

            return ['host' => $host, 'body' => $body];
        },
        $hosts
    );

    // batch() runs all callables as coroutines and waits for every result.
    $results = batch($callables, 3.0);

    foreach ($results as $result) {
        echo sprintf('%s answered with %d bytes' . PHP_EOL, $result['host'], strlen($result['body']));
    }
});

4. Channels: sichere Kommunikation zwischen Coroutines

Ein Swoole\Coroutine\Channel ist die primäre Struktur, um Daten sicher zwischen mehreren Swoole Coroutines auszutauschen. Ein Channel funktioniert wie eine threadsichere Warteschlange mit fester Kapazität. Ist die Kapazität erreicht, blockiert push() die aufrufende Coroutine, ohne den restlichen Prozess zu blockieren, bis eine andere Coroutine mit pop() Platz schafft. Dieses Producer-Consumer-Muster ersetzt in Swoole klassische Mutex-Konstrukte, weil der Channel selbst die Synchronisation übernimmt.

Channels sind besonders nützlich, wenn eine feste Anzahl von Worker-Coroutines Aufgaben aus einer gemeinsamen Warteschlange abarbeiten soll, ein Muster, das dem klassischen Worker-Pool ähnelt, aber ohne Threads oder zusätzliche Prozesse auskommt. Ein weiterer typischer Einsatz ist das Signalisieren eines Ergebnisses aus einer Hintergrund-Coroutine an den Hauptkontext, indem die Hauptkoroutine blockierend auf pop() wartet, bis die Hintergrund-Coroutine ihr Ergebnis über push() liefert.


<?php

declare(strict_types=1);

use Swoole\Coroutine\Channel;
use function Swoole\Coroutine\run;

// Producer/consumer pattern using a bounded channel with 5 slots.
run(function (): void {
    $channel = new Channel(5);

    // Producer coroutine: pushes ten jobs, blocks once the channel is full.
    \Swoole\Coroutine::create(function () use ($channel): void {
        for ($i = 1; $i <= 10; $i++) {
            $channel->push(['job_id' => $i]);
        }
        $channel->close();
    });

    // Consumer coroutine: pops jobs until the channel is closed and drained.
    \Swoole\Coroutine::create(function () use ($channel): void {
        while (true) {
            $job = $channel->pop();
            if ($job === false && $channel->errCode === SWOOLE_CHANNEL_CLOSED) {
                break;
            }
            echo sprintf('Processing job #%d' . PHP_EOL, $job['job_id']);
        }
    });
});

5. WaitGroup: mehrere Coroutines synchron zusammenführen

Swoole\Coroutine\WaitGroup löst ein anderes Problem als Channels: Es geht nicht um den Austausch von Daten, sondern um das synchrone Warten, bis eine definierte Anzahl von Swoole Coroutines abgeschlossen ist, bevor der Hauptkontext fortfährt. Das entspricht konzeptionell dem wait-Kommando bei Hintergrundprozessen in Bash oder Promise.all() in JavaScript, nur eben innerhalb eines einzelnen PHP-Prozesses und ohne Callback-Verschachtelung.

Der Ablauf ist immer derselbe: Vor dem Start jeder Coroutine wird $wg->add() aufgerufen, um den Zähler zu erhöhen. Am Ende jeder Coroutine ruft man $wg->done() auf, um den Zähler zu verringern. Der Hauptkontext blockiert bei $wg->wait(), bis der Zähler wieder null erreicht. Dieses Muster ist deutlich lesbarer als das manuelle Verwalten eines Channels nur für Synchronisationszwecke und eignet sich hervorragend, um mehrere parallele Datenbankabfragen zu bündeln und erst nach Abschluss aller Abfragen mit dem aggregierten Ergebnis weiterzuarbeiten.


<?php

declare(strict_types=1);

use Swoole\Coroutine\WaitGroup;
use function Swoole\Coroutine\run;

// Aggregate results from three independent coroutines using WaitGroup.
run(function (): void {
    $wg = new WaitGroup();
    $results = [];

    $tasks = ['orders' => 120, 'customers' => 45, 'products' => 980];

    foreach ($tasks as $key => $count) {
        $wg->add();
        \Swoole\Coroutine::create(function () use ($wg, &$results, $key, $count): void {
            \Swoole\Coroutine::sleep(0.2); // simulate a database round-trip
            $results[$key] = $count;
            $wg->done();
        });
    }

    $wg->wait(); // blocks the main coroutine until all three are done

    echo sprintf('Aggregated dashboard: %s' . PHP_EOL, json_encode($results));
});

6. Coroutine-Context statt globaler Variablen

Ein klassisches PHP-Skript hat pro Request genau einen Ausführungskontext, weshalb globale Variablen oder statische Klassenattribute unproblematisch als Request-weiter Speicher genutzt werden können. Sobald mehrere Swoole Coroutines im selben Prozess laufen, wird dieses Muster gefährlich: Eine statische Eigenschaft, die von Coroutine A gesetzt wird, ist auch für Coroutine B sichtbar, obwohl beide logisch unabhängige Anfragen bearbeiten. Das führt zu schwer reproduzierbaren Bugs, bei denen Daten des einen Requests im anderen auftauchen.

Die Lösung ist der Swoole\Coroutine::getContext()-Mechanismus, der jeder Coroutine einen eigenen, isolierten Speicherbereich zuweist, vergleichbar mit Thread-Local-Storage in anderen Sprachen. Werte, die in diesem Context abgelegt werden, sind ausschließlich innerhalb der aktuellen Coroutine und ihrer Kind-Coroutines sichtbar. Frameworks, die Swoole-Support anbieten, wie Hyperf oder Swoft, nutzen diesen Context intern, um Dinge wie den aktuellen Request oder eine Datenbankverbindung pro Coroutine zu isolieren, ohne dass Entwickler globale Variablen anfassen müssen.

Wer eigene Bibliotheken für den Einsatz mit Swoole Coroutines schreibt, sollte grundsätzlich jeden gemeinsam genutzten, veränderlichen Zustand über den Coroutine-Context statt über statische Klasseneigenschaften verwalten. Diese eine Designentscheidung verhindert die überwiegende Mehrheit der Bugs, die neue Teams beim Umstieg von synchronem PHP-FPM auf Swoole-Coroutine-Server erleben.

7. Connection-Pooling für Datenbank und Redis

In einem klassischen PHP-FPM-Setup öffnet und schließt jeder Request seine eigene Datenbankverbindung, was bei niedriger bis mittlerer Last unproblematisch ist. Bei einem Swoole-Server mit tausenden gleichzeitigen Swoole Coroutines pro Worker-Prozess würde dasselbe Muster jedoch zu einer Explosion gleichzeitiger Datenbankverbindungen führen, weit über das hinaus, was MySQL oder PostgreSQL im Standard-Setup verkraften. Die Antwort darauf ist ein Connection-Pool, der eine begrenzte Anzahl an Verbindungen vorhält und sie zwischen Coroutines wiederverwendet.

Ein einfacher Connection-Pool lässt sich mit einem Channel als Grundlage bauen: Der Pool wird mit einer festen Anzahl bereits geöffneter Verbindungen befüllt. Jede Coroutine, die eine Verbindung braucht, ruft pop() auf, nutzt die Verbindung, und gibt sie am Ende mit push() wieder zurück in den Pool. Ist der Pool leer, blockiert pop() die anfragende Coroutine, ohne den Prozess anzuhalten, bis eine andere Coroutine ihre Verbindung zurückgibt. Damit lässt sich die Anzahl gleichzeitiger Datenbankverbindungen präzise auf einen Wert begrenzen, den die Datenbank verlässlich verkraftet.


<?php

declare(strict_types=1);

use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;

final class MysqlConnectionPool
{
    private Channel $pool;

    /**
     * Builds a bounded pool of pre-opened MySQL connections for coroutine reuse.
     */
    public function __construct(private readonly array $config, int $size = 20)
    {
        $this->pool = new Channel($size);
        for ($i = 0; $i < $size; $i++) {
            $this->pool->push($this->createConnection());
        }
    }

    private function createConnection(): MySQL
    {
        $mysql = new MySQL();
        $mysql->connect($this->config);

        return $mysql;
    }

    public function borrow(): MySQL
    {
        return $this->pool->pop(5.0); // waits up to 5s for a free connection
    }

    public function return(MySQL $connection): void
    {
        $this->pool->push($connection);
    }
}

// Usage inside a coroutine handler:
// $conn = $pool->borrow();
// $rows = $conn->query('SELECT id, sku FROM catalog_product LIMIT 50');
// $pool->return($conn);

8. Fallstricke: blockierende Aufrufe und globaler State

Der größte praktische Fallstrick bei Swoole Coroutines ist der versehentliche Einsatz nicht-gehookter, blockierender Funktionen. Ruft eine Coroutine beispielsweise die native PHP-Funktion sleep() statt Coroutine::sleep() auf, blockiert das den gesamten Worker-Prozess und damit alle anderen gleichzeitig laufenden Coroutines, nicht nur die aufrufende. Dasselbe gilt für file_get_contents() gegen eine langsame Netzwerkressource ohne aktivierte Runtime-Hooks, für curl_exec() ohne Swoole-Hook oder für rechenintensive Schleifen ohne jeden I/O-Yield-Punkt.

Swoole bietet dafür die Funktion Swoole\Runtime::enableCoroutine(), die viele native PHP-Funktionen wie Datei- und Netzwerkoperationen transparent auf ihre non-blocking Coroutine-Varianten umleitet, sogenanntes One-Shot-Hooking. Trotzdem bleibt es Aufgabe des Entwicklers, in Bibliotheken von Drittanbietern zu prüfen, ob sie kompatibel sind, denn manche C-Erweiterungen lassen sich grundsätzlich nicht hooken und blockieren den Prozess unabhängig von der Konfiguration.

Ein zweiter wichtiger Fallstrick betrifft Exceptions in Coroutines: Wird eine Exception innerhalb einer per Coroutine::create() gestarteten Coroutine nicht gefangen, terminiert das nur diese eine Coroutine, nicht den gesamten Prozess, was Fehler leicht unbemerkt lässt, wenn kein zentrales Error-Handling über set_exception_handler() eingerichtet wurde. Für produktiven Einsatz von Swoole Coroutines ist ein globaler Exception-Handler, der Fehler konsequent loggt, deshalb kein optionales Detail, sondern eine Grundvoraussetzung.

9. Swoole Coroutines im Vergleich zu klassischen Modellen

Um die Entscheidung für oder gegen Swoole Coroutines fundiert zu treffen, hilft ein direkter Vergleich mit den Alternativen, die in der PHP-Welt für Nebenläufigkeit zur Verfügung stehen. Jedes Modell hat einen klar abgegrenzten Anwendungsbereich, und die Wahl hängt stark davon ab, ob der Engpass bei I/O-Wartezeiten oder bei reiner CPU-Last liegt.

Modell Nebenläufigkeitsart Speicherverbrauch Typischer Einsatz
PHP-FPM (synchron) Ein Request pro Prozess Hoch bei vielen Prozessen Klassische Webanwendungen
pcntl-Fork Echte OS-Prozesse Hoch, eigener Speicherbereich CPU-lastige Batch-Jobs
Fibers (nativ) Kooperativ, manuell verdrahtet Niedrig Custom-Scheduler, Bibliotheken
Swoole Coroutines Kooperativ, automatisch gehookt Sehr niedrig pro Coroutine I/O-lastige Server, APIs, Worker

Der entscheidende Vorteil von Swoole Coroutines gegenüber rohen Fibers ist, dass Swoole die I/O-Hooks bereits mitliefert. Wer stattdessen mit nativen Fibers arbeitet, muss den Scheduler und die Non-Blocking-I/O-Integration selbst schreiben, ein erheblicher zusätzlicher Aufwand. Gegenüber pcntl-Forks sind Swoole Coroutines um Größenordnungen leichtgewichtiger, weil kein eigener Prozess mit eigenem Speicherbereich und eigenem Kernel-Scheduling-Overhead entsteht, dafür teilen sie sich Speicher und damit auch dessen Risiken.

Mironsoft

PHP-Performance, Nebenläufigkeit und Swoole-Architektur

Zu langsame PHP-Prozesse für hohe I/O-Last?

Wir analysieren bestehende PHP-Anwendungen auf Engpässe, entwerfen Swoole-Coroutine-Architekturen mit Connection-Pooling und begleiten die Migration von PHP-FPM auf nebenläufige Worker-Modelle.

Architektur-Review

Bewertung, ob Swoole Coroutines für Ihre Last überhaupt geeignet sind

Connection-Pooling

Sichere Pools für MySQL, PostgreSQL und Redis unter Coroutine-Last

Migration

Schrittweiser Umstieg von PHP-FPM ohne Big-Bang-Rewrite

10. Zusammenfassung

Swoole Coroutines ermöglichen echte, leichtgewichtige Nebenläufigkeit in PHP, ohne dass Entwickler manuell Threads verwalten oder Callback-Hölle in Kauf nehmen müssen. Der Scheduler arbeitet kooperativ, gibt Kontrolle bei jedem gehookten I/O-Aufruf ab, und lässt CPU-intensive Schleifen ohne Yield-Punkt blockierend laufen. Channels lösen sichere Datenübergabe zwischen Coroutines, WaitGroup synchronisiert parallele Aufgaben, und der Coroutine-Context ersetzt globale Variablen als isolierten Speicherbereich pro Coroutine.

Der praktische Nutzen entfaltet sich vor allem bei I/O-lastigen Anwendungen: APIs, die mehrere Backend-Systeme aggregieren, Worker, die parallele Datenbankabfragen bündeln, oder Server, die tausende gleichzeitige Verbindungen halten müssen. Wer Swoole Coroutines einführt, sollte von Anfang an Connection-Pooling einplanen, blockierende Aufrufe konsequent vermeiden und einen globalen Exception-Handler einrichten, um stille Fehler in einzelnen Coroutines nicht zu übersehen.

Swoole Coroutines — Das Wichtigste auf einen Blick

Kooperativer Scheduler

Kontrollabgabe nur bei gehooktem I/O. CPU-Schleifen ohne Yield blockieren den ganzen Prozess.

Channels & WaitGroup

Channel für Datenaustausch, WaitGroup für synchrones Zusammenführen mehrerer paralleler Aufgaben.

Coroutine-Context

Isolierter Speicher pro Coroutine statt globaler Variablen, verhindert Datenvermischung zwischen Requests.

Connection-Pooling

Begrenzter Pool wiederverwendeter Verbindungen verhindert Explosion gleichzeitiger DB-Connections.

11. FAQ: Swoole Coroutines

1Sind Swoole Coroutines dasselbe wie Threads?
Nein. Coroutines laufen kooperativ im selben Prozess und Kern, Threads werden präemptiv vom OS geplant und können parallel auf mehreren Kernen laufen.
2Warum blockiert meine Coroutine den ganzen Prozess?
Vermutlich eine nicht gehookte blockierende Funktion wie natives sleep() oder curl_exec() ohne Hook. Nur gehookte I/O-Aufrufe geben die Kontrolle zurück.
3Channel oder WaitGroup?
Channel für tatsächlichen Datenaustausch im Producer-Consumer-Muster. WaitGroup für reines synchrones Warten ohne Datenübergabe.
4Warum Connection-Pooling?
Ohne Pool öffnet jede Coroutine eine eigene Verbindung. Bei tausenden Coroutines übersteigt das schnell die Kapazität der Datenbank.
5Funktionieren bestehende Bibliotheken einfach?
Teilweise. Reine PHP-Funktionen mit Hooks funktionieren meist. Bibliotheken mit eigenen C-Erweiterungen können nicht gehookt werden und blockieren.
6Was passiert bei ungefangener Exception?
Nur die betroffene Coroutine terminiert, der Prozess läuft weiter. Ohne globalen Exception-Handler bleibt der Fehler oft unbemerkt.
7Wie viele Coroutines gleichzeitig möglich?
Problemlos zehntausende, da jede nur wenige Kilobyte Stack braucht. Grenze hängt von Speicher und Komplexität ab.
8Ersetzen sie pcntl-Prozesse?
Nein. Für echte CPU-Parallelität über mehrere Kerne bleiben separate Prozesse nötig, Coroutines lösen primär I/O-Wartezeiten.
9Wie isoliere ich Zustand pro Request?
Über Coroutine::getContext() statt globaler Variablen. Vergleichbar mit Thread-Local-Storage in anderen Sprachen.
10Lohnt sich der Umstieg immer?
Nein. Der größte Nutzen entsteht bei I/O-lastigen Anwendungen mit vielen externen Aufrufen, einfache CRUD-Systeme profitieren oft kaum davon.