đ¶âđ«ïž This person does not exist - PHP synchrone Ă l'orchestration asynchrone avec Flow
le 13 juin 2026
Vous devez tĂ©lĂ©charger dix images depuis une API distante. Entre chaque requĂȘte, vous attendez une seconde pour respecter un rate limit. Chaque image suit le mĂȘme chemin : fetch, hash, sauvegarde, reporting.
Le code ressemble Ă une boucle for familiĂšre. Il fonctionne. Mais pendant qu'une image tĂ©lĂ©charge, les neuf autres attendent. Pendant qu'une fibre dort sur un sleep(), tout le processus est figĂ©. Les unitĂ©s de travail sont indĂ©pendantes â seule l'orchestration les traite comme si elles ne l'Ă©taient pas.
C'est le problÚme concret que nous avons isolé dans le projet flow-thispersondoesnotexist : une commande Symfony minimale qui récupÚre plusieurs images depuis ThisPersonDoesNotExist et les sauvegarde sur disque.
L'objectif n'était pas de télécharger des images plus rapidement.
L'objectif Ă©tait de comprendre comment faire Ă©voluer une exĂ©cution sĂ©quentielle vers une orchestration asynchrone avec darkwood/flow â sans changer la logique mĂ©tier, en changeant le modĂšle d'exĂ©cution.
La transition se résume en deux commits :
fae47e8â commandeapp:fetch-thispersondoesnotexist, boucle synchronec61c929â mĂȘme commande, orchestrĂ©e par Flow etFiberDriver
Un seul fichier change entre les deux : src/Command/FetchThisPersonDoesNotExistCommand.php.
Le point de départ : une boucle synchrone
La premiÚre version (fae47e8) utilise une boucle for classique. Fetch, hash, sauvegarde et reporting sont exécutés inline ; un sleep(1) bloque le processus entre chaque téléchargement :
$savedFiles = [];
for ($index = 1; $index <= $count; ++$index) {
if ($count > 1) {
$this->io->section(sprintf('Download %d/%d', $index, $count));
}
try {
$image = $this->fetchImage();
$checksum = $this->computeChecksum($image);
$filename = $this->generateFilename($checksum, $file);
$outputPath = $this->saveFile($filename, $image);
$this->report($outputPath, $checksum, \strlen($image));
} catch (\RuntimeException $exception) {
$this->io->error($exception->getMessage());
return Command::FAILURE;
}
$savedFiles[] = $outputPath;
if ($index < $count) {
$this->io->note(sprintf('Waiting %d second(s) before next download...', self::DOWNLOAD_DELAY_SECONDS));
sleep(self::DOWNLOAD_DELAY_SECONDS);
}
}
Le fonctionnement est linĂ©aire : tĂ©lĂ©charger, traiter, attendre, passer Ă l'image suivante. Chaque Ă©tape bloque entiĂšrement le processus â y compris le sleep(), qui immobilise le programme mĂȘme quand aucun travail utile n'est en cours.
Cette approche est facile à comprendre, mais elle gaspille les temps d'attente I/O : tant qu'une image n'est pas terminée, aucune autre ne peut avancer.
Ă noter : la version synchrone conservait aussi une mĂ©thode alternative fetchImageViaFileGetContents(). Elle sera supprimĂ©e dans la version Flow â les streams explicites (fopen) sont prĂ©fĂ©rĂ©s pour la suite.
Observer le workflow caché
DerriÚre la boucle se cache un pipeline répété pour chaque image :
Fetch Image â Save Image â Report Result
Image #1 : Fetch â Save â Report
Image #2 : Fetch â Save â Report
Image #3 : Fetch â Save â Report
Chaque image est une unité de travail autonome. La question devient alors : pourquoi attendre la fin complÚte de l'image #1 avant de démarrer l'image #2 ?
Avant / AprĂšs : le changement architectural
Avant l'introduction de Flow, l'exécution est strictement séquentielle :
Image 1 â Fetch â Save â Sleep
Image 2 â Fetch â Save â Sleep
Image 3 â Fetch â Save â Sleep
AprÚs, les unités de travail sont planifiées indépendamment et traversent un pipeline commun :
Image 1 ââ
Image 2 ââŒâ> Flow Pipeline (Fetch â Save + Report) â> rĂ©sultats
Image 3 ââ
Le for ne pilote plus l'exécution étape par étape. Il enqueue des Ip (unités de travail). Flow orchestre leur passage dans le pipeline ; await() synchronise à la fin.
Introduire Flow
La dépendance darkwood/flow est ajoutée au projet avant la commande (e71899a). La version c61c929 l'utilise enfin pour orchestrer le travail.
Les imports introduits :
use Flow\Driver\FiberDriver;
use Flow\ExceptionInterface;
use Flow\FlowFactory;
use Flow\Ip;
Chaque image devient une unité de travail indépendante.
Dans Flow, cette unité est représentée par un Ip (Instruction Pointer) :
$flow(new Ip($index));
Le pipeline est décrit sous forme d'étapes avec FlowFactory et un générateur :
$driver = new FiberDriver();
$savedFiles = [];
$flow = (new FlowFactory())->create(function () use ($file, &$savedFiles, $driver) {
yield [
fn (int $index) => $this->fetchImage($index, $driver),
fn (ExceptionInterface $exception) => throw new \RuntimeException($exception->getMessage()),
];
yield function (string $image) use ($file, &$savedFiles): string {
$outputPath = $this->saveAndReport($image, $file);
$savedFiles[] = $outputPath;
return $outputPath;
};
}, ['driver' => $driver]);
for ($index = 1; $index <= $count; ++$index) {
$flow(new Ip($index));
}
try {
$flow->await();
} catch (\RuntimeException $exception) {
$this->io->error($exception->getMessage());
return Command::FAILURE;
}
Conceptuellement, cela revient à écrire :
Ip($index)
â
Fetch Image â Ă©tape async (fiber + delay)
â
Save + Report â Ă©tape suivante, reçoit le string image
Chaque image traverse exactement le mĂȘme pipeline.
Les responsabilités sont extraites en méthodes dédiées :
fetchImage(int $index, FiberDriver $driver): stringâ tĂ©lĂ©chargement, avec suspension cooperativesaveAndReport(string $image, ?string $fileOverride): stringâ hash, sauvegarde, reporting
La gestion d'erreur quitte la boucle : un errorJob sur l'étape fetch relance une RuntimeException, capturée une seule fois autour de await().
Remplacer l'attente bloquante
Le changement le plus important concerne la gestion du temps d'attente.
Dans la version synchrone, le délai est global et bloquant :
private const int DOWNLOAD_DELAY_SECONDS = 1;
// dans la boucle, entre chaque image :
sleep(self::DOWNLOAD_DELAY_SECONDS);
Dans la version Flow, le délai est par fibre et cooperative :
private const int DELAY_MIN_SECONDS = 1;
private const int DELAY_MAX_SECONDS = 3;
// dans fetchImage(), avant le téléchargement :
$delay = random_int(self::DELAY_MIN_SECONDS, self::DELAY_MAX_SECONDS);
$this->io->note(sprintf('#%d: suspending fiber for %ds before download...', $index, $delay));
$driver->delay($delay);
$driver->delay() suspend uniquement la fibre en cours. Les autres fibres continuent :
Image #1 attend (delay 2s)
Image #2 télécharge
Image #3 sauvegarde
Image #4 démarre son delay
Avec --count=5, le temps total se rapproche du max(delays + fetch) plutĂŽt que de leur somme.
Les Fibers comme fondation d'exécution
Sous le capot, FiberDriver s'appuie sur les Fibers de PHP 8.1 : une Fiber peut ĂȘtre suspendue puis reprise plus tard, ce qui permet d'exĂ©cuter plusieurs traitements indĂ©pendants avec une Ă©criture proche du PHP classique.
$driver = new FiberDriver();
Chaque image dispose de sa propre Fiber. Lorsqu'une Fiber attend ($driver->delay()), les autres continuent leur progression. Le logging reflĂšte ce comportement :
#2: suspending fiber for 2s before download...
#2: downloading (other fibers may run while this one waits)...
#2: fetch complete in 1.34s
Flow ne remplace pas les Fibers : il les utilise via un driver. Le choix du driver détermine comment le pipeline s'exécute ; la description du pipeline reste identique.
Le point de synchronisation
Le for enqueue les unités de travail :
for ($index = 1; $index <= $count; ++$index) {
$flow(new Ip($index));
}
await() devient le point de synchronisation unique :
$flow->await();
Planifier Ip(1)
Planifier Ip(2)
Planifier Ip(3)
âŠ
await() â barriĂšre finale
La responsabilité de l'orchestration est déplacée vers Flow. Le for ne fait plus le travail : il enqueue des unités. Flow les exécute en parallÚle ; await() attend que tout le pipeline soit terminé.
Pourquoi conserver les Streams PHP ?
Le projet utilise volontairement :
$stream = fopen(self::FETCH_URL, 'r', false, $context);
$content = stream_get_contents($stream);
plutĂŽt que file_get_contents().
L'objectif n'est pas de réinventer un client HTTP.
L'objectif est de rester proche des primitives PHP â le commentaire du code le dit explicitement :
Preferred for Flow experimentation: the resource handle is the primitive that can later be wired to stream_select(), non-blocking mode, or fibers.
Ces primitives pourront ensuite évoluer vers :
stream_set_blocking(false);
stream_select(...);
ou ĂȘtre intĂ©grĂ©es dans des drivers plus avancĂ©s (Amp, React, Swoole â tous supportĂ©s par darkwood/flow).
Les streams constituent une base naturelle pour expérimenter les modÚles asynchrones en PHP.
Limiter la concurrence (étape suivante)
Dans ce POC, toutes les images partent en parallĂšle (jusqu'Ă --count fibres actives).
Pour un usage production, Flow propose des stratégies comme MaxIpStrategy :
use Flow\IpStrategy\MaxIpStrategy;
yield [$job1, $errorJob1, new MaxIpStrategy(5)];
L'exemple officiel du package (examples/flow.php) utilise MaxIpStrategy(2) pour plafonner le nombre de jobs simultanés sur chaque étape.
Ce n'est pas encore branché dans flow-thispersondoesnotexist, mais c'est le levier naturel pour du rate limiting sans retomber dans du sleep() séquentiel.
Un modĂšle applicable bien au-delĂ des images
L'exemple ThisPersonDoesNotExist est volontairement simple. Le mĂȘme schĂ©ma se retrouve dans les pipelines Darkwood :
Scraping YouTube â Lister â TĂ©lĂ©charger / transcrire â Sauvegarder
Traitement vidĂ©o â GĂ©nĂ©rer assets â Encoder â Persister
Agent IA â Lire â Transformer â Publier
Dans tous les cas : des unitĂ©s de travail indĂ©pendantes qui traversent une succession d'Ă©tapes. Flow ne cherche pas Ă exĂ©cuter une tĂąche â il orchestre des flux de tĂąches.
Récapitulatif de la migration
| Aspect | fae47e8 (synchrone) |
c61c929 (Flow) |
|---|---|---|
| Boucle | for avec fetch/save inline |
for enqueue + $flow->await() |
| Délai | sleep(1) global |
$driver->delay() par fibre |
| Erreurs | try/catch dans la boucle |
errorJob + catch sur await() |
| Fetch | fetchImage() |
fetchImage($index, $driver) |
| Save | inline | saveAndReport() |
| Concurrence | aucune | fibres parallĂšles via FiberDriver |
Ce que Flow n'est pas
Avant de conclure, une clarification utile pour éviter les confusions courantes.
Flow n'est pas une event loop. Il ne gĂšre pas directement un cycle select / poll sur des descripteurs I/O.
Flow n'est pas une implémentation de Fibers. Les Fibers sont une primitive PHP 8.1. Flow les orchestre via des drivers, mais ne les remplace pas.
Flow n'est pas un runtime. Il ne modifie pas le modÚle d'exécution de PHP. Il s'exécute dans un processus PHP standard.
Flow n'est pas un substitut Ă Amp, ReactPHP ou Swoole. Ces bibliothĂšques fournissent des runtimes et des boucles d'Ă©vĂ©nements. Flow peut s'appuyer dessus â AmpDriver, ReactDriver, SwooleDriver â sans entrer en concurrence avec elles.
Ce que Flow apporte, c'est un modĂšle d'orchestration :
- décrire des unités de travail (Ip)
- composer des pipelines d'étapes (
yielddansFlowFactory) - déléguer l'exécution à un driver choisi (
FiberDriver,AmpDriver,ReactDriver,SwooleDriver, âŠ)
Le modĂšle d'orchestration est sĂ©parĂ© du modĂšle d'exĂ©cution. On peut dĂ©crire un pipeline une fois, puis changer de driver selon le contexte â CLI avec Fibers, worker Swoole, service React â sans réécrire la logique mĂ©tier.
Conclusion
Le passage du synchrone vers l'asynchrone n'est pas avant tout une question de performance.
C'est un changement de modĂšle mental :
Avant : exĂ©cuter l'opĂ©ration A, puis B, puis C â et attendre entre chaque Ă©tape.
AprÚs : décrire un pipeline, enqueue des unités de travail,
laisser l'orchestrateur planifier l'exécution.
La boucle for devient un producteur d'Ip. Les Ă©tapes fetch et save deviennent des jobs chaĂźnĂ©s. await() remplace la succession de sleep(). La logique mĂ©tier (tĂ©lĂ©charger, hasher, sauvegarder) reste la mĂȘme ; ce qui change, c'est qui dĂ©cide quand chaque Ă©tape s'exĂ©cute.
C'est exactement le modÚle dont Darkwood a besoin pour ses pipelines à venir : scraping YouTube et extraction de transcripts, traitement média dans MediaBundle, agents IA multi-étapes, orchestration de workflows longue durée. Des unités indépendantes, des étapes composables, un point de synchronisation explicite.
Le projet ThisPersonDoesNotExist en est la dĂ©monstration minimale : un seul fichier, un pipeline Ă deux Ă©tapes, un driver â suffisant pour illustrer la transition sans en masquer la portĂ©e.
Pour aller plus loin
- DépÎt du POC :
flow-thispersondoesnotexistâ commitsfae47e8âc61c929 - Package :
darkwood/flowâ exempleexamples/flow.php - L'article de FrĂ©dĂ©ric Bouchery retrace l'histoire de la programmation asynchrone en PHP, depuis les streams introduits dans PHP 4.3 jusqu'aux Fibers modernes de PHP 8.1 : https://f2r.github.io/fr/asynchrone.html