Aller au contenu principal
Site en cours de refonte — quelques pages peuvent bouger ou évoluer.
07 avril 2026

TrueAsync 0.6 : mon process pool PHP a failli deadlocker

Paralléliser en PHP, c’est cool… jusqu’au moment où ton “pool” se bloque tout seul. Voilà un pattern de process pool TrueAsync 0.6 qui tient en prod.

12 min de lecture
15 vues
réactions
Partager :
TrueAsync 0.6 : mon process pool PHP a failli deadlocker

Si tu cherches comment faire un process pool en PHP avec TrueAsync 0.6, avec une limite de concurrence, de la backpressure, des timeouts et de la cancellation, tu es au bon endroit. Et oui, je vais aussi parler du truc qui m’a presque eu : le deadlock fabriqué maison, celui qui n’arrive pas en dev, et qui te tombe dessus quand tu charges un peu.

L’idée de départ est simple : tu as N tâches (IO, CPU, appels HTTP, parsing, conversions…), tu veux les exécuter en parallèle, mais tu ne veux pas exploser la machine, ni créer un backlog infini, ni te retrouver avec des workers bloqués parce que « ça attend quelque chose ».

Pourquoi un process pool en PHP (et pourquoi c’est facile à casser)

Un pool, ce n’est pas « on lance tout et on collecte ». C’est une politique de concurrence. Tu décides d’un nombre maximum de tâches en vol, tu imposes une file d’attente, et tu assumes le fait que le producteur (celui qui pousse des jobs) doit parfois ralentir. Sans ça, ton programme « marche » tant que tu as peu de jobs, puis il se met à manger de la RAM, à multiplier les timeouts, et à se transformer en machine à incidents.

Ce qui rend le sujet piégeux en PHP, c’est qu’on a longtemps vécu dans un monde « une requête = un thread mental ». Quand tu passes sur un modèle async, tu peux très vite te retrouver avec des dépendances invisibles : un appel qui attend un résultat, un channel qui est plein, une tâche qui ne libère jamais sa place parce qu’elle est annulée au mauvais moment. Et là, ton pool ne « ralentit » pas, il se fige.

TrueAsync 0.6 : l’async qui donne de la puissance… et des responsabilités

TrueAsync 0.6, pour le dire franchement, te met des briques assez bas niveau entre les mains. C’est ce qui fait son intérêt (tu peux construire des patterns propres), mais c’est aussi ce qui fait mal au début. Dans les discussions r/PHP autour de la 0.6, on voit revenir les mêmes frictions : l’envie d’avoir des stubs IDE pour l’autocomplétion, et le sentiment que « si je fais un truc naïf, je peux me fabriquer un edge case bien toxique ».

Mon conseil terrain : accepte le côté bas niveau, mais n’expose pas ça à toute ton équipe. Construis un wrapper « safe » (timeout, cancellation, release en finally, métriques) et laisse les gens utiliser ton pool plutôt que les primitives directement.

Le pattern qui tient : une queue bornée + une limite de concurrence

Le cœur d’un pool robuste, c’est deux contraintes distinctes. La limite de concurrence empêche d’avoir 200 tâches en vol. La queue bornée empêche de bufferiser 200 000 jobs en RAM « en attendant ». Les deux ensemble créent de la backpressure : quand c’est plein, le producteur attend, et c’est normal.

Au niveau structure, il y a un choix qui évite pas mal de pièges : plutôt que « je spawne une tâche par job », tu démarres un nombre fixe de workers (concurrency), et tu leur donnes une queue de jobs à consommer. C’est plus prédictible, et ça rend les cas d’arrêt/cancellation plus simples à raisonner.

<?php

/**
 * Exemple volontairement "pattern-first" : adapte les primitives exactes
 * à l'API TrueAsync 0.6 que tu utilises (channel, spawn, await, cancel...).
 */
final class ProcessPool
{
    private int $concurrency;
    private int $queueSize;

    /** @var callable */
    private $handler;

    private int $inFlight = 0;

    public function __construct(int $concurrency, int $queueSize, callable $handler)
    {
        $this->concurrency = max(1, $concurrency);
        $this->queueSize = max(1, $queueSize);
        $this->handler = $handler;
    }

