Aller au contenu
Développement medium

sync, WaitGroup et errgroup en Go

16 min de lecture

Go

Lancer des goroutines est facile ; les faire coopérer sans corrompre les données qu'elles partagent demande de la synchronisation. Ce guide vous apprend à attendre un groupe de goroutines avec sync.WaitGroup, à protéger un état partagé avec sync.Mutex et sync.RWMutex, à initialiser une ressource une seule fois avec sync.Once, et à propager la première erreur d'un lot de tâches avec errgroup. En chemin, vous verrez le détecteur de races transformer un bug invisible en message clair. Public visé : vous maîtrisez déjà les goroutines et channels. Exemples testés sur Go 1.26.

  • Attendre la fin de N goroutines avec sync.WaitGroup.
  • Protéger un état partagé avec sync.Mutex et sync.RWMutex.
  • Détecter les races avec go run -race et go test -race.
  • Propager la première erreur d'un groupe avec errgroup.

Une sync.WaitGroup est un compteur qui permet à main d'attendre que toutes les goroutines lancées aient fini. La mécanique classique tient en trois appels : Add incrémente le compteur avant de lancer, Done le décrémente à la fin de chaque goroutine (typiquement en defer), et Wait bloque tant que le compteur n'est pas revenu à zéro.

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("tâche", i)
}()
}
wg.Wait() // bloque jusqu'à la fin des 3 goroutines

Depuis Go 1.25, la méthode wg.Go condense ce trio : elle fait le Add(1), lance la goroutine et appelle Done à la fin pour vous. Le code devient plus court et il n'y a plus de risque d'oublier un Add ou un Done :

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Go(func() {
fmt.Println("tâche", i)
})
}
wg.Wait()

L'ordre d'affichage n'est pas déterministe : les goroutines s'exécutent en parallèle, main ne reprend qu'après Wait. Retenez que WaitGroup répond à une seule question, « sont-elles toutes finies ? », mais ne protège en rien les données qu'elles manipulent. C'est le rôle du verrou.

Dès que deux goroutines lisent et écrivent la même variable sans coordination, vous avez une race condition (accès concurrent). Le symptôme est perfide : le programme ne plante pas, mais le résultat est faux et varie d'une exécution à l'autre. Voici cent goroutines qui incrémentent mille fois un compteur, soit 100000 attendu :

compteur := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Go(func() {
for j := 0; j < 1000; j++ {
compteur++ // accès concurrent NON protégé
}
})
}
wg.Wait()
fmt.Println("compteur =", compteur, "(attendu 100000)")

L'opération compteur++ n'est pas atomique : elle lit la valeur, l'incrémente, puis la réécrit. Quand deux goroutines lisent la même valeur avant de la réécrire, un incrément est perdu. Le total final est donc inférieur à 100000, et différent à chaque lancement :

compteur = 40147 (attendu 100000)

Ces bugs sont trop rares et trop aléatoires pour être trouvés à l'œil. Go fournit heureusement une arme dédiée : le détecteur de races, activé par le drapeau -race. Il instrumente le programme et signale tout accès concurrent non synchronisé, avec la ligne fautive et les goroutines impliquées :

Fenêtre de terminal
go run -race .
==================
WARNING: DATA RACE
Read at 0x00c0000181c8 by goroutine 10:
main.main.func1()
.../02-race/main.go:16
Previous write at 0x00c0000181c8 by goroutine 14:
main.main.func1()
.../02-race/main.go:16
...
Found 2 data race(s)
exit status 66

Un sync.Mutex (mutual exclusion, exclusion mutuelle) garantit qu'une seule goroutine à la fois entre dans la section critique. On verrouille avec Lock avant l'accès partagé et on libère avec Unlock juste après, idéalement en defer pour ne jamais oublier de relâcher le verrou, même en cas de sortie anticipée :

compteur := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Go(func() {
for j := 0; j < 1000; j++ {
mu.Lock()
compteur++ // section critique protégée
mu.Unlock()
}
})
}
wg.Wait()
fmt.Println("compteur =", compteur, "(attendu 100000)")

Cette fois, go run -race . ne signale aucune race et le résultat est exact à chaque exécution :

compteur = 100000 (attendu 100000)

