Retour au cours

backend / go

sync : Mutex, WaitGroup, atomic

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Comprendre ce qu'est une race condition et pourquoi elle est dangereuse
  • Protéger une section critique avec sync.Mutex
  • Optimiser les lectures fréquentes avec sync.RWMutex
  • Utiliser sync/atomic pour des compteurs simples, plus légers qu'un mutex
  • Détecter automatiquement les race conditions avec go run -race

Dans quel contexte ?

Un développeur ajoute un compteur global de requêtes traitées à son service, incrémenté depuis chaque goroutine qui gère une requête HTTP. En test avec peu de trafic, le compteur final semble correct. En production, sous forte charge, le total affiché est systématiquement inférieur au nombre réel de requêtes traitées — un symptôme classique de race condition, invisible en développement mais destructeur en production.

D'abord, il faut comprendre ce qui casse exactement

Quand plusieurs goroutines lisent et modifient la même variable en même temps sans coordination, deux incrémentations peuvent se chevaucher : les deux lisent la même valeur de départ, l'incrémentent chacune de leur côté, puis écrivent leur résultat — et l'une des deux incrémentations est perdue. C'est ce qu'on appelle une race condition, et le résultat devient imprévisible d'une exécution à l'autre.

Une fois ce danger identifié, la solution la plus directe est le mutex

sync.Mutex (mutual exclusion) garantit qu'une seule goroutine à la fois peut exécuter le code compris entre Lock() et Unlock(), appelé la section critique. Toute autre goroutine qui tente de verrouiller pendant ce temps est mise en attente jusqu'au déverrouillage.

OutilCe qu'il protègeCoût relatif
sync.MutexLecture ET écriture, exclusion totaleLe plus simple, coût modéré
sync.RWMutexPlusieurs lectures simultanées OU une écriture exclusivePlus rapide si lectures majoritaires
sync/atomicUne seule variable simple (compteur, flag)Le plus léger, pas de verrou du tout

Prérequis

Il faut être à l'aise avec les goroutines et sync.WaitGroup (leçons précédentes) : cette leçon protège précisément l'état partagé entre goroutines concurrentes.

Il reste un détail syntaxique à retenir absolument

defer c.mu.Unlock() juste après c.mu.Lock() garantit que le verrou sera relâché même si la fonction panique ou retourne prématurément via un return conditionnel. Oublier ce defer, ou pire, oublier Unlock() tout court, provoque un deadlock silencieux : toutes les autres goroutines resteront bloquées indéfiniment en attente d'un verrou qui ne se libérera jamais.

Piège dangereux

Ne jamais verrouiller deux fois le même sync.Mutex (non réentrant) dans la même goroutine sans le déverrouiller entre les deux : cela bloque immédiatement le programme sur un deadlock. Si une méthode verrouillée doit en appeler une autre qui verrouille aussi, il faut restructurer le code pour éviter ce double verrouillage.

Ensuite, un cas particulier optimisable : la lecture fréquente

sync.RWMutex distingue les verrous de lecture (RLock/RUnlock, partageables entre plusieurs goroutines) des verrous d'écriture (Lock/Unlock, exclusifs). Pour un cache lu constamment mais rarement mis à jour, cette distinction améliore significativement les performances sous forte charge de lecture.

Bonne pratique

Lance systématiquement go test -race ./... en intégration continue. Le détecteur de races de Go repère des bugs de concurrence qui pourraient rester invisibles pendant des mois en production avant de se manifester sous forte charge.

sync/atomic propose des opérations garanties atomiques sans jamais poser de verrou explicite, idéal pour un simple compteur. Maintenant que tu sais protéger un état partagé, la prochaine leçon aborde context, l'outil standard pour propager annulation et délais à travers toute une chaîne d'appels concurrents.

Commandes & code

sync : Mutex, WaitGroup, atomic

Quand plusieurs goroutines accèdent au même état mutable, il faut le protéger explicitement.

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// CompteurSur : etat partage protege par un mutex (region critique)
type CompteurSur struct {
	mu     sync.Mutex
	valeur int
}

func (c *CompteurSur) Incrementer() {
	c.mu.Lock()
	defer c.mu.Unlock() // garantit le deverrouillage meme en cas de panic
	c.valeur++
}

func (c *CompteurSur) Valeur() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.valeur
}

// RWMutex : plusieurs lecteurs simultanes OU un seul ecrivain
type Cache struct {
	mu      sync.RWMutex
	donnees map[string]string
}

func (c *Cache) Lire(cle string) (string, bool) {
	c.mu.RLock() // verrou partage : plusieurs lectures concurrentes OK
	defer c.mu.RUnlock()
	v, ok := c.donnees[cle]
	return v, ok
}

func (c *Cache) Ecrire(cle, valeur string) {
	c.mu.Lock() // verrou exclusif : bloque lecteurs ET ecrivains
	defer c.mu.Unlock()
	c.donnees[cle] = valeur
}

func main() {
	// Race condition classique sans protection (a NE PAS faire)
	compteur := &CompteurSur{}
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			compteur.Incrementer()
		}()
	}
	wg.Wait()
	fmt.Println("compteur final:", compteur.Valeur()) // toujours 1000, garanti

	cache := &Cache{donnees: make(map[string]string)}
	cache.Ecrire("cle1", "valeur1")
	v, _ := cache.Lire("cle1")
	fmt.Println(v)

	// sync/atomic : operations atomiques sans mutex, plus rapides pour un simple compteur
	var compteurAtomic int64
	var wg2 sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg2.Add(1)
		go func() {
			defer wg2.Done()
			atomic.AddInt64(&compteurAtomic, 1)
		}()
	}
	wg2.Wait()
	fmt.Println("atomic final:", atomic.LoadInt64(&compteurAtomic))

	// sync.Once : garantit qu'une initialisation ne se fait qu'une seule fois
	var once sync.Once
	initialiser := func() { fmt.Println("initialisation unique") }
	for i := 0; i < 3; i++ {
		once.Do(initialiser) // affiche le message une seule fois
	}
}
bash
# Le detecteur de race conditions de Go : indispensable en developpement
go run -race main.go
go test -race ./...

Résumé

  • sync.Mutex protège une section critique ; toujours defer Unlock() juste après Lock().
  • sync.RWMutex optimise les cas lecture-fréquente / écriture-rare.
  • sync/atomic évite le coût d'un mutex pour des compteurs simples (types atomiques dédiés depuis Go 1.19).
  • go run -race détecte les accès concurrents non synchronisés — à utiliser systématiquement en CI.

Exercices pratiques

1 disponible
1

Mission : un compteur de requêtes qui perd des centaines de hits sous charge

Objectif : Diagnostiquer une race condition sur un compteur global partagé, la corriger avec un mutex, puis raisonner sur un deadlock potentiel introduit par une mauvaise réutilisation du verrou.

Contexte

Un compteur global var requetesTraitees int est incrémenté par requetesTraitees++ depuis chaque goroutine qui gère une requête HTTP. En test avec peu de trafic, le total final semble juste. Sous forte charge en production, le total affiché en fin de journée est systématiquement inférieur au nombre réel de requêtes traitées par les logs du load balancer.

Résoudre l’exercice →