Aller au contenu principal

Fibers PHP : anatomie d'une primitive mal aimée

Fibers PHP: Découvrez cette primitive bas niveau essentielle aux bibliothèques asynchrones. Comprenez son mécanisme de stack switch coopératif et sa différence avec les generators.

Pierre 10 min de lecture
Sommaire · 5 0%

PHP a livré sa coroutine en novembre 2021 sous le nom de Fiber. Cinq ans plus tard, vous n'avez probablement jamais écrit new Fiber(...) une seule fois dans votre code applicatif... Et ce n'est pas grave.

Cette absence dans nos src/ n'est pas un échec de la primitive. C'est une conséquence directe de ce que les Fibers sont : un mécanisme bas niveau, livré brut, conçu pour être consommé par des bibliothèques (Amphp, Revolt, ReactPHP) plutôt que par votre PostShowAction. La confusion qui entoure les Fibers vient en grande partie du fait que cette destination n'a jamais été clairement annoncée — la RFC parle de « stackful coroutines », et tout le monde a entendu « async/await »./

Ce qu'est vraiment un Fiber (et ce qu'il n'est pas)

Première mise au point, parce que la confusion est tenace. Un Fiber, ce n'est pas :

  • du multi-threading — PHP reste mono-threadé, un seul opcode s'exécute à la fois ;
  • du multi-processing — il n'y a pas de fork() derrière, pas de pcntl, pas de mémoire partagée ;
  • une coroutine planifiée par le runtime comme une goroutine Go ou un async Rust — rien ne décide quand un Fiber reprend, c'est vous qui le faites explicitement ;
  • une abstraction d'I/O asynchrone — les Fibers ne savent pas attendre une socket, elles savent juste se mettre en pause.

Un Fiber, c'est uniquement ceci : un mécanisme de stack switch coopératif. Une fonction qui sait dire « pause » et qui peut être reprise plus tard, avec toute sa pile mémoire (variables locales, frames d'appel, position d'exécution) intacte. L'analogie la plus juste, parce qu'elle reproduit exactement la mécanique, est la mise en attente d'un appel téléphonique : la conversation est gelée, vous prenez une autre ligne, vous revenez plus tard et tout est exactement où vous l'aviez laissé. C'est la seule analogie qu'on filera ici ; deux ou trois reprises et on passe à autre chose.

Cette définition fait apparaître la différence avec un generator, l'autre primitive d'exécution interruptible de PHP. Un generator peut suspendre son exécution avec yield, mais uniquement vers son appelant immédiat. Si vous appelez une fonction depuis un generator, et que cette fonction veut suspendre, elle ne peut pas — il faut faire remonter manuellement le yield dans toute la chaîne d'appel. C'est le célèbre problème du colored function : tout le call stack au-dessus doit être conscient qu'il fait du yield.

Le Fiber casse cette contrainte. N'importe quelle fonction appelée depuis un Fiber, à n'importe quelle profondeur, peut appeler Fiber::suspend() et rendre la main au code qui a lancé le Fiber. La pile entière est gelée d'un bloc, puis dégelée plus tard sans que les fonctions intermédiaires soient au courant.

Cette différence de portée change tout pour le design d'un framework asynchrone. Avec des generators, écrire une bibliothèque async demandait que chaque fonction utilisateur soit elle-même un generator. Avec des Fibers, la bibliothèque encapsule la primitive et expose une API synchrone classique : $response = $client->request(...). Sous le capot, ça suspend, ça attend, ça reprend, et l'utilisateur ne voit rien. Le code n'est plus coloré — il est juste du PHP normal. C'est cette propriété, et elle seule, qui justifie l'existence des Fibers en tant qu'API du noyau.

L'API, ligne par ligne

L'API se tient sur la paume d'une main. C'est sa force et c'est son piège : tout est minimaliste, tout est explicite, rien n'est aidé.

PHP
<?php

$fiber = new Fiber(function (string $tache): string {
    echo "Début de $tache\n";
    $entree = Fiber::suspend('etape-1');     // (1) je passe la main, en livrant 'etape-1'
    echo "Reprise avec : $entree\n";          // (2) reprise — $entree contient ce qu'on m'a redonné
    return "fin de $tache";                   // (3) valeur finale du Fiber
});