    /**
     * Lance le pool et renvoie un itérateur de résultats (ou une callback de collecte, selon ton goût).
     * L'important : queue bornée + workers fixes.
     */
    public function run(iterable $jobs): array
    {
        // Pseudo-primitives : Channel::bounded(), spawn(), awaitAll()
        $jobCh = Channel::bounded($this->queueSize);
        $resultCh = Channel::bounded($this->queueSize);

        $workers = [];
        for ($i = 0; $i < $this->concurrency; $i++) {
            $workers[] = spawn(function () use ($jobCh, $resultCh) {
                while (true) {
                    $job = $jobCh->recv();
                    if ($job === null) {
                        // null = poison pill / fermeture
                        return;
                    }

                    $this->inFlight++;
                    $t0 = hrtime(true);

                    try {
                        $value = ($this->handler)($job);
                        $resultCh->send([
                            'ok' => true,
                            'job' => $job,
                            'value' => $value,
                            'ns' => hrtime(true) - $t0,
                        ]);
                    } catch (Throwable $e) {
                        $resultCh->send([
                            'ok' => false,
                            'job' => $job,
                            'error' => $e,
                            'ns' => hrtime(true) - $t0,
                        ]);
                    } finally {
                        $this->inFlight--;
                    }
                }
            });
        }

        // Producteur : backpressure automatique via queue bornée.
        $producer = spawn(function () use ($jobs, $jobCh) {
            foreach ($jobs as $job) {
                $jobCh->send($job); // bloque si la queue est pleine => backpressure
            }

            // Stop propre : on envoie N poison pills.
            for ($i = 0; $i < $this->concurrency; $i++) {
                $jobCh->send(null);
            }
        });

        // Collecte : on sait combien de jobs on a poussés, donc on sait combien de résultats attendre.
        $jobCount = is_countable($jobs) ? count($jobs) : null;
        if ($jobCount === null) {
            // Si $jobs n'est pas countable, tu peux compter côté producteur (et envoyer le total sur un channel dédié).
            throw new RuntimeException('Jobs must be countable, or implement a counting mechanism.');
        }

        $results = [];
        for ($i = 0; $i < $jobCount; $i++) {
            $results[] = $resultCh->recv();
        }

        await($producer);
        awaitAll($workers);

        return $results;
    }

    public function inFlight(): int
    {
        return $this->inFlight;
    }
}

Ce code n’est pas « la lib parfaite », mais il force les bons réflexes : queue bornée (donc backpressure), workers fixes (donc concurrence stable), et surtout release en finally (sinon tu pleures le jour où une exception court-circuite un chemin).

Le deadlock classique : quand ton pool se mord la queue

Le deadlock qui m’a presque eu ressemble souvent à ça : tu as une queue bornée pour les jobs, une queue bornée pour les résultats, et tu fais de la collecte « à la fin ». Sur le papier ça va. En vrai, ça peut se figer si tes workers essaient d’envoyer un résultat dans un channel plein, pendant que le producteur est lui-même bloqué en train d’envoyer des jobs parce que la queue de jobs est pleine, et que personne ne draine les résultats à ce moment-là.

Ça arrive surtout quand tu ajoutes un peu de réalisme : un handler qui produit des résultats plus vite que tu ne consommes, un log ou une instrumentation qui ralentit la collecte, ou une cancellation qui stoppe ton consumer « proprement »… sauf que les workers, eux, continuent d’essayer d’écrire leur dernier message. Et là, tu as une attente circulaire : personne n’avance, mais tout est « vivant ».

Le fix n’est pas mystique. Soit tu draines les résultats en continu pendant la production (au lieu d’attendre « après »), soit tu garantis que resultCh ne peut pas saturer (buffer plus grand, consumer dédié, ou résultat écrit ailleurs). Dans un vrai pool, j’aime bien un collector dédié qui tourne en parallèle, parce que ça casse naturellement ce genre de cycles.

Timeouts, cancellation, exceptions : si tu ne les traites pas, ton pool est un piège

Le pool « marche » tant que toutes les tâches finissent vite et bien. C’est justement pour ça que les gens se font avoir. Le jour où une requête HTTP pend 45 secondes, ou où un appel externe se met à timeouter en cascade, ton pool devient un entonnoir. Tu n’as plus de slots disponibles, tu accumules dans la queue, puis tu bloques le producteur, puis tu bloques tout ce qui attend la fin.

Un timeout doit être une décision, pas un accident. Et la cancellation doit libérer les ressources. En pratique, ça veut dire : chaque job a une deadline, et si ça dépasse, tu annules la tâche, tu libères le slot (quoi qu’il arrive) et tu remontes une erreur claire. Surtout, tu ne laisses pas des tâches « zombies » qui continuent à tourner en arrière-plan pendant que ton code fait comme si elles étaient mortes.

<?php

// Pseudo-code : adapte les fonctions (timeout/withTimeout, cancel, etc.).

$pool = new ProcessPool(
    concurrency: 8,
    queueSize: 64,
    handler: function (array $job) {
        // Exemple : IO (HTTP), ou CPU (parsing), peu importe.
        return withTimeout(2.5, function () use ($job) {
            return fetchAndParse($job['url']);
        });
    }
);

$results = $pool->run($jobs);

foreach ($results as $r) {
    if ($r['ok']) {
        store($r['value']);
        continue;
    }

    $e = $r['error'];
    // Ici tu peux faire du « partial success » : on garde ce qui a marché,
    // on log ce qui a planté, on retente éventuellement.
    error_log('job failed: ' . $e->getMessage());
}

