Non-Blocking I/O in PHP verstehen: stream_select bis Sockets
AI generated
<?php
8.4
PHP · Sockets · Netzwerkprogrammierung · Fundamente
Non-Blocking I/O in PHP verstehen
Von stream_set_blocking bis zur eigenen Socket-Schleife

Bevor man ReactPHP oder Swoole produktiv einsetzt, lohnt sich ein Blick auf das, was darunter tatsächlich passiert. Non-Blocking I/O in PHP basiert auf denselben Betriebssystem-Primitiven wie in jeder anderen Sprache: Sockets, die im Non-Blocking-Modus laufen, und stream_select, das signalisiert, welche Sockets bereit sind. Wer diese Grundlagen versteht, durchschaut jedes Async-Framework auf Anhieb.

17 Min. Lesezeit stream_select · Sockets · epoll PHP 8.4 · ohne Framework

1. Was Non-Blocking I/O eigentlich bedeutet

Non-Blocking I/O bezeichnet ein Modell, bei dem eine Lese- oder Schreiboperation auf einem Socket oder Stream sofort zurückkehrt, unabhängig davon, ob Daten tatsächlich verfügbar sind. Statt dass der aufrufende Prozess wartet, bis Daten ankommen, erhält er umgehend eine Antwort, entweder die verfügbaren Daten oder eine Information, dass gerade nichts vorliegt. Diese eine Design-Entscheidung ist die Grundlage für nahezu jedes Framework, das nebenläufige Verarbeitung in PHP anbietet, von ReactPHP bis Swoole.

Der Gegensatz dazu ist blockierendes I/O, das Standardverhalten in PHP: Ein Aufruf wie fread() auf einem Socket ohne verfügbare Daten hält den gesamten Prozess an, bis Daten eintreffen oder eine Zeitüberschreitung erfolgt. Bei einem einzelnen Request ist das unproblematisch, bei einem Prozess, der gleichzeitig hunderte Verbindungen bedienen soll, wird blockierendes I/O jedoch zum limitierenden Faktor, weil jede wartende Verbindung den gesamten Prozess anhält.

Non-Blocking I/O selbst löst das Problem nicht vollständig, sondern liefert nur den Baustein: Sockets, die nie warten. Erst in Kombination mit einem Mechanismus, der überwacht, welche von vielen Sockets gerade bereit sind, entsteht ein funktionierendes Event-Loop-Modell. Dieser Artikel baut genau diesen Mechanismus von Grund auf mit reinem PHP nach, ohne Swoole oder ReactPHP, um das Prinzip vollständig zu durchschauen.

2. Das klassische Blocking-Modell in PHP

In der Standardkonfiguration ist jeder in PHP geöffnete Stream oder Socket blockierend. Ein Aufruf von fsockopen() gefolgt von fread() hält den Prozess an, bis der entfernte Server antwortet oder die Verbindung eine Zeitüberschreitung erreicht. Für einen einzelnen sequenziellen Request nach dem anderen ist das genau das erwartete Verhalten, und in den allermeisten PHP-Anwendungen ist das auch völlig ausreichend.

Problematisch wird blockierendes I/O erst, sobald mehrere unabhängige Verbindungen gleichzeitig innerhalb desselben Prozesses bedient werden sollen, etwa in einem selbstgeschriebenen TCP-Server oder einem Client, der zeitgleich mit zehn verschiedenen APIs kommunizieren soll. Ohne Non-Blocking I/O müsste man dafür entweder für jede Verbindung einen eigenen Prozess oder Thread starten, was Ressourcen kostet, oder eben die Blocking-Aufrufe durch non-blocking Alternativen ersetzen.

3. Streams mit stream_set_blocking non-blocking machen

Die Funktion stream_set_blocking($stream, false) ist der zentrale Schalter, um einen bestehenden PHP-Stream in den Non-Blocking-Modus zu versetzen. Nach diesem Aufruf kehrt fread() sofort zurück, auch wenn keine Daten verfügbar sind, in diesem Fall als leerer String. Das bedeutet: Ein leerer Rückgabewert von fread() ist bei Non-Blocking I/O kein Fehler und auch nicht zwangsläufig das Ende der Verbindung, sondern schlicht die Aussage "gerade keine Daten da".