$jalon = $fiber->start('traitement');         // (A) lance — récupère la valeur du suspend()
// $jalon === 'etape-1'

$fiber->resume('payload');                    // (B) reprend le Fiber, $entree dans (2) === 'payload'
$resultat = $fiber->getReturn();              // (C) valeur de retour finale du Fiber
// $resultat === 'fin de traitement'
  • new Fiber(callable $callback) crée le Fiber dans un état dormant. Aucune ligne n'est exécutée, on ne fait que mémoriser le callable. Le Fiber n'a pas encore de pile.
  • $fiber->start(...$args) allume le Fiber, exécute le callable jusqu'au premier Fiber::suspend() ou jusqu'au return. Les arguments passés à start() sont ceux que reçoit le callable. La valeur retournée par start() est ce qui a été passé à Fiber::suspend() à l'intérieur du Fiber.
  • Fiber::suspend(mixed $value = null) est la seule méthode statique qui compte. Elle ne peut être appelée que depuis l'intérieur d'un Fiber actif (sinon FiberError). Elle gèle la pile, rend la main au code qui a fait start() ou resume(), et lui livre la valeur passée en argument.
  • $fiber->resume(mixed $value = null) réveille le Fiber suspendu. La valeur passée à resume() devient la valeur de retour du Fiber::suspend() qui avait gelé l'exécution. C'est ainsi qu'on injecte de la donnée dans le Fiber au moment de la reprise.
  • $fiber->throw(\Throwable $e) réveille le Fiber comme resume(), mais en levant une exception là où le suspend() était bloqué. Indispensable pour propager des erreurs depuis l'event loop.
  • $fiber->getReturn() lit la valeur retournée par le callable une fois le Fiber terminé. Si on l'appelle alors que le Fiber n'est pas terminé, c'est FiberError.
  • Fiber::getCurrent() retourne le Fiber en cours d'exécution, ou null si on est dans le scope principal. Utile pour les bibliothèques qui doivent décider entre une stratégie sync et une stratégie async selon le contexte d'appel.

Et c'est tout. Pas de scheduler, pas de file d'attente, pas de sélecteur d'événement. La RFC l'écrit noir sur blanc : « Fibers do not provide concurrency. » Ce que les Fibers fournissent, c'est la primitive pour construire un système concurrent. La caisse à outils est livrée brute, à dessein.

Pourquoi votre application Symfony n'en voit jamais

Posons la question : quel intérêt aurait PostShowAction::__invoke() à instancier un Fiber ?

PHP
```php
final class PostShowAction extends AbstractController
{
    public function __construct(private readonly PostService $postService) {}

    public function __invoke(string $slug): Response
    {
        $post = $this->postService->findPublishedBySlug($slug);
        if (!$post) throw $this->createNotFoundException();
        return $this->render('post/show.html.twig', ['post' => $post]);
    }
}
```

Trois opérations : un appel service qui finit en SELECT Doctrine, un if, un rendu Twig. Aucune n'a de raison d'être suspendue. La requête est synchrone du début à la fin, vit quelques dizaines de millisecondes, puis meurt. Y glisser un Fiber ajouterait de la cérémonie pour zéro gain.

La raison structurelle est plus profonde. Pour qu'un Fiber serve à quelque chose, il faut que deux conditions soient réunies :

  1. Un event loop qui sait quand reprendre les Fibers suspendus.
  2. Des opérations d'I/O non bloquantes qui peuvent rendre la main pendant qu'elles attendent.

Sans event loop, suspendre un Fiber n'a pas de sens — personne n'est là pour le réveiller. Sans I/O non bloquantes, tous vos Fibers passent leur temps assis à attendre une socket bloquée, et vous avez juste réinventé le sleep() avec plus de boilerplate.

Or l'écosystème PHP traditionnel ne fournit ni l'un ni l'autre :

  • Doctrine, PDO, ext-mysqli, ext-pgsql font des appels système bloquants. Quand vous attendez un SELECT, le processus PHP est suspendu par l'OS, pas par vous. Aucun Fiber n'aidera.
  • file_get_contents(), fopen(), curl_exec() dans leur usage par défaut sont également bloquants.
  • Symfony Messenger dans la configuration par défaut du blog sync://) traite chaque message dans le processus appelant, sans concurrence.
  • Le runtime request/response : à la fin de la requête, tout est jeté. Un event loop n'a pas le temps de tourner.

