PHP async, event loop et Fibers : concurrence sur un seul thread

Un collègue demande : "Est-ce que PHP peut faire comme Node.js, sauter d'une tâche à l'autre sur un seul thread ?" La réponse courte : oui. La réponse longue : c'est plus subtil que ça, et la distinction entre concurrence et parallélisme change tout.

PHP a la réputation d'être un langage séquentiel et bloquant. C'est vrai dans le modèle classique Apache + PHP-FPM. Mais c'est loin d'être une fatalité — et comprendre pourquoi ça bloque est la première étape pour savoir quand et comment ne plus laisser bloquer.

Le modèle bloquant de PHP-FPM

En PHP-FPM, chaque requête HTTP est traitée par un worker isolé. Ce worker est mono-thread : les opérations s'exécutent les unes après les autres, dans l'ordre. Rien ne peut se passer en parallèle dans le même process.

Concrètement, si ton handler fait trois appels HTTP vers des APIs externes, ils s'enchaînent :

t=0ms   → appel API users    (200ms d'attente réseau)
t=200ms → appel API orders   (200ms d'attente réseau)
t=400ms → appel API products (200ms d'attente réseau)
t=600ms → réponse renvoyée au client

600 ms. Pendant 580 ms sur ces 600, le thread ne fait rien — il attend une réponse réseau. Le CPU est libre, mais le thread est bloqué sur les I/O. C'est là que l'async devient intéressant : on peut utiliser ce temps d'attente pour faire avancer d'autres tâches.

PHP CLI suit le même modèle : un seul thread, exécution séquentielle. La différence avec PHP-FPM, c'est que le script peut tourner longtemps — ce qui rend l'event loop viable, puisqu'on a un process persistant capable de gérer une boucle d'événements.

L'event loop : concurrence coopérative

Le principe de l'event loop est simple : plutôt que de bloquer le thread en attendant une réponse I/O, on enregistre un callback qui sera appelé quand la réponse arrive, et on passe à la tâche suivante. Le thread ne dort jamais — il boucle en vérifiant quels événements sont prêts à être traités.

ReactPHP est la bibliothèque qui implémente ce modèle en PHP. L'exemple le plus simple pour comprendre la mécanique :

$loop = React\EventLoop\Factory::create();

$loop->addTimer(1, function () { echo "Tâche A\n"; });
$loop->addTimer(2, function () { echo "Tâche B\n"; });

$loop->run();

Un seul thread. Les deux timers s'exécutent dans la même boucle. Pendant qu'on attend la première seconde, rien ne bloque le thread — l'event loop peut gérer d'autres événements en parallèle. C'est la concurrence coopérative : les tâches se partagent volontairement le thread en cédant le contrôle quand elles attendent.

Le cas concret qui justifie l'investissement : trois appels HTTP en parallèle au lieu de séquentiels. Avec React\Http\Browser :

$loop    = React\EventLoop\Factory::create();
$browser = new React\Http\Browser($loop);

$promises = [
    $browser->get('https://api.example.com/users'),
    $browser->get('https://api.example.com/orders'),
    $browser->get('https://api.example.com/products'),
];

React\Promise\all($promises)->then(function (array $responses) {
    foreach ($responses as $response) {
        echo $response->getBody() . "\n";
    }
});

$loop->run();

Les trois requêtes sont lancées simultanément. L'event loop attend les réponses et traite chaque callback dès qu'une réponse arrive. Résultat : ~200 ms au lieu de 600 ms. Le gain vient uniquement du recouvrement du temps d'attente réseau — pas de parallélisme CPU, pas de threads supplémentaires.

Les Fibers PHP 8.1 : le cooperative multitasking natif

Les Fibers, introduites en PHP 8.1, sont un mécanisme de bas niveau pour le multitasking coopératif. Une Fiber peut suspendre son exécution et rendre la main au code appelant, qui peut décider de reprendre la Fiber plus tard avec une valeur.

$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('première suspension');
    echo "Reprise avec : " . $value . "\n";
});

$value = $fiber->start();          // démarre la Fiber, reçoit la valeur suspendue
echo "Suspendu avec : " . $value . "\n";
$fiber->resume('bonjour');          // reprend la Fiber avec une valeur

Ce qui donne :

Suspendu avec : première suspension
Reprise avec : bonjour

Une Fiber, c'est essentiellement un contexte d'exécution pausable. Elle ne tourne pas en parallèle du code principal — elle lui cède le contrôle explicitement via Fiber::suspend(). C'est le cœur du cooperative multitasking : chaque Fiber est responsable de savoir quand céder.

Les Fibers seules ne suffisent pas à construire un système async utilisable. Il faut un scheduler qui gère la liste des Fibers actives, reprend celles qui peuvent avancer, et intègre une event loop pour les opérations I/O. C'est ce que Amphp v3 fournit. Avec Amp, écrire du code async ressemble à du code synchrone :

use Amp\Http\Client\HttpClientBuilder;

$client = HttpClientBuilder::buildDefault();

// Ces trois appels sont lancés en concurrence,
// mais le code se lit comme s'il était séquentiel
$responses = Amp\Future\awaitAll([
    async(fn() => $client->request(new Request('https://api.example.com/users'))),
    async(fn() => $client->request(new Request('https://api.example.com/orders'))),
    async(fn() => $client->request(new Request('https://api.example.com/products'))),
]);

La lisibilité est l'argument principal d'Amp par rapport à ReactPHP : pas de chaîne de callbacks, pas de gestion manuelle des promises. Le scheduler gère la suspension et la reprise des Fibers en coulisses.

Ce que l'async ne résout pas

L'async ne résout que le problème du temps perdu à attendre des I/O. Si la tâche est CPU-bound — calcul cryptographique, traitement d'image, parsing lourd — il n'y a rien à gagner. Le thread est actif à 100 %, et suspendre la Fiber ne fait que retarder le travail sans permettre à une autre tâche de s'exécuter pendant ce temps.

La distinction concrète : un appel HTTP de 200 ms, c'est 199 ms d'attente pendant lesquels le CPU est libre. Un hash bcrypt qui prend 200 ms, c'est 200 ms de CPU à pleine charge. L'event loop aide dans le premier cas, pas dans le second.

Le cas de la base de données est plus nuancé. PDO, le driver par défaut en PHP, est entièrement bloquant : une requête SQL bloque le thread jusqu'à la réponse. Avec ReactPHP ou Amp, utiliser PDO dans une Fiber annule tout le bénéfice — la Fiber bloque comme du code synchrone classique. Il faut des drivers async natifs : amphp/mysql ou amphp/postgres pour PostgreSQL. L'adoption de ces drivers est encore limitée, ce qui rend le stack Amp + PostgreSQL moins naturel que son équivalent Node.js.

Autre limite à ne pas sous-estimer : les extensions PHP synchrones. Si une lib tierce appelle une fonction réseau bloquante en interne (curl sans async, certains SDKs), toute la boucle se fige pendant cet appel. L'event loop ne peut rien contre du code bloquant qu'elle ne contrôle pas.

Vrai parallélisme : quand l'event loop ne suffit pas

Quand l'enjeu n'est pas l'attente I/O mais le calcul pur, ou quand on veut vraiment plusieurs threads CPU, PHP a d'autres options — moins élégantes, mais fonctionnelles.

pcntl_fork — disponible en PHP CLI, crée un process fils qui hérite du contexte parent. Simple pour paralléliser des tâches indépendantes, mais attention à la gestion des ressources partagées (connexions DB, fichiers) :

$pid = pcntl_fork();

if ($pid === 0) {
    // Process fils
    processHeavyTask();
    exit(0);
} else {
    // Process parent continue
    doOtherWork();
    pcntl_waitpid($pid, $status); // attend la fin du fils
}

ext-parallel — extension PECL qui expose de vrais threads PHP avec partage mémoire contrôlé. Plus propre que fork pour les tâches CPU-bound, mais l'extension n'est pas disponible sur toutes les installations et sa surface d'API est réduite (seules des fonctions stateless peuvent être envoyées à un thread).

Workers externes — la solution la plus pragmatique en production : déléguer le travail lourd à une queue (Redis, RabbitMQ, Beanstalkd) consommée par des workers PHP-FPM séparés. Symfony Messenger gère ça proprement. L'application web reste réactive, les tâches lourdes s'exécutent en dehors du cycle requête/réponse.

Le scaling horizontal de PHP-FPM lui-même est aussi une réponse valide : plusieurs workers traitent plusieurs requêtes en parallèle — pas dans le même thread, mais dans des processes distincts gérés par le pool FPM. C'est le modèle par défaut en prod, et pour la majorité des applications web, c'est suffisant.

Conclusion

PHP peut faire de la concurrence coopérative. ReactPHP et Amp le prouvent depuis des années en production. Les Fibers en PHP 8.1 ont enfin donné un primitif natif propre pour construire des schedulers, et Amp v3 en tire très bien parti.

Mais la réponse à "faut-il faire de l'async en PHP ?" dépend entièrement du problème. Si le goulot d'étranglement est l'attente de réponses réseau dans un script CLI longue durée — oui, ReactPHP ou Amp font sens. Si c'est une API web classique avec quelques requêtes SQL — PHP-FPM avec plusieurs workers, peut-être un cache Redis, résout le problème sans ajouter la complexité d'une event loop.

Ce qui est certain : l'argument "PHP ne peut pas faire d'async" est faux. L'argument "PHP avec event loop est adapté à tous les cas de Node.js" l'est tout autant. Comme souvent, la nuance est dans le modèle de charge, pas dans le langage.

Commentaires (0)