Maîtriser la concurrence dans Go : errgroup, pools de workers et backpressure

Limitez le travail en cours, ne vous contentez pas de l'annuler.

Sommaire

La dissémination illimitée est le mode de défaillance par défaut de la concurrence en Go : chaque instruction go ajoute une tâche en vol, sans plafond ni responsable. Cet article porte sur la mise en place de ces deux éléments.

L’annulation et la limitation sont des mécanismes de contrôle distincts. L’annulation détermine quand le travail s’arrête ; la limitation détermine combien de tâches existent simultanément. Un service peut annuler parfaitement ses tâches et tout de même s’effondrer parce que dix mille goroutines ont chacune décidé, indépendamment, que c’était le bon moment pour appeler la base de données.

entonnoir en verre régulant un flux de particules lumineuses à travers une porte régulée

Les pages autour de celle-ci couvrent l’autre moitié du problème. Go context.Context : la bonne approche traite le context comme le plan de contrôle pour arrêter le travail ; ici, le sujet est le plan de capacité — errgroup.SetLimit, les canaux à sémaphore, les pools de workers et les files d’attente limitées — plus la forme de fuite qui survit à errgroup.Wait même lorsque tous les chemins d’annulation semblent correctement câblés.

Cinq primitives, un tableau de décision

Primitive Résultats partiels Échec rapide Backpressure Travail dynamique Idéal pour
sync.WaitGroup + errors.Join oui non non non les lotissements d’attente totale où chaque résultat compte
errgroup.WithContext non oui non non la dissémination où la première erreur termine la requête
errgroup.SetLimit(n) non oui oui (bloque Go) non la dissémination à échec rapide limitée
Canal à sémaphore oui manuel oui (bloque l’acquisition) oui le contrôle d’admission personnalisé
Pool de workers oui manuel oui (la file se remplit) oui les flux stables, les workers de longue durée

Deux questions permettent de choisir la ligne. Première : si une tâche échoue, souhaitez-vous encore les résultats des autres ? Si oui, la sémantique de groupe à échec rapide supprime le travail dont vous aviez besoin. Deuxième : le travail arrive-t-il de manière continue, ou s’agit-il d’un lot fixe que vous pouvez compter avant de le lancer ? Les groupes gèrent les lots ; les pools et les files d’attente gèrent les flux.

errors.Join est ce qui rend la ligne simple WaitGroup viable — il collecte l’erreur de chaque worker au lieu de ne conserver que la première, ce qui s’accorde avec les règles de traduction des frontières dans Architecture de la gestion des erreurs en Go.

La fuite qui survit à errgroup.Wait

Le mode de défaillance le plus connu d’errgroup n’a rien à voir avec la limitation. C’est un vide d’annulation, et il se cache derrière du code qui semble correct :

g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // non tamponné

go func() { // producteur — personne ne possède cette goroutine
    for i := 0; i < 100; i++ {
        results <- i
    }
    close(results)
}()

g.Go(func() error { // consommateur — membre du groupe
    for r := range results {
        if r == 5 {
            return fmt.Errorf("save failed")
        }
    }
    return nil
})

err := g.Wait() // retourne à l'erreur du consommateur

Lorsque le consommateur retourne, gctx est annulé et Wait retourne. Le producteur est bloqué sur results <- i — et l’annulation du contexte ne débloque pas un envoi sur canal. Personne ne possède cette goroutine, donc elle reste bloquée pour la durée de vie du processus. Exécutez cette forme dans une boucle et la fuite est exactement linéaire :

Variante Goroutines actives après 500 itérations
Envoi sans un bras ctx.Done 501 (+500 fuites, une par itération)
Envoi enveloppé dans un select avec <-gctx.Done() 502 (+1 de base)
// la correction : chaque opération de canal dans une tâche annulable
// reçoit un bras Done
select {
case results <- i:
case <-gctx.Done():
    return
}

Le même vide existe côté réception. La règle est mécanique : chaque opération de canal dans du code qui peut être annulé reçoit un bras <-ctx.Done(), et chaque goroutine a un responsable qui attend dessus ou l’annule. goleak dans le CI détecte les cas que la revue manque — il s’intègre dans la même porte de contrôle qualité automatisée que les outils de Linters Go : Outils essentiels pour la qualité du code.

