đȘ Die Fiber-Illusion: Was uns RxJS ĂŒber echte asynchrone Pipelines in PHP gelehrt hat
vom 18. Juli 2026
Die meisten Entwickler lernen RxJS durch die flĂŒssige Syntax kennen:
source.pipe(map(...), mergeMap(...), retry(...));
Unvergesslich â und der uninteressanteste Teil beim Entwurf einer PHP-Pipeline-Engine.
Wir nutzten RxJS als architektonisches Spiegelbild fĂŒr Darkwood Flow. Teams, die Scraper, Importer und Medienpipelines entwickeln, fragen sich immer wieder, ob sie âreaktiveâ APIs benötigen. Unsere Frage war prĂ€ziser:
Was kann Flow von RxJS lernen, ohne selbst zu RxJS zu werden?
RxJS beschreibt Werte im Zeitverlauf anhand eines expliziten Lebenszyklus: Zusammensetzen mit pipe, Starten bei subscribe, Verfolgen aktiver VorgĂ€nge, Warten auf deren Abschluss, Weiterleiten von Fehlern, Beenden bei unsubscribe. Scheduler sind fĂŒr Timer relevant; sie ermöglichen jedoch nicht das gleichzeitige Abrufen von URLs. Der entscheidende Aspekt ist der AusfĂŒhrungslebenszyklus.
Unter der BedienoberflĂ€che sieht die Maschine folgendermaĂen aus:
subscription starts execution
â values propagate through a chain
â asynchronous inner work is tracked
â completion waits for active work
â errors terminate or restart the run
â unsubscription triggers teardown
Flow steht bereits vor denselben Fragen, wenn es ĂŒber die einfache BĂŒhnenkomposition hinausgeht:
- Wann beginnt eine Pipeline?
- Wann ist es fertig?
- Was ist noch aktiv? Was passiert im Fehlerfall? Wer sperrt Ressourcen?
- Wie können wir einen Lauf stoppen?
In Flow wird jeder Wert in einem Befehlspaket namens Ip ĂŒbertragen. Pakete werden von Strategien empfangen, von einem Treiber eingeplant und nach await() gesammelt. Wir unterstĂŒtzten bereits Fibers, Amp, React, Swoole und Ă€hnliche Technologien â und haben bereits viele Pakete durch den Prozess fetch â hash â save geleitet. Was uns fehlte, war die Transparenz bezĂŒglich nativer, nicht-blockierender E/A: ĂŒberlappende Wartezeiten an echten Sockets, ohne Amp oder React als feste AbhĂ€ngigkeit zu benötigen und ohne so zu tun, als ob ein Fiber um einen blockierenden Lesevorgang herum ausreichen wĂŒrde.
JavaScript stattet RxJS mit einer Ereignisschleife aus. Die PHP-Befehlszeile (CLI) hingegen nicht. Ein Fiber kann die kooperative PHP-AusfĂŒhrung unterbrechen; er kann jedoch einen blockierenden Socket-Aufruf nicht selbststĂ€ndig in ĂŒberlappende E/A umwandeln. Flow passt sich mit expliziten Primitiven an:
PHP streams â non-blocking mode â stream_select() â Fibers â Driver â Flow
Fazit: Ăbernehmen Sie die Lifecycle-Struktur von RxJS. Behalten Sie die Terminologie von Flow bei (Jobs, Pakete, Treiber). Diese Studie war nie ein Exkurs in RxPHP â der interessante Kern sind die Prinzipien, nicht die Typen.
Um unsere IntegritĂ€t zu wahren, haben wir eine realitĂ€tsnahe Pipeline betrieben â nicht nur kĂŒnstliche Zeitschaltuhren:
URLs â fetch concurrently â checksum â save â per-URL result â summary
Reale Sockets legen blockierendes Verhalten offen. ParallelitĂ€tsbeschrĂ€nkungen werden entweder eingehalten oder nicht. Timeouts und AufrĂ€umarbeiten werden sichtbar. Eine fehlgeschlagene URL darf nicht fĂŒnf erfolgreiche Verbindungen zunichtemachen, wenn dies die Produktregel ist. KĂŒnstliche delay()-Demos beweisen zwar die Fiber-Planung, aber nicht, dass offene Sockets eine stream_select-Wartezeit teilen.
These: Flow sollte sich die Disziplin von RxJS in Bezug auf AusfĂŒhrung, ParallelitĂ€t, Abschluss und Abbau zunutze machen â und gleichzeitig eine PHP-native Engine bleiben, die auf Jobs, Paketen, Strategien und Treibern basiert.
Anders formuliert: Wir dachten, ein Fiber-basierter Treiber wĂŒrde Flow bereits asynchrone AusfĂŒhrung ermöglichen. Die BeschĂ€ftigung mit RxJS zwang uns jedoch, den Begriff âasynchronâ neu zu definieren. Der Rest dieses Artikels beschreibt diese Erkenntnis.
Unter den Operatoren: Wie RxJS tatsÀchlich funktioniert
Wir haben RxJS 8.0.0-alpha.14 (Kerntypen in @rxjs/observable) untersucht. Die Paketstruktur ist versionsspezifisch; die Lebenszyklusprinzipien sind jedoch umfassender als in dieser Alpha-Version.
from(urls).pipe(
mergeMap(fetchImage, 4),
map(hashBinary),
retry(2),
finalize(cleanup),
);
Komposition ist nicht AusfĂŒhrung
Observable.pipe wandelt Operatoren in neue Observables um. Es werden noch keine Daten abgerufen. Operatoren in diesem Baum geben typischerweise new Observable(subscriber => { ... }) zurĂŒck; der Ă€ltere lift-Pfad ist ab Version 8 veraltet. Flow trennt bereits Komposition (FlowFactory / fn) von AusfĂŒhrung (await()) â gleiche Aufteilung, andere Namen.
Das Abonnement startet den Lauf
subscribe kapselt den Konsumenten in einen Subscriber, verbindet den Produzenten und registriert den Abbau. Operatoren abonnieren Upstream; next / error / complete schieben Daten in Richtung Senke. Hilfsfunktionen wie operate({ destination, ... }) verknĂŒpfen Kindelemente mit dem Abbaubaum des Ziels, sodass das Abmelden kaskadierend wirkt.
Ein Subscription speichert Finalisierer und fĂŒhrt sie idempotent bei unsubscribe aus. Terminalsignale stoppen die Benachrichtigungen und kĂŒndigen das Abonnement. Die Bereinigung ist Teil des Beendens eines Laufs. Kalte Quellen (einschlieĂlich from(array)) isolieren LĂ€ufe: Jedes Subscription erhĂ€lt seinen eigenen Producer.
mergeMap verfolgt aktive innere
mergeMap(project, concurrent) delegiert an eine gemeinsame Merge-Logik: einen ZĂ€hler fĂŒr aktive innere Prozesse, einen Puffer fĂŒr Ăberlauf und die Fertigstellung erst, wenn der Ă€uĂere Prozess abgeschlossen ist, der Puffer leer ist und keine inneren Prozesse mehr vorhanden sind. Innere Eingaben werden ĂŒber from(project(...)) verarbeitet.
Wir brauchten mergeMap in PHP nicht. Wir brauchten seine Garantie: begrenzte laufende Arbeit und Abschluss, der auf jede aktive Operation wartet.
erneut versuchen und abschlieĂen
Im Fehlerfall meldet retry das aktuelle innere Abonnement ab und meldet sich fĂŒr dieselbe Quelle erneut an. Bei einem kalten from(urls) wird die Liste dadurch neu gestartet â eine leistungsstarke, aber oft ungeeignete Methode fĂŒr Batch-I/O. finalize(callback) protokolliert die Bereinigung des Ă€uĂeren Abonnements (abgeschlossen, Fehler oder Abmeldung), nicht zwischen den Wiederholungsversuchen.
Was man stehlen darf und was nicht
Dies ist der Kern der Studie.
| Stehlen | ZurĂŒcklassen |
|---|---|
| Lazy Compose â expliziter Start | Observable / Observer / Subscriber als App-Typen |
| Abgegrenzte Arbeit wÀhrend des Fluges | Operatorkataloge und JS-Methodennamen |
| Fertigstellung, die auf aktive Bearbeitung wartet | Ăffentliche Terminplaner-Zoos |
| Abbau an jedem Terminalpfad | Themen / Multicast âweil Rx sie hatâ |
Analogien sind hilfreich â und dabei bleibt es auch:
Subscriptionâ ein zukĂŒnftiger Flow-AusfĂŒhrungshandle- Planer â Fahrer
mergeMap(n)â beschrĂ€nkte parallele Phase (MaxIpStrategy+ resultierende Jobs)
Wichtigste Erkenntnis: Kopieren Sie den Lebenszyklusvertrag. Kopieren Sie nicht das Typsystem.
Was Flow bereits hatte
Vor der Studie war Flow kein unbeschriebenes Blatt. Diese Ausgangslage machte die Fiber-Illusion so ĂŒberzeugend.
Wir könnten es bereits:
- Stufen mit
FlowFactory(Generator-Do-Notation) oderfn()zusammensetzen - Werte in
Ip-Paketen verschieben - Jobs ausfĂŒhren (
JobInterfaceoder Closures) - Zulassung mit
MaxIpStrategy($n)begrenzen - Zeitplan ĂŒber Treiber (standardmĂ€Ăig
FiberDriver, auĂerdem Amp, React, Swoole, âŠ) - Barriere bei
await()(voidâErgebnisse benötigen einen Collector-Job) - Fehler werden ĂŒber den optionalen
errorJobweitergeleitet
build pipeline â push Ip(s) â await()
â Driver admits packets, runs jobs, forwards results
â when nothing remains queued or active, await returns
Durch das HinzufĂŒgen eines Pakets wird dieses in die erste Stufe eingereiht. Innerhalb von await() fragt der Treiber die Strategie jeder Stufe ab, welche Pakete ausgefĂŒhrt werden dĂŒrfen, startet Job-Fibers (oder Koroutinen) und leitet erfolgreiche Pakete weiter oder ruft errorJob auf. Abgeschlossene Pakete geben ParallelitĂ€tsplĂ€tze frei.
Mentales Modell: Pakete durchlaufen verschiedene Phasen, Strategien begrenzen die Anzahl der ausgefĂŒhrten Pakete, der Treiber ist fĂŒr das Warten verantwortlich.
Mit FiberDriver, mehreren gleichzeitig ĂŒbertragenen Paketen, delay() und MaxIpStrategy(4) sah es nach asynchroner AusfĂŒhrung aus. Amp-Timer-Demos ĂŒberschnitten sich tatsĂ€chlich. Der blinde Fleck war die native blockierende Stream-E/A â und wir hatten uns in Bezug auf die Bedeutung von âasynchronâ in diesem Zusammenhang geirrt.
Die Faserillusion
Was wir glaubten
Vier URLs pushen â vier zulassen â vier Fibers starten â await(). Wenn Jobs E/A-Operationen durchgefĂŒhrt haben, mĂŒssen sich die Wartezeiten ĂŒberschneiden.
RxJS machte diese Annahme unbequem: Wenn mergeMap aktive innere Knoten verfolgt, was verfolgte dann Flow â Pakete, Fibers oder Sockets?
Was die Geschichte ans Licht brachte
file_get_contents($url); // still blocks the process
Innerhalb eines FiberDriver-Jobs wird dieser Aufruf nie bei Bereitschaft angehalten. Der Treiber wartet auf den Block. MaxIpStrategy(4) kann vier Pakete zulassen, wĂ€hrend der Prozess noch am Socket serialisiert â oder den Scheduler einfrieren, wĂ€hrend nach parallelen Prozessen gesucht wird.
Dieselbe Falle: fread wird blockiert. Fibers helfen nur, wenn der Job pusht. Ohne eine Readiness-API gibt es keinen Grund fĂŒr einen Push.
Ein Fiber kann die PHP-AusfĂŒhrung unterbrechen. Er kann jedoch keinen blockierenden Socket-Aufruf in einen nicht-blockierenden umwandeln.
| Etikett | Bedeutung | Nur Ballaststoffe? |
|---|---|---|
| Gleichzeitiges PHP | Viele Fibers geplant | Ja |
| Ăberlappende E/A | Viele Sockets in einem stream_select |
Nein â benötigt nicht-blockierende Auswahl |
| GefÀlschte asynchrone Aufrufe | Blockierende Aufrufe innerhalb von Fibers | GefÀhrlich einfach |
RxJS erzwang diese Terminologie: ZugriffsbeschrĂ€nkungen sind keine I/O-Ăberlappung. Flow musste diese Ehrlichkeit ĂŒbernehmen.
Der benötigte Stapel
non-blocking streams
â stream_select()
â Fiber suspension on wait
â readiness-based resume
â Driver await loop
â Flow stages
Dieser Stack wurde zum Mittelpunkt der Studie â und deshalb wurde StreamSelectDriver in darkwood/flow aufgenommen.
Fazit: âAsync Driverâ ohne Yield-on-Readiness-Strategie ist nur ein Slogan. Mit stream_select wird es zur festen Regel.
Konkrete Ergebnisse: die Bildverarbeitung
Die Demo befindet sich in content/rxjs-flow und nutzt lokalen Flow ĂŒber einen Composer-Pfad-Symlink. Eine kurzlebige Demo-Engine erprobte stream_select und wurde anschlieĂend absichtlich entfernt. Nur eine wiederverwendbare Laufzeitumgebung.
php bin/console app:fetch-images --concurrency=4 --timeout=5
| BĂŒhne | Rolle |
|---|---|
FetchImageJob |
stream_socket_client, nicht-blockierende E/A, waitWritable / waitReadable, minimales HTTP-Parsing, finally schlieĂt |
HashBinaryJob |
SHA-256 |
SaveFileJob |
Dateikörper speichern |
| Collector | Aggregieren Sie ImageResult (await() gibt void zurĂŒck) |
Die ParallelitĂ€t wird in der Fetch-Phase ĂŒber MaxIpStrategy($concurrency) gesteuert. HTTP 404 ist ein weicher Fehler pro Element bei DTOs â nicht bei errorJob â, sodass fĂŒnf erfolgreiche Abrufe neben einem Fehler auftreten können. Traces geben flow.started, item.started, completed, failed und flow.completed aus.
ReprÀsentativer Lauf (concurrency=2):
[flow.started] items=6 concurrency=2
[item.started] url=http://127.0.0.1:8765/image-a.png
[item.started] url=http://127.0.0.1:8765/image-b.png
[item.started] url=http://127.0.0.1:8765/image-c.png
[item.completed] url=âŠ/image-a.png checksum=⊠path=âŠ
âŠ
[item.failed] url=âŠ/fail.png error=HTTP 404
[flow.completed] ok=5 failed=1 elapsed_ms=711.23
Die Testumgebungen verwenden php -S (Single-Threaded) mit kurzen Routenverzögerungen, sodass die PoolĂŒbergabe sichtbar ist. Clientseitige Wartezeitbegrenzungen sind real; eine tatsĂ€chliche Beschleunigung erfordert einen Server mit mehreren Verbindungen. Der Fetch-Job vermittelt HTTP â Weiterleitungen, TLS-SonderfĂ€lle, Chunked-Besonderheiten, Proxys, Komprimierung und partielle E/A werden nicht behandelt. Produktionsclients gehören weiterhin in Jobs; Treiber bleiben generisch.
NĂ€chster Abnahmetest (erwartet, hier nicht als gemessen beansprucht):
5 Ă ~1s delayed endpoints on a multi-connection backend
concurrency=1 â ~5s
concurrency=4 â ~2s
StreamSelectDriver: Was wir ausgeliefert haben
StreamSelectDriver multiplexiert die native Stream-Bereitschaft auf Fibers:
$ready = $driver->waitReadable($stream, $timeoutSeconds);
$ready = $driver->waitWritable($stream, $timeoutSeconds);
Beide geben einen booleschen Wert zurĂŒck und mĂŒssen innerhalb eines Fiber-Jobs ausgefĂŒhrt werden. Intern gilt fĂŒr den Treiber Folgendes:
- Aufzeichnungen Glasfaser, Stream, Modus, optionale Frist
- Setzt aus
- Erstellt
stream_select-Sets aus ausstehenden Wartezeiten. - Setzt bereite Fibers mit
truefort, zeitĂŒberschreitende Wartezeiten mitfalse. - Setzt weiterhin einfache
delay()-Anhalten beim normalen Schleifentakt fort.
Die gleiche DriverInterface wie bei anderen Treibern (async, defer, await, delay, tick), plus diese Warte-APIs, die Jobs je nach Typ aktivieren.
MaxIpStrategy FIFO-Fix: Bei maximaler KapazitÀt keine erneute Eingabe in die Warteschlange (die rotierte Reihenfolge). Gleichzeitige Traces machten den Fehler deutlich.
Warum nicht FiberDriver erweitern? Die standardmĂ€Ăige Wiederaufnahme von Fiber-Prozessen ist nicht mit einer bereitschaftsgesteuerten Wiederaufnahme identisch. Ein dedizierter Treiber hĂ€lt die Planungsregel explizit.
Driver â schedule, wait, resume, deadlines
Job â protocol + application work
IpStrategy â packet admission / concurrency
Collector â terminal aggregate
$driver = new StreamSelectDriver();
$flow = (new FlowFactory($driver))->create(static function () use (...) {
yield [$fetchJob, null, new MaxIpStrategy($concurrency)];
yield $hashJob;
yield $saveJob;
yield $collectorFn;
});
Fazit: Jobs werden erledigt. Treiber warten. HTTP existiert nie im Treiber â oder jedes Protokoll erfindet ein Framework.
Was âconcurrency=4â tatsĂ€chlich einschrĂ€nkt
Eine Begrenzung der Parallelverarbeitung ist nur dann sinnvoll, wenn die Laufzeitumgebung genau angeben kann, was sie begrenzt.
MaxIpStrategy(4) begrenzt die Paketverarbeitung in einem Schritt. Allein dadurch entstehen keine sich ĂŒberschneidenden Netzwerkwartezeiten.
| Ebene | â4â könnte bedeuten |
|---|---|
| Pakete in der Warteschlange | Viele warten |
| Zugelassene Stellen | †4 (Verarbeitung < max) |
| Aktive Fasern | †4 nach dem Start |
| Offene Sockets | †4, wenn jeder Abruf ergibt |
| Anfragen wĂ€hrend des Fluges | Gleiches gilt â nur mit nicht blockierenden Wartezeiten |
Bei blockierenden E/A-VorgÀngen ist die BeschrÀnkung rein kosmetischer Natur. Mit StreamSelectDriver und dem Erzeugen von Fetch-Jobs:
MaxIpStrategy(4)
+ non-blocking fetch
+ StreamSelectDriver
â †4 fetch packets admitted
â †4 fetch Fibers active
â †4 sockets waiting on select
Das ist Flows Ăbersetzung von mergeMap(..., 4). Der GĂŒltigkeitsbereich ist eine Stufe: Hashing/Speichern kann sich mit dem nĂ€chsten Abruf ĂŒberschneiden â das ist eine Pipeline-Ăberlappung, keine fehlende Begrenzung.
Fehler, Abschluss, Bereinigung
Sammeln vs. schnelles Fail-Response. Batch-Importe benötigen weiche Fehler pro Element; kritische Veröffentlichungen erfordern möglicherweise errorJob oder einen Prozessabbruch. Flow unterstĂŒtzt beides. Timeouts fĂŒr die Warteschicht sind bereits vorhanden; vollstĂ€ndige Wiederholungs-/Timeout-Operatoren können warten, bis sich Muster in verschiedenen Anwendungen wiederholen.
Abschluss bedeutet: Quelle erschöpft, Warteschlangen leer, keine aktiven Jobs, keine ausstehenden Stream-Wartezeiten, Ressourcen geschlossen. Aktuell wird diese Barriere durch await() plus einen Collector realisiert. Ein zukĂŒnftiges $flow->start() / Execution (Abbruch, Ergebnis, Fehler) ist eine optionale Benutzerfreundlichkeit â fĂŒr diesen Abschnitt nicht erforderlich.
AufrĂ€umarbeiten bedeuten âendlichâ + Wartezeiten â RxJS-Teardown im PHP-Gewand, keine Hoffnung auf den Destruktor. Strukturierte Traces sind einem internen Event-Bus in puncto Beobachtbarkeit vom ersten Tag an ĂŒberlegen.
Mapping RxJS â Flow
| RxJS | Flow | Aufruf |
|---|---|---|
| Beobachtbar | Quelle von Ips |
Anpassen |
| Observer-Protokoll | Collector + errorJob |
Vereinfachen |
| Abonnement | ZukĂŒnftige AusfĂŒhrungshandle | Verschieben |
| Bediener | Job / Phase | Ăbernehmen |
| Planer | Treiber | Flow-Namen beibehalten |
mergeMap(n) |
MaxIpStrategy(n) + resultierende Jobs |
Adopt-Funktion |
finalize |
Explizite Bereinigung | Ăbernehmen |
| Betreff / teilen | â | VorlĂ€ufig ablehnen |
Beibehalten: Lazy Compose / Explicit Run, Multi-Item-Pipelines, begrenzte ParallelitĂ€t, deterministische VervollstĂ€ndigung, Bereinigung, Isolation pro AusfĂŒhrung, Beobachtbarkeit.
Vereinfachen: Jobs + Pakete; Treiber; Sammler â kein öffentliches next/error/complete-Protokoll; Limits â keine Operatornamen.
Verschieben: öffentliche AusfĂŒhrung, Abbruchtoken, erstklassige Wiederholungs-/Timeout-Operationen, switchMap / exhaustMap, Hot Streams.
Abgelehnt: Ăffentliche APIs in RxJS-Form, Subjects, Promise/Operator-Zoos, HTTP-in-Driver, Dual-Engines.
Flow bleibt eine ausgereifte, pragmatische Pipeline-Engine mit einem echten asynchronen Executor â kein reaktives Framework im Gewand der PHP-Syntax.
Wohin Flow als NĂ€chstes geht
Keep: lifecycle discipline (start â bound concurrency â wait â tear down)
Shape: Job + Ip + MaxIpStrategy + Driver + await + collector
Next: multi-connection overlap benchmarks
cancel tokens on StreamSelectDriver waits
richer await ergonomics if collectors become painful
Darkwood Flow wird nicht zu RxJS fĂŒr PHP. Es setzt vielmehr auf eine PHP-native Pipeline-Engine: begrenzte ParallelitĂ€t, bereitkeitsbasierte E/A, deterministische VervollstĂ€ndigung und explizite Bereinigung â mit Treibern, die ihre Wartezeiten nachweisen können.
Wenn ein Job auf E/A wartet, wartet die Laufzeitumgebung dann tatsĂ€chlich mit â oder tut sie nur so?
Ressourcen
- Demo-Repository: github.com/matyo91/rxjs-flow
- PrÀsentationsfolien: github.com/matyo91/slidewire (
/slides/the-fiber-illusion)