
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.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Attendre la fin de N goroutines avec
sync.WaitGroup. - Protéger un état partagé avec
sync.Mutexetsync.RWMutex. - Détecter les races avec
go run -raceetgo test -race. - Propager la première erreur d'un groupe avec
errgroup.
Attendre un groupe de goroutines avec WaitGroup
Section intitulée « Attendre un groupe de goroutines avec WaitGroup »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.WaitGroupfor 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 goroutinesDepuis 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.WaitGroupfor 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.
La race condition, le bug invisible
Section intitulée « La race condition, le bug invisible »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 := 0var wg sync.WaitGroupfor 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 :
go run -race .==================WARNING: DATA RACERead at 0x00c0000181c8 by goroutine 10: main.main.func1() .../02-race/main.go:16Previous write at 0x00c0000181c8 by goroutine 14: main.main.func1() .../02-race/main.go:16...Found 2 data race(s)exit status 66Protéger l'état partagé avec Mutex
Section intitulée « Protéger l'état partagé avec Mutex »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 := 0var mu sync.Mutexvar wg sync.WaitGroupfor 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.
Lectures concurrentes avec RWMutex
Section intitulée « Lectures concurrentes avec RWMutex »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.
Initialiser une seule fois avec sync.Once
Section intitulée « Initialiser une seule fois avec sync.Once »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 42Le 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.
Propager la première erreur avec errgroup
Section intitulée « Propager la première erreur avec errgroup »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 2La 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.
Exercice : un cache concurrent sûr
Section intitulée « Exercice : un cache concurrent sûr »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
compteurMotscontenant unemap[string]intet 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 chacuneajouter("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.
Le verrou protège l'accès à la map ; wg.Go attend les dix goroutines.
package main
import ( "fmt" "sync")
type compteurMots struct { mu sync.Mutex m map[string]int}
func (c *compteurMots) ajouter(mot string) { c.mu.Lock() defer c.mu.Unlock() c.m[mot]++}
func main() { c := &compteurMots{m: make(map[string]int)} var wg sync.WaitGroup
for i := 0; i < 10; i++ { wg.Go(func() { for j := 0; j < 1000; j++ { c.ajouter("go") } }) } wg.Wait()
fmt.Println("go:", c.m["go"])}go: 10000Une map n'est pas sûre en accès concurrent : sans le Mutex, Go peut même paniquer avec concurrent map writes. Le verrou sérialise les écritures et garantit un total exact, ce que go run -race . confirme en ne signalant aucune race.
Erreurs fréquentes
Section intitulée « Erreurs fréquentes »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ôme | Cause | Correction |
|---|---|---|
Wait rend la main trop tôt, résultats incomplets | wg.Add(1) appelé dans la goroutine : course entre Add et Wait | Appeler Add avant go, ou utiliser wg.Go |
Blocage définitif (all goroutines are asleep - deadlock!) | wg.Done() oublié, ou WaitGroup copié par valeur | defer wg.Done() ; passer *sync.WaitGroup par pointeur |
panic: sync: negative WaitGroup counter | plus de Done que de Add | Un Add par goroutine, un seul Done ; wg.Go équilibre pour vous |
| Deadlock sur un Mutex | Unlock oublié sur un retour anticipé, ou re-verrouillage du même mutex | defer mu.Unlock() ; le Mutex n'est pas réentrant |
| Programme lent malgré la concurrence | I/O (réseau, disque) exécutée en tenant le verrou | Garder la section critique minimale : verrouiller la seule mise à jour mémoire |
| Protection silencieusement inopérante | un 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.
À retenir
Section intitulée « À retenir »sync.WaitGroupattend un groupe de goroutines ; depuis Go 1.25,wg.Goremplace le trioAdd/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 -raceetgo test -race. sync.Mutexprotège une section critique enLock/Unlock; ne le copiez jamais, passez la structure par pointeur.sync.RWMutexautorise plusieurs lecteurs concurrents (RLock), utile quand les lectures dominent.sync.Once(etsync.OnceValue) garantit une initialisation unique malgré la concurrence.errgroupattend un lot de tâches, renvoie la première erreur et, avecWithContext, annule les autres au premier échec.go vetattrape statiquement les pièges classiques :Adddans une goroutine et copie d'un verrou.