Tant que ces fondations ne changent pas, glisser un Fiber dans votre contrôleur revient à mettre un turbo sur un vélo dont le frein arrière est serré. Vous payez la cérémonie, vous ne récoltez aucun gain.

Cette explication est aussi celle de l'apparente déception. Beaucoup attendaient des Fibers PHP 8.1 qu'elles transforment leur quotidien. Mais elles ont été conçues pour faire évoluer l'écosystème d'abord, et le quotidien ensuite — par ricochet, quand les briques au-dessus se mettront à jour. C'est en train de se passer, lentement.

Là où elles vivent vraiment : Revolt, Amphp, ReactPHP

Si vous voulez voir des Fibers travailler, il faut sortir du périmètre Symfony classique et descendre d'un cran, vers les bibliothèques qui ont précisément attendu cette primitive depuis dix ans.

Revolt est l'event loop standard de la communauté async PHP, né en 2021 — peu après le vote de la RFC, ce n'est pas une coïncidence. Son rôle est minimaliste : tenir une boucle d'événements EventLoop::run()), gérer les timers, les watchers de socket, les signaux. Revolt ne sait pas ce qu'est un Fiber. Mais il fournit le squelette sans lequel les Fibers n'auraient personne pour les réveiller — comme un standard téléphonique sans opérateur, vos appels en attente le restent indéfiniment.

Amphp v3 est le framework qui a pris cette primitive à bras-le-corps. La v2 d'Amphp utilisait des generators et obligeait l'utilisateur à écrire yield partout. La v3, sortie après PHP 8.1, a pu réécrire son API comme du PHP synchrone normal, parce qu'elle utilise les Fibers en sous-main :

PHP
```php
use Amp\Future;
use function Amp\async;

// Trois requêtes HTTP en parallèle, code lisible comme du sync
[$a, $b, $c] = Future\await([
    async(fn() => $client->request('https://example.com/a')),
    async(fn() => $client->request('https://example.com/b')),
    async(fn() => $client->request('https://example.com/c')),
]);
```

Le code ressemble à du PHP de tous les jours, mais sous le capot chaque async() instancie un Fiber, chaque await orchestre les Fiber::resume() au bon moment via Revolt, et les trois requêtes HTTP voyagent en parallèle au lieu de s'enfiler en séquence. La latence totale tombe à max(t_a, t_b, t_c) au lieu de t_a + t_b + t_c.

ReactPHP existait avant les Fibers et avait choisi historiquement la voie des promesses then(), catch()). L'arrivée des Fibers n'a pas balayé son API — la rétro-compatibilité était trop importante — mais il propose désormais des wrappers qui rendent l'expérience synchrone-like, par-dessus le même moteur de promesses.

Dans tous ces cas, vous écrivez un code qui ressemble à du synchrone, et la bibliothèque traduit ça en suspensions/reprises de Fibers qu'un event loop orchestre. Vous n'écrivez jamais new Fiber(). C'est exactement ce qui était promis : les Fibers sont une primitive d'écosystème, pas d'application.

Le mot de la fin

Les Fibers sont un outil de bibliothèque. Vous êtes presque toujours du côté des consommateurs.

Et puis il y a la prophétie d'écosystème. PHP n'aura probablement jamais son async/await intégré au langage avant la 9.0 — la RFC qui circule depuis quelques années a peu de chances d'aboutir d'ici là, et c'est probablement très bien comme ça. Ce que les Fibers permettent, c'est de construire des bibliothèques async qui ressemblent à du sync sans coloration de fonction. C'est exactement ce qu'on demandait. C'est arrivé. Le reste — l'adoption massive en application — viendra ou ne viendra pas, mais ce n'est plus le langage qui freine. C'est nous qui devons décider quand l'investissement Amphp / Revolt est rentable.

En attendant, gardez les Fibers en réserve pour quand vous aurez un vrai besoin de concurrence d'I/O.

Cet article vous a-t-il aidé ?

Vos réactions ne sont pas encore enregistrées — bientôt disponible.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.