Diese Doppeldeutigkeit ist ein häufiger Anfängerfehler: Ein leerer String bei fread() im Non-Blocking-Modus kann sowohl "aktuell keine Daten" als auch "Verbindung geschlossen" bedeuten. Die Unterscheidung erfolgt über feof($stream), das explizit prüft, ob das Streamende erreicht ist. Wer diese Prüfung vergisst, baut Schleifen, die eine geschlossene Verbindung fälschlich als "wartet noch auf Daten" interpretieren und endlos weiterlaufen.


<?php

declare(strict_types=1);

$stream = stream_socket_client('tcp://api.internal:8080', $errno, $errstr, 5);
if ($stream === false) {
    throw new \RuntimeException("Connection failed: {$errstr} ({$errno})");
}

// Switch the stream to non-blocking mode — fread() returns immediately.
stream_set_blocking($stream, false);

fwrite($stream, "GET /status HTTP/1.1\r\nHost: api.internal\r\n\r\n");

$buffer = '';
while (!feof($stream)) {
    $chunk = fread($stream, 8192);

    if ($chunk === '' || $chunk === false) {
        // No data available right now — this is normal, not an error.
        usleep(10000); // avoid a pure busy-loop while polling
        continue;
    }

    $buffer .= $chunk;
}

echo "Received " . strlen($buffer) . " bytes" . PHP_EOL;

4. stream_select: mehrere Sockets gleichzeitig überwachen

Das obige Beispiel funktioniert für einen einzelnen Stream, skaliert aber nicht: Eine Schleife mit usleep() für jeden von zehn Sockets würde entweder unnötig CPU verbrauchen oder unnötig Latenz einführen. Die Lösung ist stream_select(), das PHP-Äquivalent zum POSIX-Systemaufruf select(). Diese Funktion nimmt Arrays von Streams entgegen, die auf Lesbarkeit, Schreibbarkeit oder Fehler überwacht werden sollen, und blockiert effizient, bis mindestens einer davon bereit ist, oder bis ein Timeout erreicht wird.

Der entscheidende Vorteil gegenüber Polling mit usleep(): stream_select() delegiert das Warten an das Betriebssystem, das intern effizient über Interrupts benachrichtigt wird, statt aktiv in einer Schleife nachzufragen. Für echtes Non-Blocking I/O mit vielen gleichzeitigen Verbindungen ist stream_select() deshalb der zentrale Baustein, um CPU-Last gering zu halten, während gleichzeitig auf beliebig viele Sockets reagiert werden kann.


<?php

declare(strict_types=1);

// Multiple non-blocking connections monitored in a single loop.
$sockets = [
    'a' => stream_socket_client('tcp://service-a.internal:9001', $e1, $s1, 5),
    'b' => stream_socket_client('tcp://service-b.internal:9002', $e2, $s2, 5),
    'c' => stream_socket_client('tcp://service-c.internal:9003', $e3, $s3, 5),
];

foreach ($sockets as $socket) {
    stream_set_blocking($socket, false);
}

$results = [];

while (count($results) < count($sockets)) {
    $read = array_diff_key($sockets, $results);
    $write = null;
    $except = null;

    // Blocks efficiently until at least one stream is readable, or 2s pass.
    $ready = stream_select($read, $write, $except, 2);

    if ($ready === false) {
        throw new \RuntimeException('stream_select failed');
    }

    if ($ready === 0) {
        continue; // timeout, loop again — no busy waiting happened
    }

    foreach ($read as $key => $socket) {
        $chunk = fread($socket, 8192);
        if ($chunk !== '' && $chunk !== false) {
            $results[$key] = $chunk;
        }
    }
}

echo sprintf('Collected responses from %d services' . PHP_EOL, count($results));

5. Einen minimalen Non-Blocking TCP-Server bauen