Deux pièges classiques guettent le débutant. D'abord, ne copiez jamais un sync.Mutex : passez toujours la structure qui le contient par pointeur, sinon chaque copie possède son propre verrou et la protection tombe. Ensuite, pour un simple compteur, un verrou n'est pas toujours nécessaire : le paquet sync/atomic (par exemple atomic.Int64 et sa méthode Add) offre des opérations atomiques plus rapides. Le Mutex reste la bonne réponse dès que la section critique touche plusieurs variables à garder cohérentes ensemble.

Quand un état est lu bien plus souvent qu'écrit, comme un cache, un sync.Mutex sérialise inutilement les lectures. Le sync.RWMutex distingue deux verrous : RLock (lecture) autorise plusieurs lecteurs simultanés, tandis que Lock (écriture) reste exclusif et bloque lecteurs comme écrivains le temps de la modification.

type cache struct {
mu sync.RWMutex
m map[string]int
}
func (c *cache) lire(cle string) (int, bool) {
c.mu.RLock() // verrou partagé : lectures concurrentes
defer c.mu.RUnlock()
v, ok := c.m[cle]
return v, ok
}
func (c *cache) ecrire(cle string, val int) {
c.mu.Lock() // verrou exclusif
defer c.mu.Unlock()
c.m[cle] = val
}

Cinquante lecteurs et cinq écrivains lancés en parallèle sur ce cache passent le détecteur de races sans un avertissement. Le RWMutex n'a d'intérêt que si les lectures dominent nettement : sous forte charge d'écriture, sa comptabilité supplémentaire le rend plus lent qu'un Mutex simple. Notez que Go propose aussi une sync.Map optimisée pour certains profils d'accès concurrents, mais une map classique gardée par un RWMutex reste le choix le plus lisible dans la majorité des cas.

Charger une configuration, ouvrir une connexion, compiler une expression régulière : ces initialisations coûteuses ne doivent avoir lieu qu'une fois, même si plusieurs goroutines les réclament en même temps. Le type sync.Once garantit exactement cela via Do, qui n'exécute la fonction fournie qu'au premier appel et fait patienter les autres jusqu'à sa fin.

var (
once sync.Once
config string
)
func chargerConfig() {
config = "config chargée"
}
// Appelé par 10 goroutines concurrentes :
once.Do(chargerConfig)

Même sollicitée par dix goroutines simultanées, chargerConfig ne s'exécute qu'une seule fois, et Once pose une barrière mémoire qui rend le résultat visible à toutes. Go 1.21 a ajouté des variantes plus concises pour mémoriser un résultat : sync.OnceValue exécute un calcul au premier appel et renvoie sa valeur mémorisée aux suivants :

valeurUnique := sync.OnceValue(func() int {
fmt.Println("calcul coûteux exécuté")
return 42
})
fmt.Println(valeurUnique(), valeurUnique(), valeurUnique())
calcul coûteux exécuté
42 42 42

Le message « calcul coûteux exécuté » n'apparaît qu'une fois : les appels suivants renvoient directement la valeur mémorisée. C'est le moyen idiomatique de remplacer une variable globale initialisée paresseusement.

WaitGroup attend, mais ne remonte aucune erreur. Dès qu'un lot de tâches peut échouer, le paquet golang.org/x/sync/errgroup (une bibliothèque officielle hors bibliothèque standard, à installer avec go get golang.org/x/sync) prend le relais. Un errgroup.Group lance les tâches avec Go, et son Wait renvoie la première erreur non nulle rencontrée. Sa variante WithContext annule un context.Context partagé au premier échec, pour signaler aux autres tâches d'abandonner :

g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return errors.New("échec du téléchargement 2")
})
g.Go(func() error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err() // abandonne : une autre tâche a échoué
}
})
if err := g.Wait(); err != nil {
fmt.Println("erreur du groupe:", err)
}
erreur du groupe: échec du téléchargement 2

La première tâche échoue immédiatement, ce qui annule ctx ; la seconde, au lieu d'attendre ses deux secondes, détecte l'annulation et s'arrête. errgroup combine ainsi trois besoins courants : lancer des tâches concurrentes, collecter la première erreur, et couper court au reste dès qu'une échoue. Sa méthode SetLimit permet en prime de plafonner le nombre de goroutines actives, utile pour ne pas saturer une API distante.