Deux formes liées méritent d’être nommées. Si le producteur est un membre du groupe, le même code produit non pas une fuite mais un deadlock — Wait bloque à jamais en attendant l’envoi bloqué, ce qui échoue au moins bruyamment. Et un producteur qui envoie avec select mais reçoit sur un canal dont le consommateur a quitté fuit de la même manière ; les bras Done appartiennent aux deux côtés.

errgroup.SetLimit : dissémination à échec rapide limitée

SetLimit transforme un errgroup en groupe avec contrôle d’admission. Chaque appel Go au-delà de la limite bloque jusqu’à ce qu’un créneau se libère :

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
    g.Go(func() error {
        return process(ctx, item)
    })
}
err := g.Wait()

Trois sémantiques comptent en production. Go bloque, donc la boucle ci-dessus applique un backpressure au producteur — c’est généralement ce que vous voulez, mais cela signifie que la goroutine de l’appelant peut stagner, donc un gestionnaire de requête alimentant un grand lot a besoin de son propre budget de délai autour de la boucle entière. SetLimit(0) bloque chaque appel Go à jamais. Et changer la limite pendant que des goroutines sont actives provoque une panique — la limite est fixée pour la durée de vie du groupe.

SetLimit hérite de la sémantique de groupe : la première erreur annule le contexte et Wait retourne la première erreur, supprimant les résultats du travail encore en vol. C’est la primitive appropriée lorsque l’échec de l’opération entière à la première erreur est correct — la récupération d’une page de données, l’appel d’un ensemble de vérifications d’état indépendantes, la dissémination d’une requête vers des shards.

Canaux à sémaphore : le backpressure comme fonctionnalité

Un canal tamponné de capacité n est un sémaphore sans dépendances :

sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    sem <- struct{}{} // bloque lorsque 8 tâches sont en vol
    go func() {
        defer wg.Done()
        defer func() { <-sem }()
        _ = process(ctx, item)
    }()
}
wg.Wait()

L’acquisition a lieu avant l’instruction go, donc les goroutines ne sont créées que lorsqu’un créneau existe. La taille du tampon est le contrat : c’est simultanément le plafond de concurrence et la file d’attente des admissions en attente.

La primitive mérite sa place lorsque vous avez besoin d’un contrôle d’admission que les groupes n’expriment pas — acquisition consciente du contexte, pools par aval, ou résultats partiels en cas d’échec :

select {
case sem <- struct{}{}: // créneau acquis
case <-ctx.Done():      // l'appelant a renoncé avant d'entrer
    return ctx.Err()
}

Mesuré sur la même charge de travail, le canal à sémaphore et SetLimit(8) finissent à moins de 1,5 % l’un de l’autre — le choix entre eux est sémantique, pas performance.

Pools de workers : pour les flux, pas les lots

Un pool fixe de workers de longue durée sépare l’admission (la file) de l’exécution (les workers) :

func pool(ctx context.Context, workers int) chan<- func() {
    jobs := make(chan func())
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case f, ok := <-jobs:
                    if !ok {
                        return
                    }
                    f()
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    return jobs
}

Les pools conviennent au travail qui arrive en continu — consommateurs de files, boucles de sondage, workers de requêtes — là où un groupe par lot serait créé et détruit indéfiniment. Depuis Go 1.25, sync.WaitGroup.Go supprime le verbiage de la paire Add/Done pour la forme courante. Le contrat d’arrêt est la partie à bien faire : fermer jobs termine les workers après que la file se vide ; annuler ctx abandonne le travail en file. Les deux sont légitimes, mais seul l’un peut être le comportement documenté. Go 1.27 continue dans la même direction — méthodes génériques, nouveaux modernisateurs go fix, et un httptest.NewTestServer en mémoire — tout couvert dans le Guide de mise à niveau Go 1.27.

Files d’attente limitées : bloquer ou supprimer

Un canal tamponné est aussi une file avec un plafond. Une fois le tampon rempli, le producteur bloque — et ce moment est la décision de conception. Bloquer propage la pression en amont à celui qui alimente la file ; supprimer évacue la charge au coût du travail perdu. Un pipeline de surveillance peut supprimer ; un pipeline de commandes doit bloquer ou déborder vers un stockage durable.

