sync.WaitGroup, Mutex et errgroup : orchestrer les goroutines
1. Aperçu
Lancer une goroutine en Go est très facile, il suffit d’ajouter le mot-clé go. La partie la plus difficile est de savoir quand les goroutines ont terminé, de protéger les données partagées et de gérer les erreurs quand une goroutine échoue.
Cet article couvre trois outils pour ces trois tâches : sync.WaitGroup, sync.Mutex et errgroup.Group. Après la lecture, vous saurez quel outil choisir pour chaque situation, comment écrire des tests exécutés avec -race et quelles erreurs courantes éviter.
Les exemples utilisent Go 1.22 ou supérieur. À partir de cette version, chaque itération de boucle for crée une nouvelle variable pour chaque itération, vous n’avez donc plus besoin d’écrire id := id.
2. Problème d’exemple
Supposez que vous avez une liste de numéros de commande (Order). Chaque commande doit appeler un service lent prenant environ 100 ms. Si vous appelez 10 commandes séquentiellement, cela prend environ 1 seconde. Si vous appelez en parallèle, cela ne prend que environ 100 ms.
Voici une fonction qui simule l’appel au service :
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
Si vous écrivez simplement go fetchOrder(id) dans la boucle, la fonction main se terminera avant que les goroutines ne se terminent. Le programme se ferme et le résultat est perdu. La section suivante traite ce problème.
3. sync.WaitGroup : attendre un groupe de goroutines
3.1. Utilisation basique
WaitGroup est un compteur. Vous augmentez le compteur avec Add, le diminuez avec Done, et Wait se bloque jusqu’à ce que le compteur revienne à 0.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
Le code ci-dessus fonctionne comme suit :
wg.Add(1)est appelé avant de lancer la goroutine, dans la goroutine parente.defer wg.Done()garantit que le compteur est toujours décrémenté, même si la goroutine se termine tôt ou panique.wg.Wait()bloquemainjusqu’à ce que les trois goroutines appellentDone.
Le résultat peut être comme suit. L’ordre des lignes fetched change entre les exécutions :
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go en Go 1.25
À partir de Go 1.25, WaitGroup a une méthode Go supplémentaire. Cette méthode combine Add, go et Done en un seul appel :
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
Écrire ainsi est plus court et évite deux erreurs courantes : oublier d’appeler Done, ou appeler Add à l’intérieur d’une goroutine.
3.3. Collecter les résultats sans verrous
Vous avez généralement besoin de conserver les résultats, pas seulement de les imprimer. Une approche sûre est de créer un slice avec une longueur prédéfinie, puis de laisser chaque goroutine écrire à un index unique :
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
Aucune goroutine n’écrit sur le même élément, il n’y a donc pas de data race ici. wg.Wait() crée également une relation happens-before. Par conséquent, après Wait, la goroutine parente peut lire complètement les données écrites par les goroutines enfants.
Remarque : Ne pas appeler
wg.Add(1)à l’intérieur d’une goroutine. Si vous le faites,Wait()peut s’exécuter avantAddet revenir alors que le travail n’est pas terminé.
4. sync.Mutex : protéger les données partagées
4.1. À quoi ressemble une data race
Supposons maintenant que vous deviez accumuler les revenus par client (Customer), et que plusieurs goroutines mettent à jour la même map :
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
Ce programme peut s’arrêter brutalement avec l’erreur suivante :
fatal error: concurrent map writes
L’utilisation d’une simple variable int est encore plus dangereuse. Le programme ne s’arrête pas brutalement mais produit silencieusement un résultat incorrect, par exemple 9730 au lieu de 10000. Pour détecter ce type d’erreur, exécutez avec le race detector :
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Correction avec Mutex
sync.Mutex garantit qu’à chaque moment, une seule goroutine exécute le code entre Lock et Unlock. Une approche courante est d’empaqueter le mutex avec les données qu’il protège dans la même structure :
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
Quelques points à noter :
- Tous les accès à
total, y compris les lectures, doivent passer par le verrou. defer r.mu.Unlock()aide à ce que le verrou soit toujours libéré, même si la fonction a plusieurs branchesreturn.- Les méthodes utilisent un receiver pointeur (
*RevenueTracker). Si vous utilisez un receiver valeur, chaque appel copiera le mutex, et ce verrou ne protégerait plus rien.
Quand les lectures sont beaucoup plus nombreuses que les écritures, vous pouvez utiliser sync.RWMutex. Les méthodes de lecture appellent RLock/RUnlock, les méthodes d’écriture appellent Lock/Unlock. De nombreuses goroutines peuvent lire à la fois, mais quand on écrit, seule une goroutine détient le verrou.
Remarque : Ne copiez pas une structure contenant un
Mutexaprès l’avoir utilisée. La commandego vetavertira avec le messagecopylocks. De plus, Go n’a pas de verrous réentrants : si une goroutine appelleLock()deux fois sur le même mutex, elle se retrouvera deadlockée.
4.3. Quand vous n’avez pas besoin de Mutex
Pour un simple compteur, sync/atomic est plus concis et plus rapide :
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
Quand les données doivent passer d’une goroutine à une autre sous forme de flux, les canaux sont généralement plus appropriés. Mutex convient mieux quand vous avez un état partagé, comme une map ou une structure, et que plusieurs goroutines doivent y lire ou écrire.
5. errgroup : WaitGroup avec gestion d’erreurs
WaitGroup ne sait rien des erreurs. Si fetchOrder peut retourner une error, vous devez vous-même regrouper les erreurs avec des mutexes ou des canaux et annuler les goroutines restantes. Le paquet errgroup fait déjà tout cela.
5.1. Ajouter la dépendance
errgroup se trouve dans le module golang.org/x/sync, il ne fait pas partie de la bibliothèque standard :
go get golang.org/x/sync
5.2. Regrouper les erreurs et annuler via context
La première étape est de réécrire fetchOrder pour que la fonction accepte un context.Context et puisse retourner une erreur :
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
Le code ci-dessus fonctionne comme suit :
errgroup.WithContext(ctx)crée un groupe et un contexte enfant. Dès qu’une goroutine retourne la première erreur, le contexte enfant est annulé.g.SetLimit(3)permet à un maximum de 3 goroutines de s’exécuter en même temps. L’appelg.Gosuivant se bloquera jusqu’à ce qu’il y ait de la place. Cela vous permet de ne pas surcharger le service backend lorsque la liste contient des milliers de commandes.g.Goaccepte une fonction de typefunc() error, et vous n’avez pas besoin d’appelerAdd/Done.g.Wait()attend que tous les goroutines se terminent puis retourne la première erreur. Les erreurs qui surviennent après sont ignorées.fetchOrderécoutectx.Done(), donc les goroutines restantes s’arrêtent tôt au lieu de s’exécuter pour les 100ms complètes.
Appeler la fonction avec un numéro de commande invalide :
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
Si vous avez besoin d’essayer d’exécuter sans vous bloquer une fois la limite atteinte, utilisez g.TryGo(f). Cette fonction retourne false au lieu de se bloquer.
Remarque : L’annulation du contexte n’a d’effet que si le code à l’intérieur de la goroutine vérifie activement
ctx. SifetchOrderutilisetime.Sleepsans écouter le contexte, la goroutine s’exécutera encore pour toute la durée même si le groupe a échoué.
6. Écrire des tests avec -race
Go n’a pas JUnit ou AssertJ. Vous utilisez le paquet testing de la bibliothèque standard. Le plus important est d’exécuter toujours les tests avec l’indicateur -race, car de nombreuses erreurs de concurrence ne s’affichent que lorsque le race detector est activé.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Exécuter les tests :
go test -race ./...
ok example.com/shop 0.412s
Essayez de supprimer les deux lignes Lock/Unlock dans Add puis réexécutez. Le test échouera avec WARNING: DATA RACE ou s’arrêtera brutalement avec concurrent map writes. C’est un moyen rapide de vérifier que le mutex est vraiment nécessaire.
Le deuxième test confirme également que l’ordre des résultats est correctement préservé : orders[i] correspond à ids[i], même si les goroutines se terminent dans n’importe quel ordre.
7. Erreurs courantes et comment choisir un outil
Les erreurs les plus courantes sont :
- Appeler
Addà l’intérieur d’une goroutine.Waitpeut revenir trop tôt. AppelezAddavant l’instructiongo, ou utilisezwg.Go. - Passer
WaitGroupouMutexpar valeur. La fonction reçoit une copie, et le compteur ou le verrou original ne change pas. Passez un pointeur*sync.WaitGroup, ou utilisez une closure. - Garder le verrou pendant un appel I/O. Par exemple, faire un appel HTTP à l’intérieur de
Lock. Tous les autres goroutines doivent faire la queue, et le programme perd le bénéfice de l’exécution parallèle. Gardez le verrou uniquement dans le segment de code le plus court possible. - Utiliser
errgroup.Groupvide (var g errgroup.Group) mais s’attendre à ce que le contexte soit annulé. SeulWithContextcrée un contexte capable d’être annulé. - Ignorer la valeur de retour de
g.Wait(). L’erreur disparaît alors sans que personne ne la remarque. - Lancer un nombre illimité de goroutines pour des dizaines de milliers de requêtes. Utilisez
SetLimitou un pool de workers.
Comment choisir un outil :
| Situation | Outil |
|---|---|
| Attendre plusieurs tâches sans erreur | sync.WaitGroup |
| Plusieurs goroutines modifient une map ou une structure partagée | sync.Mutex / sync.RWMutex |
| Compteur ou drapeau simple | sync/atomic |
| Tâches peuvent échouer, besoin d’annulation précoce, limitation du nombre de goroutines | errgroup |
Remarque :
errgroupconserve uniquement la première erreur. Si vous avez besoin de connaître toutes les erreurs, par exemple pour signaler quelles commandes ont échoué, laissez chaque goroutine écrire l’erreur danserrs[i], puis combinez-les avecerrors.Join(errs...)après l’appel deWait.
8. Conclusion
sync.WaitGroup répond à la question « quand c’est fini ? ». sync.Mutex répond à la question « qui peut modifier les données en ce moment ? ». errgroup combine l’attente avec la gestion des erreurs, l’annulation via context et la limitation du nombre de goroutines s’exécutant simultanément.
Dans le code réel, lors de l’appel de plusieurs services en parallèle, errgroup.WithContext avec SetLimit est souvent un choix par défaut raisonnable. Quel que soit l’outil utilisé, exécutez toujours go test -race. C’est le moyen le moins cher de détecter les data races avant qu’elles ne se retrouvent en production.