Worker pool en Go : limiter les goroutines et annuler en toute sécurité
1. Aperçu
Les goroutines en Go sont peu coûteuses, donc appeler go pour chaque tâche est courant. Quand le nombre de tâches atteint des dizaines de milliers et que chaque tâche appelle une base de données ou HTTP, cette approche peut vider le connection pool ou dépasser le rate limit du service externe.
Cet article vous montre comment construire un worker pool, c’est-à-dire un nombre fixe de goroutines qui reçoivent du travail depuis un channel. À la fin, vous saurez comment écrire un pool basique, collecter les résultats et les erreurs, annuler avec context, simplifier avec errgroup.SetLimit, et tester que le pool limite vraiment le parallélisme.
2. Le problème : goroutines non limitées
Considérez ce code qui traite une liste de commandes :
for _, o := range orders {
go chargeOrder(o) // 50 000 commandes -> 50 000 goroutines qui appellent l'API de paiement
}
Chaque goroutine ne consomme que quelques KB de mémoire, donc le problème se trouve rarement à ce niveau. Le problème vient des ressources en amont :
- Le pool de connexions de la base de données n’a que 20–50 connexions.
- La passerelle de paiement limite le nombre de requêtes par seconde.
- Le nombre de descripteurs de fichier du processus est limité.
Un worker pool résout ce problème en séparant deux concepts. Le nombre de tâches peut être très grand, tandis que le nombre de tâches s’exécutant simultanément est limité par le nombre de workers.
3. Worker pool basique
Le modèle comprend trois parties :
- Un channel
jobsqui contient le travail à faire. - N goroutines workers, chaque worker boucle avec
for rangesur le channel. - Un
sync.WaitGrouppour attendre que tous les workers se terminent.
package main
import (
"fmt"
"sync"
"time"
)
type Order struct {
ID int
Amount float64
}
func worker(id int, orders <-chan Order, wg *sync.WaitGroup) {
defer wg.Done()
for o := range orders {
time.Sleep(100 * time.Millisecond) // simule l'appel à l'API de paiement
fmt.Printf("worker %d traite la commande %d (%.0f)\n", id, o.ID, o.Amount)
}
}
func main() {
orders := make(chan Order)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, orders, &wg)
}
start := time.Now()
for i := 1; i <= 9; i++ {
orders <- Order{ID: i, Amount: float64(i) * 10}
}
close(orders)
wg.Wait()
fmt.Println("terminé après", time.Since(start).Round(100*time.Millisecond))
}
Le code fonctionne comme suit :
orders <-chan Orderest un channel de réception du côté du worker. Le compilateur empêchera le worker d’envoyer ou de fermer le channel accidentellement.for o := range ordersse ferme automatiquement quand le channel est fermé et vidé.close(orders)est le signal “fin du travail”. Si vous oubliez cette ligne, les workers attendent indéfiniment etwg.Wait()provoque un deadlock.wg.Add(1)doit être appelé avantgo worker(...), pas à l’intérieur de la goroutine.
Résultat de l’exécution (l’ordre des lignes peut varier) :
worker 3 traite la commande 1 (10)
worker 1 traite la commande 2 (20)
worker 2 traite la commande 3 (30)
worker 2 traite la commande 4 (40)
...
terminé après 300ms
9 commandes divisées par 3 workers, chaque commande prenant 100ms, donc le temps total est d’environ 300ms au lieu de 900ms si exécuté séquentiellement.
Note : À partir de Go 1.25, vous pouvez écrire
wg.Go(func() { worker(i, orders) }). Cette méthode appelle automatiquementAddetDone, évitant d’oublier l’un des deux.
4. Collecter les résultats et les erreurs
En pratique, vous avez souvent besoin de savoir quelle commande a réussi et laquelle a échoué. On ajoute un channel results pour cela :
package main
import (
"errors"
"fmt"
"sync"
)
type Order struct {
ID int
Amount float64
}
type Result struct {
OrderID int
Fee float64
Err error
}
func chargeOrder(o Order) (float64, error) {
if o.Amount <= 0 {
return 0, errors.New("montant invalide")
}
return o.Amount * 0.02, nil // frais 2%
}
func main() {
orders := []Order{{1, 100}, {2, 0}, {3, 250}, {4, 80}}
jobs := make(chan Order)
results := make(chan Result)
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
for o := range jobs {
fee, err := chargeOrder(o)
results <- Result{OrderID: o.ID, Fee: fee, Err: err}
}
}()
}
go func() {
for _, o := range orders {
jobs <- o
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
for r := range results {
if r.Err != nil {
fmt.Printf("commande %d erreur: %v\n", r.OrderID, r.Err)
continue
}
fmt.Printf("commande %d frais %.2f\n", r.OrderID, r.Fee)
}
}
Le résultat ressemble à :
commande 1 frais 2.00
commande 3 frais 5.00
commande 2 erreur: montant invalide
commande 4 frais 1.60
Il y a trois points à noter :
- L’envoi des jobs s’exécute dans une goroutine distincte. Si
mainenvoie les jobs et lit les résultats sur la même goroutine, les workers seront bloqués àresults <-tandis quemainest bloqué àjobs <-. Cela provoque un deadlock. - Une troisième goroutine attend
wg.Wait()avant de fermerresults. Seulement quand tous les workers ont terminé, on peut être sûr que personne n’envoie plus àresults. Règle générale : seul l’émetteur ferme un channel. - Les erreurs sont encapsulées dans
Resultplutôt que d’arrêter tout le pool. L’erreur d’une commande ne doit pas bloquer les autres.
L’ordre des résultats ne correspond pas à l’ordre des entrées. Si vous avez besoin de préserver l’ordre, transportez l’index avec la tâche, ou utilisez la méthode de la section 6.
5. Annuler le pool avec context
Quand une requête HTTP est annulée ou expire, le pool doit s’arrêter au lieu de continuer à appeler l’API de paiement. Vous le faites en laissant le worker écouter ctx.Done() :
func worker(ctx context.Context, jobs <-chan Order, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case o, ok := <-jobs:
if !ok {
return
}
fee, err := chargeOrder(o)
select {
case results <- Result{OrderID: o.ID, Fee: fee, Err: err}:
case <-ctx.Done():
return
}
}
}
}
Le côté producteur a également besoin d’un select similaire :
go func() {
defer close(jobs)
for _, o := range orders {
select {
case jobs <- o:
case <-ctx.Done():
return
}
}
}()
Et créer un context avec timeout à l’endroit de l’appel :
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Pourquoi select dans les deux directions d’envoi : si un worker ne vérifie ctx que lors de la réception d’une tâche, il peut rester bloqué indéfiniment à results <- si le lecteur a quitté. C’est une source très commune de fuite de goroutines.
Note :
selectaide le worker à quitter entre les tâches. SichargeOrdereffectue un appel HTTP, vous devez passerctxà l’intérieur (http.NewRequestWithContext) pour annuler la requête en cours.
6. Simplifier avec errgroup.SetLimit
Écrire manuellement les channels, WaitGroup et context est sujet aux erreurs. Le package errgroup a SetLimit, qui permet de créer un pool limité en quelques lignes. Tout d’abord, ajoutez la dépendance :
go get golang.org/x/sync/errgroup
La fonction générique ci-dessous traite une slice avec un maximum de limit goroutines, préserve l’ordre des résultats et s’arrête à la première erreur :
package pool
import (
"context"
"golang.org/x/sync/errgroup"
)
func Process[T, R any](
ctx context.Context,
limit int,
items []T,
fn func(context.Context, T) (R, error),
) ([]R, error) {
results := make([]R, len(items))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for i, item := range items {
g.Go(func() error {
r, err := fn(ctx, item)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
Le code fonctionne comme suit :
g.SetLimit(limit)rendg.Gobloquant quand il y a déjàlimitgoroutines en cours. En termes d’efficacité, c’est équivalent à un worker pool.errgroup.WithContextretourne unctxqui sera annulé dès qu’une fonction retourne une erreur. Les autres tâches utilisantctxrecevront le signal d’arrêt.results[i] = rest sûr lors d’exécutions concurrentes car chaque goroutine écrit à un index distincte. Pas besoin de mutex dans ce cas.- À partir de Go 1.22, les variables de boucle
ietitemsont créées à nouveau à chaque itération, donc la closure n’utilise pas la même variable que dans les versions antérieures.
Comment l’appeler :
fees, err := pool.Process(ctx, 5, orders, func(ctx context.Context, o Order) (float64, error) {
return paymentService.Charge(ctx, o)
})
Différence avec un pool écrit manuellement : errgroup crée une nouvelle goroutine pour chaque item au lieu de réutiliser N workers. Pour la plupart des applications, cette différence est négligeable. Si vous avez besoin que les workers maintiennent un état, par exemple chaque worker ayant une connexion, utilisez le pool écrit manuellement des sections 3–5.
7. Tester le worker pool
Vous devez tester deux comportements principaux. D’abord, le pool ne dépasse jamais la limit. Deuxièmement, les erreurs sont retournées correctement. Pour mesurer le niveau de parallélisme, on utilise atomic.Int32 :
package pool
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestProcess_LimitsConcurrencyAndKeepsOrder(t *testing.T) {
var running, maxSeen atomic.Int32
items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
got, err := Process(context.Background(), 3, items, func(_ context.Context, n int) (int, error) {
cur := running.Add(1)
defer running.Add(-1)
for {
m := maxSeen.Load()
if cur <= m || maxSeen.CompareAndSwap(m, cur) {
break
}
}
time.Sleep(10 * time.Millisecond)
return n * 2, nil
})
if err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if maxSeen.Load() > 3 {
t.Errorf("concurrence %d, limite 3", maxSeen.Load())
}
want := []int{2, 4, 6, 8, 10, 12, 14, 16, 18, 20}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %d, want %d", i, got[i], want[i])
}
}
}
func TestProcess_ReturnsFirstError(t *testing.T) {
errDeclined := errors.New("carte refusée")
_, err := Process(context.Background(), 2, []int{1, 2, 3}, func(_ context.Context, n int) (int, error) {
if n == 2 {
return 0, errDeclined
}
return n, nil
})
if !errors.Is(err, errDeclined) {
t.Fatalf("attendu errDeclined, reçu %v", err)
}
}
Exécutez le test avec le détecteur de races :
go test -race ./...
ok example.com/shop/pool 0.061s
La boucle CompareAndSwap met à jour maxSeen sans verrou. Le flag -race est obligatoire pour le code concurrent, car il détecte les accès de données partagées que l’œil nu peut facilement manquer.
8. Erreurs courantes et quand ne pas utiliser
Erreurs courantes :
- Oublier
close(jobs): les workers ne terminent jamais et le programme signalefatal error: all goroutines are asleep - deadlock!. - Fermer le channel du côté du récepteur, ou deux fois : provoque
panic: close of closed channel. - Appeler
wg.Addà l’intérieur de la goroutine :Waitpeut retourner avant que la goroutine n’ait le temps d’appelerAdd. - Envoyer des jobs et lire les résultats sur la même goroutine avec un channel non bufférisé : provoque un deadlock comme analysé à la section 4.
- Ne pas faire
selectsurctx.Done()lors de l’envoi des résultats : entraîne une fuite de goroutines si le lecteur a quitté plus tôt. - Choisir arbitrairement le nombre de workers : pour les tâches CPU-bound, utilisez
runtime.NumCPU(). Pour les tâches I/O-bound, basez-vous sur la limite des ressources en amont, par exemple la taille du pool de connexions.
Quand ne pas utiliser un worker pool :
- Le nombre de tâches est petit et connu à l’avance, par exemple appeler 3 services en parallèle. Dans ce cas,
errgroupsansSetLimitest suffisant. - Les tâches sont extrêmement courtes, seulement quelques microsecondes. Le coût de passage par le channel peut être plus grand que le travail lui-même, donc traiter séquentiellement ou par lots sera plus efficace.
- Vous devez limiter selon le débit (requêtes/seconde) et non le nombre simultané. Dans ce cas, vous avez besoin d’un rate limiter, par exemple
golang.org/x/time/rate. Un worker pool ne peut pas remplacer un rate limiter.
9. Conclusion
Un worker pool vous aide à contrôler le niveau de parallélisme en Go : le nombre de tâches peut être très grand mais les ressources en amont sont toujours protégées. L’article a couvert les étapes suivantes :
- Pool basique avec channel et
sync.WaitGroup. - Collecter les résultats et erreurs via le channel
results, fermer le channel correctement. - Annulation sécurisée avec
contextdans les deux directions d’envoi. - Simplification avec
errgroup.SetLimiten utilisant une fonctionProcessgénérique. - Test de la limite de concurrence et des erreurs avec
go test -race.
Dans la plupart des cas, errgroup.SetLimit est le choix le plus simple et le moins sujet aux erreurs. Vous ne devriez écrire votre propre pool que si les workers doivent maintenir un état ou si vous avez besoin de contrôler finement le flux des tâches et des résultats.