Dieselben Bausteine, die einen Client parallel mit mehreren Servern kommunizieren lassen, lassen sich spiegelbildlich für einen Server nutzen, der gleichzeitig mehrere Clients bedient, ohne für jeden Client einen eigenen Prozess zu starten. Der Server öffnet einen lauschenden Socket mit stream_socket_server(), versetzt ihn ebenfalls in den Non-Blocking-Modus, und behandelt neue Verbindungen und bestehende Client-Sockets in derselben stream_select()-Schleife.

Dieses Muster ist konzeptionell exakt das, was ReactPHP und ähnliche Bibliotheken unter der Haube tun, nur ohne die Abstraktionsschichten aus Promises und Event-Emittern. Wer diesen minimalen Server einmal selbst gebaut hat, versteht sofort, warum ReactPHP eine Loop-Klasse anbietet, warum Streams über Event-Namen wie data und close kommunizieren, und warum Non-Blocking I/O in PHP grundsätzlich single-threaded bleibt, auch bei hunderten gleichzeitigen Verbindungen.


<?php

declare(strict_types=1);

$server = stream_socket_server('tcp://0.0.0.0:9000', $errno, $errstr);
if ($server === false) {
    throw new \RuntimeException("Server bind failed: {$errstr}");
}
stream_set_blocking($server, false);

$clients = [];

while (true) {
    $read = $clients;
    $read[] = $server;
    $write = null;
    $except = null;

    if (stream_select($read, $write, $except, 1) === false) {
        break;
    }

    foreach ($read as $socket) {
        if ($socket === $server) {
            // New incoming connection — accept without blocking.
            $client = stream_socket_accept($server, 0);
            if ($client !== false) {
                stream_set_blocking($client, false);
                $clients[(int) $client] = $client;
            }
            continue;
        }

        $data = fread($socket, 4096);
        if ($data === '' || $data === false) {
            // Client closed the connection — clean up.
            fclose($socket);
            unset($clients[(int) $socket]);
            continue;
        }

        fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK");
    }
}

6. Was unter der Haube passiert: select, poll und epoll

stream_select() ruft intern je nach Plattform unterschiedliche Betriebssystem-Mechanismen auf, um Non-Blocking I/O effizient umzusetzen. Unter Linux nutzt PHP historisch den POSIX-select()-Syscall, der ein Bitfeld aller überwachten Datei-Deskriptoren an den Kernel übergibt. Das Problem: select() skaliert schlecht, weil der Kernel bei jedem Aufruf das komplette Bitfeld erneut durchsuchen muss, ein linearer Aufwand mit der Anzahl der Deskriptoren.

Moderne Event-Loop-Implementierungen wie in Swoole oder in ReactPHPs ext-event-Erweiterung nutzen stattdessen epoll unter Linux, das dieses Skalierungsproblem löst, indem der Kernel selbst eine Liste bereiter Deskriptoren führt und bei jedem Aufruf nur diese zurückgibt, statt alle zu prüfen. Für Non-Blocking I/O mit wenigen Dutzend Verbindungen macht der Unterschied kaum spürbaren Effekt, bei zehntausenden gleichzeitigen Verbindungen wird select() jedoch zum echten Flaschenhals, weshalb produktive Async-Server in PHP fast immer epoll-basierte Implementierungen einsetzen.

7. Partial Reads und Writes korrekt behandeln

Ein zentraler Aspekt von Non-Blocking I/O, der in den meisten Einführungen unterschlagen wird: Sowohl fread() als auch fwrite() können weniger Bytes verarbeiten, als angefragt, ohne dass das ein Fehler ist. Ein fwrite($stream, $data) mit einem großen Payload kann nur einen Teil davon tatsächlich in den Socket-Puffer schreiben, während der Rest verworfen wird, wenn der Rückgabewert nicht ausgewertet wird. Für zuverlässiges Non-Blocking I/O muss jeder Schreibvorgang in einer Schleife wiederholt werden, bis alle Daten tatsächlich geschrieben wurden.

Dasselbe gilt spiegelbildlich für fread() bei größeren Nachrichten: Eine 10-Kilobyte-Antwort kommt selten in einem einzigen Aufruf an, sondern verteilt sich über mehrere Lesevorgänge, die im Anwendungscode zusammengesetzt werden müssen. Wer diese Fragmentierung ignoriert und annimmt, ein einzelner fread()-Aufruf liefere immer die vollständige Nachricht, produziert Bugs, die unter Last und bei größeren Payloads sporadisch auftreten und schwer zu reproduzieren sind.


<?php

declare(strict_types=1);

// Reliable non-blocking write: retries until the full payload is sent.
function writeAll($stream, string $data): void
{
    $total = strlen($data);
    $written = 0;

    while ($written < $total) {
        $bytes = fwrite($stream, substr($data, $written));

        if ($bytes === false) {
            throw new \RuntimeException('Write failed');
        }

        if ($bytes === 0) {
            // Socket buffer full — wait briefly, then retry.
            $write = [$stream];
            $read = null;
            $except = null;
            stream_select($read, $write, $except, 0, 50000);
            continue;
        }

        $written += $bytes;
    }
}

8. Fallstricke: Busy-Waiting und vergessene Timeouts

Der häufigste Fehler beim Einstieg in Non-Blocking I/O ist eine Schleife ohne jede Wartefunktion, die kontinuierlich fread() auf leeren Sockets aufruft. Diese Busy-Waiting-Variante belastet eine CPU-Kernel dauerhaft mit hundert Prozent Auslastung, selbst wenn keine Daten fließen, ein Muster, das in Produktionsumgebungen schnell zu Alarmmeldungen und unnötigen Kosten führt. stream_select() mit einem sinnvollen Timeout ist die korrekte Alternative, weil der Prozess währenddessen tatsächlich schläft, statt aktiv zu prüfen.

Ein zweiter Fallstrick ist das Ignorieren von Verbindungsabbrüchen. Ein entfernter Server, der die Verbindung schließt, ohne ein Signal zu senden, das PHP eindeutig interpretieren kann, führt zu Sockets, die dauerhaft in der stream_select()-Überwachungsliste verbleiben, obwohl keine Kommunikation mehr möglich ist. Regelmäßige Health-Checks und explizite Timeouts pro Verbindung, nicht nur für stream_select() selbst, verhindern, dass tote Verbindungen unbemerkt Ressourcen binden.

9. Non-Blocking I/O im Vergleich zu höheren Abstraktionen

Rohes Non-Blocking I/O mit stream_select() ist lehrreich, aber selten die richtige Wahl für produktive Anwendungen, weil höhere Abstraktionen dieselbe Grundlage bereits robust implementiert haben. Der folgende Vergleich zeigt, wann welche Ebene angemessen ist.

Ebene Abstraktion Wann sinnvoll
stream_select() pur Keine, manuelle Verwaltung Lernen, sehr spezielle Protokolle
ReactPHP Event Loop Promises, Event-Emitter Async-Clients, kleine Server
Swoole Coroutines Automatisches Hooking, Channels Hochlast-Server, viele Verbindungen
Fibers (nativ) Scheduler selbst geschrieben Eigene Bibliotheken, Frameworks

Der praktische Wert, rohes Non-Blocking I/O einmal selbst zu implementieren, liegt nicht darin, es produktiv einzusetzen, sondern darin, jedes höhere Framework auf Anhieb zu verstehen. Wer weiß, dass ReactPHPs Event Loop letztlich nur stream_select() oder epoll kapselt, debuggt Async-Probleme in ReactPHP-Anwendungen deutlich schneller, weil er die zugrunde liegende Semantik kennt, statt die Abstraktion als Blackbox zu behandeln.

Mironsoft

Netzwerkprogrammierung und Performance-Tiefe in PHP

Verbindungsprobleme in einer selbstgebauten PHP-Netzwerkanwendung?

Wir analysieren bestehende Socket- und Streaming-Implementierungen, finden Busy-Waiting-Muster und fehlende Partial-Read-Behandlung, und beraten bei der Wahl zwischen rohem Non-Blocking I/O und höheren Frameworks.

