PHP Fibers: Asynchrones PHP ohne Framework
Ein Aggregations-Endpunkt, der sechs interne Dienste nacheinander aufruft, verbringt den Großteil seiner Lebenszeit damit, nichts zu tun. Sechs Aufrufe mit durchschnittlich 400 ms summieren sich auf etwa 2,4 Sekunden Wall-Time, und fast die gesamte Zeit sitzt ein PHP-Prozess untätig auf einem Socket. PHP Fibers, eingeführt in 8.1, erlauben es dir, diese Leerlaufzeit zurückzugewinnen, ohne ReactPHP oder Swoole einzuführen oder deine Anwendung um eine Event-Loop herum umzuschreiben.
Das ist das Versprechen. Die Realität hat mehr Ecken und Kanten, und die Version dieses Themas, die auf Konferenzen kursiert, überspringt sie meist. Im Folgenden geht es darum, was Fibers tatsächlich bieten, was sie dir zum Selberbauen überlassen und den einen Symfony-Anwendungsfall, bei dem sich der zusätzliche Aufwand aus unserer Sicht lohnt.
Was PHP Fibers wirklich sind
Ein Fiber ist eine Funktion, deren Ausführung von sich selbst pausiert und von demjenigen, der sie gestartet hat, fortgesetzt werden kann. Sie trägt ihren eigenen Call-Stack, sodass die Pause fünf Ebenen tief in verschachtelten Aufrufen passieren kann, und wenn du sie fortsetzt, läuft die Ausführung genau an dieser Stelle weiter, mit intaktem Stack.
Sie sind keine Threads. Nur ein Fiber läuft gleichzeitig, im selben Prozess, auf demselben Kern. Fibers geben dir keinerlei parallele CPU-Arbeit. Sie sind auch keine Prozesse: gleicher Speicher, gleicher Request-Lebenszyklus, gleiche Verbindungen.
Der Vergleich, der zählt, ist der mit Generatoren. PHP hat kooperatives Multitasking über yield seit 5.5, und Leute haben Scheduler darauf aufgebaut. Die Einschränkung war immer, dass nur der Funktionskörper des Generators yielden kann. Wenn dein HTTP-Client drei Aufrufe tief im Stack liegt, musstest du jede Zwischenfunktion zu einem Generator machen und die Yields manuell weiterreichen. Ein Fiber hängt den gesamten Stack darunter auf. Deine Repository-Methode kann eine Client-Methode aufrufen, die sich aufhängt, und weder das Repository noch der Aufrufer müssen davon wissen.
Genau dieser Unterschied ist der Grund, warum Fibers existieren. Alles andere ist Buchhaltung.
Die gesamte API
Es gibt nicht viel zu lernen.
$fiber = new Fiber(function (string $url): string {
$handle = openConnection($url);
$payload = Fiber::suspend($handle);
return process($payload);
});
$handle = $fiber->start('https://api.example.com/orders');
// Der Socket ist offen. Die Kontrolle ist zurück hier. Mach etwas anderes.
$fiber->resume($rawPayload);
$result = $fiber->getReturn();
start() führt die Funktion bis zum ersten Fiber::suspend() aus und gibt zurück, was dieses suspend übergeben hat. resume() schickt einen Wert zurück hinein, und dieser Wert wird zum Rückgabewert von Fiber::suspend() innerhalb des Fibers. getReturn() liefert den Rückgabewert der Funktion, sobald isTerminated() wahr ist.
Der Rest der Oberfläche besteht aus isStarted(), isSuspended(), isRunning(), isTerminated(), throw() zum Einschleusen einer Exception am Suspension-Punkt und dem statischen Fiber::getCurrent(), das den laufenden Fiber oder null zurückgibt. Das ist das gesamte Feature.
Der Teil, den die Tutorials auslassen
Fibers machen blockierende Aufrufe nicht nicht-blockierend. Setze file_get_contents() in einen Fiber, und der gesamte PHP-Prozess stoppt, bis die Antwort eintrifft, genau wie vorher. Suspension ist freiwillig. Etwas muss bemerken, dass ein Socket noch nicht bereit ist, und sich entscheiden zu suspendieren, was bedeutet, dass du einen Transport brauchst, der Bereitschaft melden kann, und einen Scheduler, der entscheidet, wer als Nächstes läuft. PHP gibt dir keins von beidem.
Es gibt außerdem eine harte Grenze, die man kennen sollte, bevor man darum herum designt: Man kann nicht über den Callback einer internen Funktion hinweg suspendieren. Ruft man Fiber::suspend() aus einer Closure heraus auf, die an array_map(), usort() oder preg_replace_callback() übergeben wurde, wirft PHP einen FiberError, weil der C-Stack-Frame der internen Funktion zwischen dem Fiber und seinem Suspension-Punkt liegt. In der Praxis bedeutet das: Awaiten innerhalb eines Mapping-Callbacks funktioniert nicht, und man baut stattdessen auf eine foreach-Schleife um.
Ein Scheduler, kurz genug zum Lesen
Der minimal funktionsfähige Scheduler ist Round-Robin über eine Queue.
final class Scheduler
{
/** @var list<Fiber> */
private array $queue = [];
public function spawn(callable $task): void
{
$this->queue[] = new Fiber($task);
}
public function run(): void
{
while ($this->queue !== []) {
$fiber = array_shift($this->queue);
if (!$fiber->isStarted()) {
$fiber->start();
} elseif ($fiber->isSuspended()) {
$fiber->resume();
}
if (!$fiber->isTerminated()) {
$this->queue[] = $fiber;
}
}
}
}
Dreißig Zeilen, und man kann sie komplett im Kopf behalten. Das ist ein echter Vorteil gegenüber einer Event-Loop, die man nicht selbst geschrieben hat. Der kooperative Teil ist auch der gefährliche Teil: Eine Task, die niemals suspendiert, hungert jede andere Task in der Queue aus, und es gibt kein Preemption, das einen rettet. Eine Busy-Loop in einem Fiber hängt den Request auf.
Gleichzeitige HTTP-Aufrufe in Symfony ohne Swoole
Hier verdienen sich Fibers ihren Platz in einer normalen Symfony-Anwendung. Symfonys HttpClient ist darunter bereits asynchron. $client->request() gibt sofort ein lazy ResponseInterface zurück; der Request wird erst abgeschlossen, wenn man getContent() oder toArray() aufruft, und $client->stream() multiplext viele Antworten über ein einziges curl-Multi-Handle.
Man kann also bereits mit einer einfachen stream()-Schleife Nebenläufigkeit bekommen. Das Problem ist, wie der Code danach aussieht. Jede Antwort trifft als Chunk-Event ein, und jede Pro-Request-Logik über „Body sammeln" hinaus wird zu einer Switch-Anweisung über Response-Identität, was einen Zustandsautomaten ist, den man jetzt von Hand pflegt.
Fibers erlauben es jeder Task, ihre lineare Form zu behalten, während der Scheduler den gemultiplexten Transport steuert.
final class HttpScheduler
{
/** @var array<int, Fiber> */
private array $fibers = [];
/** @var array<int, ResponseInterface> */
private array $waiting = [];
public function __construct(private readonly HttpClientInterface $client)
{
}
public function spawn(callable $task): void
{
$fiber = new Fiber($task);
$this->fibers[spl_object_id($fiber)] = $fiber;
}
public function await(ResponseInterface $response): ResponseInterface
{
$fiber = Fiber::getCurrent()
?? throw new LogicException('await() must run inside a fiber');
$this->waiting[spl_object_id($fiber)] = $response;
Fiber::suspend();
return $response;
}
public function run(): void
{
foreach ($this->fibers as $fiber) {
$fiber->start();
}
while ($this->waiting !== []) {
$completed = [];
foreach ($this->client->stream($this->waiting, 0.05) as $response => $chunk) {
if ($chunk->isTimeout()) {
break;
}
if ($chunk->isLast()) {
$completed[] = array_search($response, $this->waiting, true);
}
}
foreach ($completed as $id) {
unset($this->waiting[$id]);
$this->fibers[$id]->resume();
}
}
}
}
Das Resume passiert nach Abschluss der stream()-Iteration statt innerhalb davon, weil ein fortgesetzter Fiber erneut await() aufrufen und $waiting verändern könnte, während man gerade darüber iteriert. Diese Reihenfolge ist die eine Feinheit in dieser Klasse.
Der aufrufende Code liest sich von oben nach unten:
$scheduler = new HttpScheduler($client);
$orders = $inventory = [];
$scheduler->spawn(function () use ($scheduler, $client, &$orders): void {
$response = $scheduler->await($client->request('GET', '/api/orders'));
$orders = $response->toArray();
});
$scheduler->spawn(function () use ($scheduler, $client, &$inventory): void {
$response = $scheduler->await($client->request('GET', '/api/inventory'));
$inventory = $response->toArray();
});
$scheduler->run();
Zwei Requests gehen gemeinsam raus. Jede Task behält ihre eigene sequenzielle Logik, einschließlich jedes Folgeaufrufs, den sie basierend auf der ersten Antwort machen muss.
Was es kostet und was es bringt
Für einen Aggregations-Endpunkt, der sechs Dienste anspricht, ist die Rechnung einfach:
| Ansatz | Wall-Time, sechs Aufrufe bei ~400 ms | Form des Codes |
|---|---|---|
Sequenzielles toArray() | ~2,4 s | Linear, offensichtlich |
Rohe stream()-Schleife | ~0,5 s | Handgeschriebener Zustandsautomat |
Fibers über stream() | ~0,5 s | Linear pro Task |
Diese Tabelle sollte man aufmerksam lesen, denn sie enthält das ehrliche Fazit. Der Latenzgewinn kommt vom curl-Multiplexing, nicht von Fibers. Eine stream()-Schleife liefert die gleiche Wall-Time ohne neue Abstraktionen. Was Fibers bringen, ist die zweite Spalte: lesbare Pro-Task-Logik, wenn eine Task mehr macht, als nur eine URL abzurufen und zu stoppen.
Zwei Kosten gleichen das aus. CPU-Arbeit bleibt seriell, sodass das Dekodieren von sechs großen JSON-Payloads weiterhin nacheinander passiert und im Profiler auftaucht. Und Stack-Traces werden schlechter, weil eine innerhalb eines Fibers geworfene Exception bis zur Fiber-Grenze abwickelt und ein hängender Fiber gar keine Exception erzeugt, nur einen Endpunkt, der nie zurückkehrt. Plane dafür Zeit ein, wenn in der Produktion zum ersten Mal etwas schiefgeht.
Wann man das komplett überspringen sollte
Wenn dein Endpunkt nur einen externen Aufruf macht, gibt es nichts zu verschachteln und keinen Grund, einen Scheduler hinzuzufügen. Wenn der langsame Teil eine Datenbankabfrage statt eines HTTP-Roundtrips ist, helfen Fibers nicht, da PDO und Doctrine den Prozess blockieren, egal was drumherum liegt.
Wenn du bereits AMPHP verwendest: Version 3 basiert auf Fibers mit der Revolt-Event-Loop darunter. Nutze das statt einen Scheduler von Hand zu bauen, und greife auf den Code oben nur zurück, wenn du verstehen willst, was die Library macht, oder wenn eine zusätzliche Abhängigkeit keine Option ist.
Ein paar Regeln halten das langweilig, sobald es in einer Produktions-Codebasis läuft. Besitze den Scheduler in genau einer Klasse am Rand des Requests, statt Fiber::suspend() über deine Services zu verstreuen. Suspendiere niemals innerhalb einer Doctrine-Transaktion, da ein anderer Fiber laufen und dieselbe Verbindung nutzen könnte, während die Transaktion offen ist. Setze ein Timeout auf jede awaited Operation, denn eine Task, die für immer wartet, ist der Fehlermodus, den man tatsächlich erlebt. Und begrenze, wie viele Tasks man pro Request spawnt, da acht gleichzeitige ausgehende Aufrufe pro Nutzeranfrage einen nachgelagerten Dienst schneller sättigen können, als sequenzieller Code es je könnte. Dieselbe Überlegung, die wir bei Symfony HttpClient in Produktion anwenden, gilt hier mit noch mehr Nachdruck.
Noch eine Warnung für alle mit einer langlebigen Laufzeitumgebung. Unter FrankenPHP Worker-Modus oder Symfony Runtime verschwindet ein Fiber, der beim Ende des Requests suspendiert ist, nicht einfach. Er behält seinen Stack, seine Closures und alles, was sie erfasst haben. Beende deine Scheduler explizit am Ende jedes Requests, sonst debuggst du irgendwann ein Memory-Leak, das erst nach ein paar Tausend Requests auftaucht.
Häufige Fragen
Sind Fibers schneller als Generatoren für Coroutinen?
Nicht messbar im Durchsatz. Der Unterschied ist strukturell: Ein Fiber hängt den gesamten Stack darunter auf, sodass Zwischenfunktionen keine Änderungen brauchen, während ein Generator-basierter Scheduler jede Funktion in der Kette zwingt, ein Generator zu werden und Yields manuell weiterzureichen.
Kann ich Fibers mit PHP-FPM verwenden?
Ja. Fibers sind ein Sprachfeature und brauchen keine spezielle SAPI. Sie leben und sterben innerhalb eines einzigen Requests, was genau das Modell ist, das PHP-FPM bietet. Bei Runtimes im Worker-Modus braucht das Lebenszyklus-Management Aufmerksamkeit.
Ersetzen Fibers Symfony Messenger?
Nein. Messenger verlagert Arbeit aus dem Request in einen separaten Prozess. Fibers verschachteln Arbeit innerhalb eines Requests. Lagere alles aus, worauf der Nutzer nicht warten muss; nutze Fibers für Aufrufe, deren Ergebnisse in derselben Antwort zurückkommen müssen.
Wo das reinpasst
Fibers lohnen sich, wenn du einen bestimmten Endpunkt hast, dessen Latenz von mehreren unabhängigen I/O-Aufrufen dominiert wird und dessen Pro-Aufruf-Logik zu verzweigt für eine flache stream()-Schleife ist. Das ist ein enges Ziel, und außerhalb davon ist die sequenzielle Version die bessere technische Entscheidung.
Wenn du auf einen Aggregations-Endpunkt schaust und nicht sagen kannst, ob die Latenz I/O-Wartezeit, serielle CPU-Arbeit oder eine N+1-Abfrage ist, die sich hinter einem ORM-Aufruf versteckt, ist Messen zuerst günstiger als Umbauen. Diese Art von Profiling ist Teil unserer Code-Quality-Consulting-Arbeit, und Nebenläufigkeitsdesign taucht regelmäßig in Custom-Software-Development-Projekten auf, bei denen eine API aus Diensten aggregieren muss, die niemand kontrolliert.
Gern schauen wir uns mit dir einen konkreten Endpunkt an. Schick die Details an hello@wolf-tech.io, oder lies mehr darüber, wie wir arbeiten, auf wolf-tech.io.