Je suis volontairement pro « résultats partiels ». En prod, « tout ou rien » est souvent une posture qui te force à relancer 10 000 jobs pour 12 erreurs temporaires. Le pool te permet justement de collecter proprement ce qui a réussi, et d’isoler les cas foireux.

Backpressure : la différence entre « stable » et « ça a l’air stable »

La backpressure, c’est le moment où tu acceptes que le producteur doit ralentir. Beaucoup de pools « maison » n’en ont pas : on push des jobs dans un tableau, on spawn au fur et à mesure, et on se dit « au pire ça attend ». Sauf que non, au pire ça bufferise. Et le buffer, en PHP, ça veut dire mémoire, GC, latence, et un process qui se fait tuer au mauvais moment.

Une queue bornée, c’est un garde-fou. Ça met un plafond à ton backlog. Et ça force la question qui compte : « si ça produit plus vite que ça consomme, qu’est-ce qu’on fait ? ». La réponse saine est rarement « on empile ». C’est plutôt « on attend », « on shed », « on priorise » ou « on met en file externe ».

Instrumentation : ce que je mesure pour arrêter de me raconter des histoires

Tu n’as pas besoin d’un APM pour rendre un pool observable. Il te faut juste quelques signaux. Le plus basique, c’est le nombre de tâches in-flight. Si tu es constamment collé à la limite, tu es saturé, point. Ensuite, la latence par job (p50/p95 si tu peux), parce que c’est elle qui te dit si tu as un problème d’IO, de CPU, ou de service externe qui rame.

Je log aussi les « slow jobs » avec leur payload minimal (pas le JSON complet, juste l’identifiant et la durée), et je garde un compteur d’erreurs par type. Ça paraît trivial, mais c’est exactement ce qui te permet de distinguer « pool mal dimensionné » de « dépendance externe en feu ». Et ça t’évite de tuner la concurrence à l’aveugle jusqu’à ce que ça casse ailleurs.

La friction dev réelle : stubs IDE, API bas niveau, wrapper “safe” pour ton équipe

Le point qui revient dans les threads r/PHP sur TrueAsync 0.6, c’est la friction quotidienne : sans stubs, ton IDE devient myope, tu te bats avec des types implicites, tu perds du temps à deviner les signatures. Et quand l’API est bas niveau, tu es tenté de bricoler des patterns un peu au hasard, parce que « ça marche »… jusqu’au jour où tu tombes sur le coin sombre (deadlock, cancellation partielle, channel jamais drainé).

Ma règle : une seule personne (ou un petit groupe) « possède » les primitives async, et expose une surface stable. Un ProcessPool comme ci-dessus, ou un mapConcurrent() maison, avec une convention claire sur les timeouts, une gestion d’erreurs cohérente, et des métriques par défaut. Et oui, ça vaut le coup de commit des stubs dans le repo ou de référencer un paquet de stubs quand il existe, juste pour arrêter de coder en aveugle.

Quand ne pas utiliser un process pool TrueAsync (et quoi choisir à la place)

Un pool async dans un process PHP, c’est top pour du batch, du CLI, du traitement de flux, des import/export, des crawls, des tâches de maintenance, ou même certains workers applicatifs. Mais ce n’est pas une excuse pour tout faire « en parallèle » dans une requête web, surtout si tu ne maîtrises pas ton temps de réponse et ton budget CPU. Le pool te donne une arme. Dans un contexte HTTP classique, tu peux très vite te retrouver à dégrader tout le monde pour accélérer un endpoint.

Si ton besoin principal est « exécuter des jobs en arrière-plan », le bon outil reste souvent une queue et des workers séparés (même basiques). Si ton besoin est « tirer du CPU », pense aussi à isoler ça dans des process dédiés ou un service, parce que PHP n’est pas magiquement devenu un moteur de calcul. TrueAsync aide, mais il ne change pas les lois de la physique.

Conclusion : un pool “safe” vaut plus que 20 hacks async

TrueAsync 0.6 te permet de faire des choses franchement agréables en PHP. Mais si tu veux éviter l’effet « ça marche, puis ça se bloque », tu dois traiter ton pool comme un composant de prod : backpressure, timeouts, cancellation, collecte qui ne bloque pas, instrumentation minimale. Le reste, c’est du confort.

Si tu veux aller plus loin, l’étape suivante intéressante, c’est de formaliser une API d’équipe du style mapConcurrent() avec des options qui ont du sens (concurrency, queue size, timeout, retry), et de la rendre facile à utiliser sans que chacun réinvente sa propre version bancale dans un coin.

Sources

Cet article vous a plu ?

Commentaires

Laisser un commentaire

Entre 10 et 2000 caractères

Les commentaires sont modérés avant publication.

Aucun commentaire pour le moment.

Soyez le premier à donner votre avis !