Darkwood Blog Blog
  • Articles
  • Veille
  • Releases
  • CrĂ©ateurs
fr
  • de
  • en
Connexion
  • Blog
  • Articles
  • Veille
  • Releases
  • CrĂ©ateurs

đŸȘ„ L'illusion de la fibre : ce que RxJS nous a appris sur les pipelines asynchrones rĂ©els en PHP

le 18 juillet 2026

Connectez-vous pour réagir à cet article

🚀 1

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) ou fn()
  • DĂ©placer les valeurs dans les paquets Ip
  • ExĂ©cuter des tĂąches (JobInterface ou fermetures)
  • Limiter l'admission avec MaxIpStrategy($n)
  • Planification via les pilotes (FiberDriver par 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 :

  1. Enregistrements Fibre, flux, mode, date limite facultative
  2. Suspend
  3. Construit des ensembles stream_select Ă  partir des attentes en attente
  4. Reprend les fibres prĂȘtes avec true, les attentes expirĂ©es avec false
  5. 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)

Connectez-vous pour réagir à cet article

🚀 1

Site

  • Plan du Site
  • Contact
  • Mentions lĂ©gales

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, tous droits réservés