Deux goroutines qui écrivent dans la même map[string]int au même moment, et le programme s’arrête net avec fatal error: concurrent map writes. Pas de panic récupérable, pas de stack trace applicative à intercepter. Juste l’arrêt. Ce dernier article de la série regarde pourquoi le runtime réagit comme ça, et ce qu’il faut poser à la place.
Le premier article a décrit la structure interne d’une map, le deuxième sa croissance. Ici, on s’arrête sur ce qui se passe quand plusieurs goroutines y touchent en même temps.
💡 Tout le code de la série est sur github.
Le crash n’est pas un panic ordinaire
Le runtime Go pose un flag writing sur chaque map. Chaque écriture le bascule au début de l’opération et le rebascule à la fin, via un XOR sur un bit dédié. Si une deuxième écriture arrive alors que le flag est déjà posé, le runtime en conclut qu’il y a un accès concurrent et déclenche un fatal error.
Ce n’est pas une panic classique. recover() ne rattrape rien : le programme s’arrête, point final. C’est un choix délibéré du runtime : corrompre silencieusement une structure de données partagée serait pire qu’un crash net.
func main() {
m := make(map[int]int)
var wg sync.WaitGroup
wg.Add(2)
writer := func(start int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Println("recover a intercepté:", r)
}
}()
for i := range 1000 {
m[start+i] = i
}
}
go writer(0)
go writer(1000)
wg.Wait()
fmt.Println("terminé sans crash, taille finale:", len(m))
}
fatal error: concurrent map writes
goroutine 19 [running]:
internal/runtime/maps.fatal(...)
[...]
exit status 2
Le recover est posé directement dans writer, la goroutine qui écrit effectivement dans la map. Ça ne change rien au résultat. La ligne « recover a intercepté » n’apparaît jamais, et la ligne « terminé sans crash » non plus : le fatal error arrête tout le programme avant que wg.Wait() ne rende la main.
Un détail à connaître : cette détection est best-effort. Le flag writing attrape les cas où deux écritures se chevauchent dans une fenêtre de temps suffisante pour que le runtime les voie. Il n’y a aucune garantie de détecter systématiquement toute concurrence. Une map peut être corrompue sans que le programme crashe, simplement parce que le timing n’a pas fait se chevaucher les deux accès sous l’œil du flag.
C’est là qu’intervient le race detector. Compiler et lancer avec -race instrumente chaque accès mémoire et détecte les data races indépendamment de ce flag, y compris les lectures concurrentes à une écriture (qui, elles, ne déclenchent pas toujours le fatal error). En CI, sur les tests qui touchent des maps partagées, -race doit tourner. Le flag writing est un garde-fou de dernier recours, pas une détection fiable : la démo plus haut le montre bien, puisque sous -race, le fatal error ne se déclenche généralement plus. C’est le race detector qui signale le problème (une WARNING: DATA RACE, code de sortie 66) ; le flag, lui, n’a pas eu l’occasion de voir la collision passer.
Mutex, RWMutex ou sync.Map : la question à se poser d’abord
Trois options s’offrent pour protéger une map partagée. Le choix dépend de deux choses : le ratio lectures/écritures, et la forme de l’accès.
Mutex + map classique est le choix par défaut. Une seule primitive à comprendre, un verrou qui protège toute opération, lecture comme écriture.
type SafeCounter struct {
mu sync.Mutex
m map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil {
c.m = make(map[string]int)
}
c.m[key]++
}
La vérification de c.m == nil évite un piège classique : la valeur zéro d’un SafeCounter a une map nil, et écrire dans une map nil panique. L’initialiser à la volée dans Inc rend la valeur zéro directement utilisable, sans constructeur à appeler avant le premier usage.
RWMutex + map classique autorise plusieurs lecteurs simultanés tant qu’aucune écriture n’est en cours. Ça vaut le coup quand les lectures dominent largement et que la section critique de lecture n’est pas triviale. Mais un RWMutex a un coût propre : maintenir le compteur de lecteurs, arbitrer entre lecteurs et writers en attente. Sur une section critique très courte, ce coût peut dépasser le bénéfice du parallélisme en lecture. Il n’y a pas de règle générale ici : ça se mesure sur le cas réel, avec un benchmark.
sync.Map est une structure spécialisée, pas un remplacement générique de map + verrou. La documentation officielle est explicite : elle cible deux cas d’usage précis. Le premier, une clé écrite une fois puis lue de nombreuses fois (des caches qui ne grossissent qu’en append). Le second, plusieurs goroutines qui lisent, écrivent et réécrivent des jeux de clés disjoints, sans se marcher dessus. En dehors de ces deux cas, la doc recommande explicitement une map classique avec verrou séparé.
type User struct {
ID string
Name string
}
func syncMapStoreAndLoadExample(user User) (User, bool) {
var cache sync.Map
cache.Store("user:42", user)
v, ok := cache.Load("user:42")
if !ok {
return User{}, false
}
return v.(User), true
}
Go 1.24 a réécrit l’implémentation interne de sync.Map avec une structure de type HashTrieMap. Les benchmarks publiés avant cette version, y compris ceux qu’on trouve dans d’anciens articles, ne reflètent donc plus le comportement actuel.
Le dépôt d’exemples qui accompagne cette série contient un benchmark comparant Mutex, RWMutex et sync.Map sur quatre ratios de lecture/écriture (0, 10, 50 et 90 % d’écritures), dans article3/bench_test.go. Aucun chiffre n’est donné ici : ce genre de comparaison bouge d’une version de Go à l’autre et d’une machine à l’autre, mieux vaut le relancer soi-même que de faire confiance à des chiffres figés dans un article.
Ce qu’il faut faire maintenant
Si une map est accessible depuis plusieurs goroutines, on commence par un Mutex simple. On ne passe à un RWMutex que si un profil ou un benchmark montre que la contention en lecture est le goulot. On ne touche à sync.Map que si l’accès correspond vraiment à l’un des deux cas décrits par la doc — sinon c’est un Mutex déguisé, avec une API moins pratique.
Il faut faire tourner go test -race sur tout ce qui touche une map partagée, avant de découvrir le fatal error en production.
📚 Liens
- Key Lookup and Concurrency in Go Maps: From Syntax to hmap
(⚠️ l’explication de l’ordre d’itération y est erronée, voir le deuxième article) - Go runtime : src/internal/runtime/maps
- Go 1.24 Release Notes
- sync.Map