Assemblez les briques du guide. Cherchez la solution avant d'ouvrir la réponse.

Écrivez un compteur de mots partagé entre plusieurs goroutines :

  • une structure compteurMots contenant une map[string]int et un verrou.
  • une méthode ajouter(mot string) qui incrémente le compteur du mot, protégée contre les accès concurrents.
  • dans main, lancez 10 goroutines qui appellent chacune ajouter("go") mille fois, attendez-les, puis affichez le total.

Le total attendu pour « go » est 10000, et go run -race . doit être propre.

Indice : un sync.Mutex suffit ; passez la structure par pointeur aux goroutines.

Ces bugs reviennent sans cesse dans les questions de débutants sur les forums Go. La bonne nouvelle : go vet (lancé automatiquement par go test) en attrape plusieurs statiquement, avant même l'exécution, et le reste se manifeste par un panic ou un deadlock explicite.

SymptômeCauseCorrection
Wait rend la main trop tôt, résultats incompletswg.Add(1) appelé dans la goroutine : course entre Add et WaitAppeler Add avant go, ou utiliser wg.Go
Blocage définitif (all goroutines are asleep - deadlock!)wg.Done() oublié, ou WaitGroup copié par valeurdefer wg.Done() ; passer *sync.WaitGroup par pointeur
panic: sync: negative WaitGroup counterplus de Done que de AddUn Add par goroutine, un seul Done ; wg.Go équilibre pour vous
Deadlock sur un MutexUnlock oublié sur un retour anticipé, ou re-verrouillage du même mutexdefer mu.Unlock() ; le Mutex n'est pas réentrant
Programme lent malgré la concurrenceI/O (réseau, disque) exécutée en tenant le verrouGarder la section critique minimale : verrouiller la seule mise à jour mémoire
Protection silencieusement inopéranteun sync.Mutex copié (structure passée par valeur)Passer par pointeur ; go vet signale « copies lock value »

Le piège numéro un reste le Add dans la goroutine. Comme la goroutine démarre en parallèle, rien ne garantit que son Add s'exécute avant le Wait de main : le compteur peut être encore à zéro quand Wait s'évalue, et le programme n'attend personne. Depuis Go 1.25, go vet signale directement ce cas avec le message WaitGroup.Add called from inside new goroutine. La parade la plus sûre est wg.Go, qui incrémente le compteur avant de lancer la goroutine.

La copie de verrou est tout aussi vicieuse. Passer une sync.WaitGroup ou un sync.Mutex par valeur à une fonction en fait une copie indépendante : le Done ou le Unlock porte sur la copie, l'original reste bloqué. Ces types intègrent un marqueur noCopy que go vet repère (passes lock by value ... contains sync.noCopy). La règle est simple : toute structure contenant un verrou se manipule par pointeur.

Un dernier faux ami mérite d'être écarté : la variable de boucle. Avant Go 1.22, un for i := ...; go func(){ ... i ... }() capturait une variable partagée, et toutes les goroutines lisaient la dernière valeur. Depuis Go 1.22, chaque itération possède sa propre variable : sur Go 1.26, dix goroutines lancées dans une boucle voient bien des valeurs distinctes. Recopier i := i en tête de boucle n'est donc plus nécessaire, sauf si vous ciblez une version antérieure.

  • sync.WaitGroup attend un groupe de goroutines ; depuis Go 1.25, wg.Go remplace le trio Add / go / Done.
  • Un accès concurrent non protégé est une race condition : résultat faux et non déterministe, à débusquer avec go run -race et go test -race.
  • sync.Mutex protège une section critique en Lock / Unlock ; ne le copiez jamais, passez la structure par pointeur.
  • sync.RWMutex autorise plusieurs lecteurs concurrents (RLock), utile quand les lectures dominent.
  • sync.Once (et sync.OnceValue) garantit une initialisation unique malgré la concurrence.
  • errgroup attend un lot de tâches, renvoie la première erreur et, avec WithContext, annule les autres au premier échec.
  • go vet attrape statiquement les pièges classiques : Add dans une goroutine et copie d'un verrou.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn