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.
Inhaltsverzeichnis
- 1. Was Non-Blocking I/O eigentlich bedeutet
- 2. Das klassische Blocking-Modell in PHP
- 3. Streams mit stream_set_blocking non-blocking machen
- 4. stream_select: mehrere Sockets gleichzeitig überwachen
- 5. Einen minimalen Non-Blocking TCP-Server bauen
- 6. Was unter der Haube passiert: select, poll und epoll
- 7. Partial Reads und Writes korrekt behandeln
- 8. Fallstricke: Busy-Waiting und vergessene Timeouts
- 9. Non-Blocking I/O im Vergleich zu höheren Abstraktionen
- 10. Zusammenfassung
- 11. FAQ
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.