Code-Review

Analyse bestehender Socket-Schleifen auf typische Non-Blocking-Fehler

Performance-Diagnose

CPU-Last durch Busy-Waiting identifizieren und beheben

Framework-Beratung

Passende Abstraktionsebene zwischen stream_select, ReactPHP und Swoole finden

10. Zusammenfassung

Non-Blocking I/O in PHP basiert auf zwei einfachen Bausteinen: Sockets, die mit stream_set_blocking() nie warten, und stream_select(), das effizient signalisiert, welche von mehreren Sockets gerade bereit sind. Diese beiden Funktionen genügen, um einen minimalen Server oder Client zu bauen, der gleichzeitig mehrere Verbindungen bedient, ohne für jede einen eigenen Prozess zu starten. Unter der Haube nutzen moderne Implementierungen statt des simplen select()-Syscalls effizientere Mechanismen wie epoll, um auch bei zehntausenden Verbindungen linear zu skalieren.

Für den produktiven Einsatz sind höhere Abstraktionen wie ReactPHP oder Swoole fast immer die bessere Wahl, weil sie Partial Reads, Fehlerbehandlung und Timeout-Logik bereits robust gelöst haben. Der Wert, Non-Blocking I/O einmal selbst mit reinem PHP zu implementieren, liegt darin, jedes dieser Frameworks von Grund auf zu verstehen, statt sie als undurchsichtige Blackbox zu behandeln, wenn im Produktivbetrieb ein Problem auftritt.

Non-Blocking I/O in PHP — Das Wichtigste auf einen Blick

stream_set_blocking

Versetzt einen Stream in den Non-Blocking-Modus, Lesevorgänge kehren sofort zurück, auch ohne Daten.

stream_select

Überwacht mehrere Sockets effizient über das Betriebssystem, statt aktiv per Busy-Waiting zu pollen.

Partial Reads/Writes

fread() und fwrite() können weniger Bytes liefern als erwartet, Schleifen mit Rückgabewert-Prüfung sind Pflicht.

select vs. epoll

Moderne Frameworks nutzen epoll statt select, um bei sehr vielen gleichzeitigen Verbindungen linear zu skalieren.

11. FAQ: Non-Blocking I/O in PHP

1Blocking vs. Non-Blocking I/O?
Blocking hält den Prozess an, bis Daten da sind. Non-Blocking gibt sofort zurück, auch ohne Daten, per leerem Rückgabewert.
2Warum leerer String bei fread()?
Meist nur "keine Daten gerade verfügbar", kein Fehler. Ob die Verbindung geschlossen ist, muss zusätzlich mit feof() geprüft werden.
3Wofür stream_select()?
Delegiert das Warten ans Betriebssystem und vermeidet Busy-Waiting, das die CPU unnötig belastet.
4select vs. epoll?
select prüft jeden Deskriptor linear, epoll führt eine Liste bereiter Deskriptoren im Kernel und skaliert deutlich besser.
5Was ist ein Partial Write?
fwrite() kann weniger Bytes schreiben als übergeben, ohne Fehler. Ignorierter Rückgabewert führt zu stillem Datenverlust.
6Brauche ich die sockets-Erweiterung?
Nein, stream_set_blocking() und stream_select() funktionieren mit regulären PHP-Streams ohne zusätzliche Erweiterung.
7Roh in Produktion einsetzen?
Meist nicht. Frameworks wie ReactPHP oder Swoole lösen Partial Reads und Fehlerbehandlung bereits robust.
8Wie erkenne ich Busy-Waiting?
Eine Schleife ohne Wartefunktion mit dauerhaft hoher CPU-Last. stream_select() mit Timeout ist die korrekte Alternative.
9Unterschied zu Swoole Coroutines?
Non-Blocking I/O ist die zugrunde liegende Technik, Swoole Coroutines automatisieren Hooking und Verwaltung darüber.
10Timeouts zusätzlich zu stream_select?
Ja, tote Verbindungen ohne Aktivität müssen zusätzlich über eigene Zeitstempel und Health-Checks erkannt werden.