đȘ L'illusion de la fibre : ce que RxJS nous a appris sur les pipelines asynchrones rĂ©els en PHP
le 18 juillet 2026
La plupart des développeurs découvrent RxJS grùce à sa syntaxe fluide :
source.pipe(map(...), mergeMap(...), retry(...));
MĂ©morable â et la partie la moins intĂ©ressante lorsqu'on conçoit un moteur de pipeline PHP.
Nous avons utilisé RxJS comme modÚle architectural pour Darkwood Flow. Les équipes qui développent des scrapers, des importateurs et des pipelines multimédias se demandent sans cesse si elles ont besoin d'API « réactives ». Notre question était plus directe :
Que peut apprendre Flow de RxJS sans devenir RxJSÂ ?
RxJS dĂ©crit les valeurs au fil du temps selon un cycle de vie explicite : composition avec pipe, dĂ©marrage avec subscribe, suivi des tĂąches en cours, attente de la fin, propagation des erreurs, et suppression lors de la dĂ©sinscription. Les planificateurs sont importants pour les minuteurs ; ils ne sont pas ce qui permet le fonctionnement des requĂȘtes d'URL simultanĂ©es. Le concept fondamental est le cycle de vie d'exĂ©cution.
Sous le capot, cette machine ressemble Ă ceci :
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 se heurte dĂ©jĂ aux mĂȘmes questions Ă mesure qu'il dĂ©passe le simple cadre de la composition de scĂšnes :
- Quand un pipeline démarre-t-il ?
- Quand sera-t-il terminé ?
- Qu'est-ce qui est encore actif ?
- Que se passe-t-il en cas d'erreur ?
- Qui ferme les ressources ?
- Comment arrĂȘter une course ?
Dans Flow, chaque valeur est transportĂ©e dans un paquet d'instructions nommĂ© Ip. Les paquets sont admis par des stratĂ©gies, planifiĂ©s par un pilote et collectĂ©s aprĂšs l'appel Ă await(). Nous prenions dĂ©jĂ en charge Fibers, Amp, React, Swoole et d'autres technologies similaires, et nous traitions dĂ©jĂ de nombreux paquets via le processus fetch â hash â save. Ce qui nous manquait, c'Ă©tait une prise en charge transparente des E/S non bloquantes natives : la possibilitĂ© de gĂ©rer les attentes rĂ©elles des sockets sans imposer Amp ou React comme dĂ©pendance stricte, et sans supposer qu'une Fiber autour d'une lecture bloquante soit suffisante.
JavaScript fournit une boucle d'événements à RxJS. PHP CLI n'en fournit pas. Un Fiber peut suspendre l'exécution PHP coopérative ; il ne peut pas transformer à lui seul un appel socket bloquant en E/S chevauchantes. Flow s'adapte avec des primitives explicites :
PHP streams â non-blocking mode â stream_select() â Fibers â Driver â Flow
Ă retenir : Adoptez la rigueur du cycle de vie de RxJS. Conservez le vocabulaire de Flow (tĂąches, paquets, pilotes). Cette Ă©tude nâa jamais Ă©tĂ© une digression sur RxPHP ; ce qui est intĂ©ressant, ce sont les principes, et non les types.
Pour rester intĂšgres, nous avons utilisĂ© un pipeline en bĂ©ton â et non pas seulement des minuteries artificielles :
URLs â fetch concurrently â checksum â save â per-URL result â summary
Les sockets rĂ©elles rĂ©vĂšlent un comportement bloquant. Les limites de concurrence sont respectĂ©es ou non. Les dĂ©lais d'attente et le nettoyage deviennent visibles. Une URL ayant Ă©chouĂ© ne doit pas annuler cinq succĂšs lorsque c'est la rĂšgle du produit. Les dĂ©monstrations artificielles de delay() prouvent la planification Fiber ; ââelles ne peuvent pas prouver que les sockets ouvertes partagent une attente stream_select.
ThĂšse : Flow devrait emprunter la discipline de RxJS en matiĂšre dâexĂ©cution, de concurrence, dâachĂšvement et de nettoyage, tout en restant un moteur PHP natif construit sur des tĂąches, des paquets, des stratĂ©gies et des pilotes.
Autrement dit : nous pensions quâun pilote basĂ© sur Fiber permettait dĂ©jĂ lâexĂ©cution asynchrone de Flow. LâĂ©tude de RxJS nous a contraints Ă dĂ©finir ce que signifie « asynchrone ». La suite de cet article relate cette dĂ©couverte.
Sous les opĂ©rateurs : comment RxJS sâexĂ©cute rĂ©ellement
Nous avons examiné RxJS 8.0.0-alpha.14 (types principaux dans @rxjs/observable). La structure des packages est spécifique à cette version ; les principes du cycle de vie sont plus généraux que dans cette version alpha.
from(urls).pipe(
mergeMap(fetchImage, 4),
map(hashBinary),
retry(2),
finalize(cleanup),
);
La composition n'est pas l'exécution
La mĂ©thode Observable.pipe transforme les opĂ©rateurs en nouveaux Observables. Aucune donnĂ©e n'est encore rĂ©cupĂ©rĂ©e. Les opĂ©rateurs de cette arborescence renvoient gĂ©nĂ©ralement new Observable(subscriber => { ... }) ; l'ancienne mĂ©thode lift est obsolĂšte Ă partir de la version 8. Flow sĂ©pare dĂ©jĂ la composition (FlowFactory / fn) de l'exĂ©cution (await()) : mĂȘme sĂ©paration, noms diffĂ©rents.
L'abonnement lance l'exécution
La fonction subscribe encapsule le consommateur dans un Subscriber, connecte le producteur et enregistre la destruction. Les opérateurs s'abonnent en amont ; next, error et complete envoient les données vers le récepteur. Des fonctions auxiliaires telles que operate({ destination, ... }) lient les processus enfants à l'arbre de destruction de la destination, ce qui permet aux désabonnements de se propager.
Un Subscription stocke les finaliseurs et les exĂ©cute de maniĂšre idempotente lors de la dĂ©sinscription. Les signaux terminaux arrĂȘtent les notifications et dĂ©sinscrivent. Le nettoyage fait partie de la fin d'une exĂ©cution. Les sources froides (y compris from(array)) isolent les exĂ©cutions : chaque abonnement possĂšde son propre producteur.
mergeMap suit les éléments internes actifs
mergeMap(project, concurrent) dĂ©lĂšgue la logique de fusion partagĂ©e : un compteur dâentrĂ©es internes actives, une mĂ©moire tampon pour les dĂ©bordements et la fin de la fusion uniquement lorsque la source externe est terminĂ©e, que la mĂ©moire tampon est vide et quâil ne reste plus dâentrĂ©es internes. Les entrĂ©es internes passent par from(project(...)).
Nous n'avions pas besoin de mergeMap en PHP. Nous avions besoin de sa garantie : un travail en cours limité et une achÚvement qui attend la fin de chaque opération active.
réessayer et finaliser
En cas d'erreur, retry dĂ©sinscrit l'abonnement interne courant et se rĂ©inscrit Ă la mĂȘme source. Pour les requĂȘtes from(urls) effectuĂ©es Ă froid, cela redĂ©marre la liste â une mĂ©thode puissante, mais souvent inadaptĂ©e aux E/S par lots. finalize(callback) enregistre le nettoyage de l'abonnement externe (terminĂ©, erreur ou dĂ©sinscription), et non entre les tentatives de nouvelle connexion.
Ce qu'il faut voler, et ce qu'il ne faut pas voler
C'est le cĆur de l'Ă©tude.
| Voler | Laisser derriĂšre soi |
|---|---|
| Lazy compose â dĂ©marrage explicite | Observable / Observer / Subscriber comme types d'application |
| Travail en vol délimité | Catalogues d'opérateurs et noms de méthodes JS |
| AchĂšvement en attente de travail actif | Zoos du planificateur public |
| Nettoyage sur chaque chemin terminal | Sujets / multidiffusion « car Rx les prend en charge » |
Les analogies sont utiles, mais il ne faut pas s'arrĂȘter lĂ Â :
Subscriptionâ un gestionnaire d'exĂ©cution de flux futur- Planificateur â Pilote
mergeMap(n)â Ă©tape concurrente bornĂ©e (MaxIpStrategy+ tĂąches gĂ©nĂ©rantes)
à retenir : Copiez le contrat de cycle de vie. Ne copiez pas le systÚme de types.
Ce que Flow possĂ©dait dĂ©jĂ
Avant l'étude, Flow n'était pas une page blanche. C'est ce point de départ qui a rendu l'illusion de la fibre convaincante.
Nous pourrions déjà :
- Composez des étapes avec
FlowFactory(notation do du générateur) oufn() - Déplacer les valeurs dans les paquets
Ip - Exécuter des tùches (
JobInterfaceou fermetures) - Limiter l'admission avec
MaxIpStrategy($n) - Planification via les pilotes (
FiberDriverpar dĂ©faut, ainsi que Amp, React, Swoole, âŠ) - BarriĂšre sur
await()(voidâles rĂ©sultats nĂ©cessitent une tĂąche de collecte) - GĂ©rer les erreurs de routage via
errorJob(optionnel)
build pipeline â push Ip(s) â await()
â Driver admits packets, runs jobs, forwards results
â when nothing remains queued or active, await returns
L'envoi d'un paquet le place dans la file d'attente de la premiĂšre Ă©tape. Dans la fonction await(), le pilote interroge la stratĂ©gie de chaque Ă©tape pour dĂ©terminer quels paquets peuvent ĂȘtre exĂ©cutĂ©s, lance les fibres de tĂąches (ou coroutines), puis transmet le succĂšs en aval ou appelle errorJob. Les paquets terminĂ©s libĂšrent des emplacements de concurrence.
ModÚle mental : les paquets entrent en étapes, les stratégies limitent leur nombre d'exécutions, le pilote gÚre l'attente.
Avec FiberDriver, la gestion de plusieurs paquets en transit, delay() et MaxIpStrategy(4), l'exécution ressemblait à de l'asynchronisme. Les démonstrations de minuterie d'amplification se chevauchaient parfaitement. Le problÚme venait des E/S bloquantes natives : nous avions une interprétation erronée du terme « asynchrone » dans ce contexte.
L'illusion de la fibre
Ce que nous croyions
Envoi de quatre URL â admission de quatre processus â dĂ©marrage de quatre fibres â await(). Si les tĂąches ont effectuĂ© des E/S, les attentes doivent se chevaucher.
RxJS a rendu cette hypothĂšse gĂȘnante : si mergeMap suit les internes actifs, que suivait Flow â des paquets, des fibres ou des sockets ?
Ce qui a révélé l'affaire
file_get_contents($url); // still blocks the process
Dans une tùche FiberDriver, cet appel n'est jamais suspendu en cas de disponibilité. Le pilote attend sur le bloc. MaxIpStrategy(4) peut accepter quatre paquets pendant que le processus effectue encore la sérialisation au niveau du socket, ou bloquer le planificateur pendant la recherche de concurrence.
MĂȘme piĂšge : blocage de fread. Les fibres ne sont utiles que lorsque la tĂąche cĂšde la main. Sans API de disponibilitĂ©, il n'y a rien sur quoi cĂ©der la main.
Une fibre optique peut suspendre l'exécution de PHP. Elle ne peut pas rendre non bloquant un appel socket bloquant.
| Ătiquette | Signification | Fibres seulement ? |
|---|---|---|
| PHP simultané | Plusieurs fibres planifiées | Oui |
| Chevauchement des E/S | Plusieurs sockets dans un seul stream_select |
Non â nĂ©cessite une sĂ©lection non bloquante |
| Faux asynchrone | Blocage des appels dans Fibers | Dangereusement facile |
RxJS a imposĂ© ce vocabulaire : les limites dâadmission ne correspondent pas Ă un chevauchement dâE/S. Flow a dĂ» adopter cette transparence.
La pile dont nous avions besoin
non-blocking streams
â stream_select()
â Fiber suspension on wait
â readiness-based resume
â Driver await loop
â Flow stages
Cette pile est devenue le centre de l'étude, et c'est pourquoi StreamSelectDriver a été intégré à darkwood/flow.
à retenir : « Pilote asynchrone » sans mécanisme de gestion de la disponibilité reste un slogan. Avec stream_select, cela devient un engagement.
RĂ©sultats concrets : le pipeline dâimages
La démo se trouve dans content/rxjs-flow et utilise Flow local via un lien symbolique Composer. Un moteur local éphémÚre a permis de tester stream_select, puis a été volontairement supprimé. Un seul environnement d'exécution réutilisable.
php bin/console app:fetch-images --concurrency=4 --timeout=5
| ScĂšne | RĂŽle |
|---|---|
FetchImageJob |
stream_socket_client, E/S non bloquantes, waitWritable / waitReadable, analyse HTTP minimale, fermeture finally |
HashBinaryJob |
SHA-256 |
EnregistrerFichierJob |
Persister le corps |
| Collecteur | Agrégat ImageResult (await() renvoie void) |
La gestion de la concurrence s'effectue lors de la récupération via MaxIpStrategy($concurrency). L'erreur HTTP 404 est une erreur limitée à chaque élément sur les DTO (et non sur errorJob), ce qui signifie que cinq succÚs peuvent coexister avec un échec. Les traces génÚrent flow.started, item.started / completed / failed, flow.completed.
Exécution représentative (concurrence=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
Les fixtures utilisent php -S (monothread) avec de courts délais de routage pour que le transfert de pool soit visible. Les temps d'attente limités cÎté client sont bien réels ; l'accélération réelle nécessite un serveur multi-connexions. La tùche de récupération (fetch) illustre le fonctionnement du protocole HTTP : les redirections, les cas particuliers de TLS, les spécificités du traitement par blocs, les proxys, la compression et les E/S partielles ne sont pas abordés. Les clients de production restent gérés par des tùches ; les pilotes restent génériques.
Prochain test d'acceptation (prévu, non revendiqué tel que mesuré ici) :
5 Ă ~1s delayed endpoints on a multi-connection backend
concurrency=1 â ~5s
concurrency=4 â ~2s
StreamSelectDriver : ce que nous avons livré
StreamSelectDriver multiplexe la disponibilité des flux natifs sur les fibres :
$ready = $driver->waitReadable($stream, $timeoutSeconds);
$ready = $driver->waitWritable($stream, $timeoutSeconds);
Les deux renvoient un boolĂ©en et doivent ĂȘtre exĂ©cutĂ©s dans un job Fiber. En interne, le pilote :
- Enregistrements Fibre, flux, mode, date limite facultative
- Suspend
- Construit des ensembles
stream_selectĂ partir des attentes en attente - Reprend les fibres prĂȘtes avec
true, les attentes expirées avecfalse - Reprend toujours les suspensions
delay()simples à l'itération normale de la boucle.
MĂȘme DriverInterface que les autres Drivers (async, defer, await, delay, tick), plus ces API d'attente auxquelles les Jobs s'inscrivent par type.
Correction FIFO pour MaxIpStrategy : à pleine capacité, ne pas extraire et remettre en file d'attente (dans cet ordre de rotation). Les traces simultanées ont permis de mettre en évidence le bogue.
Pourquoi ne pas améliorer FiberDriver ? La reprise par défaut de Fiber diffÚre de la reprise conditionnée par l'état de disponibilité. Un pilote dédié permet de définir explicitement la rÚgle de planification.
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;
});
Ă retenir : Les tĂąches sont exĂ©cutĂ©es. Les pilotes attendent. Le protocole HTTP ne rĂ©side jamais dans le pilote â à moins que chaque protocole nâinvente un framework.
Ce que « concurrence=4 » limite réellement
Une limite de concurrence n'a de sens que si l'environnement d'exécution peut indiquer précisément ce qu'elle limite.
MaxIpStrategy(4) limite le traitement des paquets à une étape donnée. à elle seule, elle ne crée pas d'attentes réseau qui se chevauchent.
| Couche | « 4 » pourrait signifier |
|---|---|
| Paquets en attente | Nombreux sont ceux qui attendent |
| Offres d'emploi acceptées | †4 (traitement < max) |
| Fibres actives | †4 une fois commencées |
| Sockets ouverts | †4 si chaque récupération donne |
| RequĂȘtes en cours | Idem, mais avec des temps d'attente non bloquants |
Avec les E/S bloquantes, la limitation est purement cosmétique. Avec StreamSelectDriver et les tùches de récupération avec catch :
MaxIpStrategy(4)
+ non-blocking fetch
+ StreamSelectDriver
â †4 fetch packets admitted
â †4 fetch Fibers active
â †4 sockets waiting on select
Il s'agit de la traduction de Flow de mergeMap(..., 4). La portĂ©e est d'une seule Ă©tape : le hachage/l'enregistrement peuvent encore chevaucher la rĂ©cupĂ©ration suivante â il s'agit d'un chevauchement de pipeline, et non d'une rupture de la limite.
Erreurs, achĂšvement, nettoyage
Collecte vs gestion des échecs rapides. Les importations par lots nécessitent une gestion des erreurs par élément ; les publications critiques peuvent nécessiter errorJob ou le traitement des échecs. Flow prend en charge les deux. Les délais d'attente existent déjà  ; les opérateurs de nouvelle tentative/délai d'attente complets peuvent attendre que les schémas se répÚtent entre les applications.
Terminé signifie : source épuisée, files d'attente vides, aucune tùche active, aucune attente de flux en cours, ressources fermées. Actuellement, cette étape est franchie par await() et un collecteur. L'ajout futur de $flow->start() / Execution (annulation, résultat, erreurs) est une amélioration ergonomique optionnelle, non requise pour cette section.
Le nettoyage, c'est enfin respecter les dĂ©lais â le nettoyage RxJS implĂ©mentĂ© en PHP, et non l'espoir d'un destructeur. Les traces structurĂ©es sont plus efficaces qu'un bus d'Ă©vĂ©nements interne pour une observabilitĂ© dĂšs le premier jour.
Correspondance entre RxJS et Flow
| RxJS | Flux | Appel |
|---|---|---|
| Observable | Source des adresses IP | Adapt |
| Protocole observateur | Collecteur + errorJob |
Simplifier |
| Abonnement | Gestionnaire d'exécution future | Reporter |
| Opérateur | Poste / étape | Adopter |
| Planificateur | Pilote | Conserver le nom du flux |
mergeMap(n) |
MaxIpStrategy(n) + tùches générantes |
Capacité d'adoption |
finaliser |
Nettoyage explicite | Adopter |
| Sujet / partage | â | Refuser pour le moment |
à conserver : composition paresseuse / exécution explicite, pipelines multi-éléments, concurrence limitée, achÚvement déterministe, nettoyage, isolation par exécution, observabilité.
Simplification : tĂąches + paquets ; pilotes ; collecteurs â pas un protocole public next/error/complete ; limites â pas des noms d'opĂ©rateurs.
Postpone: public Execution, cancel tokens, first-class retry/timeout ops, switchMap / exhaustMap, hot streams.
Rejet : API publique de type RxJS, Subjects, zoos de promesses/opérateurs, HTTP dans le pilote, moteurs doubles.
Flow reste un moteur de pipeline mature et pragmatique avec un véritable exécuteur asynchrone, et non un framework réactif utilisant la syntaxe PHP.
OĂč Flow va-t-il ensuite ?
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 ne devient pas RxJS pour PHP. Il mise tout sur un moteur de pipeline natif PHP : concurrence limitĂ©e, E/S basĂ©es sur la disponibilitĂ©, achĂšvement dĂ©terministe et nettoyage explicite, avec des pilotes capables de justifier leurs temps dâattente.
Lorsqu'une tùche est en attente d'E/S, l'environnement d'exécution attend-il réellement avec elle, ou fait-il seulement semblant ?
Ressources
- DépÎt de démonstration : github.com/matyo91/rxjs-flow
- Slides de la présentation : github.com/matyo91/slidewire (
/slides/the-fiber-illusion)