select {
case queue <- event: // admis
default:            // file pleine : la politique de suppression est ici
    dropped.Add(1)
}

Quelle que soit la politique, rendez-la explicite et mesurée. Un bras default silencieux est l’endroit où les événements vont disparaître.

Ce que la limitation coûte — mesuré

La même charge de travail, 10 000 tâches de 5 ms d’entrée-sortie simulée chacune, Go 1.27.1 :

Stratégie Pic de goroutines Temps réel
go illimité par tâche ~10 000 lancées 18 ms
errgroup.SetLimit(8) 8 6,9 s
Canal à sémaphore (cap 8) 8 6,6 s

L’exécution illimitée gagne sur le temps réel parce que rien dans l’expérience ne pousse en retour — dix mille goroutines de minuteur dorment simplement en parallèle. C’est exactement le piège. Le coût de la dissémination illimitée n’est jamais le CPU dans votre propre processus ; c’est dix mille connexions simultanées, un aval qui commence à expirer sous la rafale, et un graphe mémoire qui suit la profondeur de file de celui que vous appelez. Sur une vraie dépendance, l’exécution illimitée ne finit pas en 18 ms — elle expire. Les exécutions limitées paient 6,6 secondes parce que 10 000 tâches ÷ 8 créneaux × 5 ms est 6,25 s de sérialisation inévitable, et le plafond est le point : il transforme une rafale incontrôlée en un drainage prévisible de 6,25 secondes.

Une affinement compte lorsque le plafond est plus élevé que quelques créneaux : maintenez le plafond à ou en dessous de ce que l’aval peut soutenir en concurrence, et dérivez-le de cette limite (taille du pool de connexions, limite de débit, capacité des workers) plutôt que d’une intuition sur le parallélisme “raisonnable”.

Mesurer et tester le code limité

Trois signaux couvrent la majeure partie. Les tendances du nombre de goroutines (/sched/goroutines:goroutines exporté via le collecteur Prometheus Go) détectent les fuites comme une pente. La latence du planificateur (/sched/latencies:seconds) détecte la saturation — des goroutines exécutables en attente de temps CPU est une pression réelle indépendamment du nombre. Un delta pprof de deux dumps de goroutines identifie où vivent les goroutines bloquées.

Pour les tests, les workers limités sont le cas pour lequel Tester le code Go concurrent avec synctest a été conçu : le temps factice fait qu’un drainage complet de 10 000 tâches s’exécute en millisecondes, synctest.Wait remplace le sommeil et l’espoir pour la quiescence, et une bulle échoue rapidement lorsqu’un worker reste durablement bloqué — la repro de fuite ci-dessus meurt dans une bulle de test au lieu de la production.

À l’échelle du service, la même question de limitation réapparaît entre les services, où le sémaphore devient une limite de débit et le disjoncteur protège l’aval — Motif du disjoncteur en Go couvre cette couche, et Microservices Go pour l’orchestration IA/ML montre les formes adossées à des files que la couche d’orchestration prend.

Choix

flowchart TD A[Travail à exécuter en parallèle] --> B[Lot fixe ou flux continu ?] B -- flux continu --> C[Pool de workers ou file limitée] B -- lot fixe --> D[Résultats partiels nécessaires
à la première erreur ?] D -- oui --> E[WaitGroup + errors.Join
ou canal à sémaphore] D -- non --> F[errgroup.WithContext] F --> G[Besoin d'un plafond de concurrence ?] G -- oui --> H[errgroup.SetLimit] G -- non --> I[groupe sans limite
uniquement si le lot est prouvablement petit] C --> J[File pleine : bloquer ou supprimer ?] J -- bloquer --> K[Backpressure en amont] J -- supprimer --> L[Évacuer la charge, compter les suppressions]

Le tableau et le diagramme se compressent en une seule habitude : avant d’écrire go, nommez le plafond et le responsable. Le plafond est SetLimit, un sémaphore, ou une file limitée ; le responsable est un Wait, un bras ctx.Done sur chaque opération de canal, ou les deux. Tout site de création de goroutine qui peut répondre à ces deux noms d’un coup d’œil est celui qui ne rappellera personne à 3 h du matin.

Liens utiles

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.