Maps en Go et l'erreur « concurrent map writes »
1. Aperçu
Les maps sont l’une des structures de données les plus utilisées en Go, après les slices. Cependant, elles ne sont pas sûres quand plusieurs goroutines y accèdent simultanément. Si vous écrivez dans une map depuis plusieurs goroutines, le programme peut s’arrêter avec le message fatal error: concurrent map writes.
Cet article revoit les opérations basiques avec les maps. Ensuite, il explique pourquoi cette erreur se produit, comment la détecter avec le race detector, et trois façons de la corriger : sync.Mutex, sync.RWMutex, sync.Map. Les exemples utilisent Go 1.22 ou plus récent.
2. Les maps en Go : ce qu’il faut retenir
2.1. Opérations basiques
package main
import "fmt"
func main() {
stock := map[string]int{"apple": 10, "banana": 5}
stock["orange"] = 7
qty, ok := stock["grape"]
fmt.Println(qty, ok)
delete(stock, "banana")
fmt.Println(len(stock))
}
Explications :
map[string]int{...}crée une map avec les clés de typestringet les valeurs de typeint.stock["grape"]retourne la valeur zéro (0) quand la clé n’existe pas. La variableokvous indique si la clé se trouve réellement dans la map ou non.deletesupprime une clé. Appelerdeleteavec une clé inexistante ne cause pas d’erreur.
0 false
2
2.2. Map nil
Déclarer var m map[K]V sans initialisation vous donne une map nil. Lire depuis une map nil fonctionne, mais écrire déclenche une panique :
package main
func main() {
var orders map[string]int
_ = orders["A-001"] // OK, retourne 0
orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map
La correction est toujours initialiser la map avec make(map[string]int) ou avec un literal map[string]int{}. Cette erreur est courante quand la map est un champ dans une struct et que vous oubliez de l’initialiser dans le constructeur.
2.3. Les maps sont un type référence, l’ordre de parcours est aléatoire
Quand vous assignez une map à une autre variable ou la passez à une fonction, les deux pointent vers une seule et même structure de données en dessous. C’est pourquoi deux goroutines qui reçoivent « la même map » partagent en fait une même zone mémoire. Ceci est à la racine du problème dans la section suivante.
De plus, l’ordre de parcours avec for k, v := range m est délibérément aléatoire. N’écrivez pas de code ou de test qui dépend de cet ordre. Si vous avez besoin d’un ordre fixe, récupérez les clés, triez-les, puis parcourez.
3. L’erreur « concurrent map writes »
3.1. Reproduire l’erreur
Supposez que vous comptiez les accès par utilisateur, chaque requête s’exécutant sur une goroutine :
package main
import (
"fmt"
"sync"
)
func main() {
visits := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
visits[fmt.Sprintf("user-%d", j%10)]++
}
}()
}
wg.Wait()
fmt.Println(len(visits))
}
En lançant ceci plusieurs fois, vous verrez presque sûrement :
fatal error: concurrent map writes
goroutine 23 [running]:
main.main.func1()
/tmp/visits/main.go:17 +0x...
...
exit status 2
La ligne visits[...]++ consiste en une lecture et une écriture. Quand 100 goroutines font cela simultanément, plusieurs goroutines peuvent modifier la structure interne de la map (buckets, processus de croissance) en même temps. Le runtime de Go a un mécanisme de vérification simple : la map maintient un flag « en cours d’écriture ». Si une goroutine voit ce flag déjà activé quand elle commence à écrire, le runtime arrête le programme immédiatement.
Il existe une variante similaire quand une goroutine lit pendant qu’une autre écrit :
fatal error: concurrent map read and map write
3.2. Pourquoi on ne peut pas recover
Nombreux sont ceux qui essaient de wrapper le code avec recover :
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
visits["user-1"]++
}()
Cette approche ne marche pas. concurrent map writes n’est pas une panic. C’est une fatal error appelée par le runtime avec throw, et elle termine l’ensemble du processus. recover ne peut pas l’attraper. Ce choix est délibéré : si la map est corrompue, continuer l’exécution est plus dangereux que d’arrêter complètement.
Note : Le mécanisme de détection du runtime est seulement du « best-effort ». Le fait que le programme ne plante pas ne signifie pas que le code est sûr. Il est possible que vous ayez simplement pas encore rencontré le moment où deux goroutines entrent en collision, et les données sont devenues incorrectes silencieusement.
3.3. Détecter tôt avec le race detector
Un outil plus fiable est le race detector, activé avec l’option -race :
go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
runtime.mapaccess1_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
Previous write at 0x00c000... by goroutine 7:
runtime.mapassign_faststr()
main.main.func1()
/tmp/visits/main.go:17 +0x...
==================
Le race detector identifie précisément la ligne de lecture et la ligne d’écriture qui entrent en conflit. Vous devriez activer -race quand vous lancez les tests dans votre CI. Un programme s’exécutant avec -race est plus lent et consomme plus de mémoire, c’est pourquoi on n’utilise généralement pas cette option pour une build de production.
4. Premier correctif : sync.Mutex
L’approche la plus courante et la plus lisible est de wrapper la map dans une struct contenant un mutex, puis de permettre l’accès à la map uniquement via des méthodes :
package visits
import (
"maps"
"sync"
)
type VisitCounter struct {
mu sync.Mutex
counts map[string]int
}
func NewVisitCounter() *VisitCounter {
return &VisitCounter{counts: make(map[string]int)}
}
func (c *VisitCounter) Inc(user string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[user]++
}
func (c *VisitCounter) Get(user string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[user]
}
func (c *VisitCounter) Snapshot() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.counts)
}
Explications :
- Le champ
countsest en minuscules donc le code en dehors du package ne peut pas accéder directement à la map. IncetGetverrouillent tous deux le mutex, donc à chaque instant une seule goroutine manipule la map.Snapshotretourne une copie avecmaps.Clone(disponible depuis Go 1.21). Si vous retourniezc.countsdirectement, l’appelant parcourrait cette map sans verrouillage, et vous seriez de retour au problème initial.- Le constructeur
NewVisitCounterinitialise la map, évitant l’erreur de map nil vue en section 2.2.
4.1. Écrire un test avec -race
Go n’utilise pas JUnit ou AssertJ. Le paquet testing standard suffit pour cet exemple :
package visits
import (
"sync"
"testing"
)
func TestVisitCounter_ConcurrentInc(t *testing.T) {
counter := NewVisitCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Inc("alice")
}
}()
}
wg.Wait()
if got := counter.Get("alice"); got != 100_000 {
t.Fatalf("Get(alice) = %d, want 100000", got)
}
}
go test -race ./...
ok example.com/visits 1.214s
Le test vérifie deux choses. D’abord, le résultat est exactement 100.000, donc aucun incrément n’est perdu. Ensuite, le race detector ne signale pas de DATA RACE. Essayez de retirer c.mu.Lock() dans Inc puis relancez, le test échouera immédiatement.
4.2. Erreurs courantes avec mutex
Copier une struct contenant un mutex. Si vous déclarez une méthode avec un receiver par valeur, comme func (c VisitCounter) Inc(...), chaque appel à la méthode utilisera une copie du mutex. Verrouiller la copie ne protège rien. Utilisez toujours un pointer receiver. go vet vous avertira de cette situation via la vérification copylocks :
./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex
Garder le verrou trop longtemps. Ne faites pas d’appels HTTP, de requêtes de base de données ou d’écritures de fichiers pendant que vous tenez un verrou. Récupérez les données nécessaires, relâchez le verrou, puis faites le travail lent.
Check-then-act dissociés. Le code ci-dessous a deux verrouillages distincts, donc entre les deux, une autre goroutine peut s’intercaler :
if counter.Get("bob") == 0 {
counter.Inc("bob") // une autre goroutine a peut-être incrémenté avant
}
Si vous avez besoin de la logique « si n’existe pas alors créer », écrivez-la comme une seule méthode et gardez le verrou pour toute la durée de la méthode.
5. Deuxième correctif : sync.RWMutex
Avec des données lues très fréquemment mais rarement modifiées, comme un cache de configuration ou un tableau de prix de produits, sync.RWMutex permet à plusieurs goroutines de lire en parallèle :
package pricing
import "sync"
type PriceTable struct {
mu sync.RWMutex
prices map[string]int64
}
func NewPriceTable() *PriceTable {
return &PriceTable{prices: make(map[string]int64)}
}
func (p *PriceTable) Price(sku string) (int64, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
v, ok := p.prices[sku]
return v, ok
}
func (p *PriceTable) SetPrice(sku string, cents int64) {
p.mu.Lock()
defer p.mu.Unlock()
p.prices[sku] = cents
}
RLock permet à plusieurs goroutines de lire simultanément. Lock est un verrou exclusif : il attend que tous les lecteurs terminent et bloque les nouveaux lecteurs jusqu’à ce que l’écriture soit terminée.
Note :
RWMutexn’est pas toujours plus rapide queMutex. Il a un surcoût de gestion plus élevé. Quand la proportion d’écritures est importante, ou quand la section de code dans le verrou est très courte,Mutexest souvent aussi rapide ou plus rapide. Mesurez avec des benchmarks avant de changer.
Une erreur facile à commettre : appeler p.mu.RLock() puis appeler à l’intérieur une autre méthode qui fait aussi RLock. S’il y a un writer en attente entre les deux, le programme peut deadlock. La raison en est que RWMutex bloque les nouveaux lecteurs quand un writer est déjà en file d’attente.
6. Troisième correctif : sync.Map
Le paquet sync fournit sync.Map, une map sûre pour la concurrence sans avoir besoin de gérer soi-même un verrou :
package main
import (
"fmt"
"sync"
)
type Session struct {
UserID string
}
func main() {
var sessions sync.Map
sessions.Store("token-abc", Session{UserID: "alice"})
actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
fmt.Println(actual.(Session).UserID, loaded)
if v, ok := sessions.Load("token-abc"); ok {
fmt.Println(v.(Session).UserID)
}
sessions.Range(func(key, value any) bool {
fmt.Println(key, value.(Session).UserID)
return true
})
}
alice true
alice
token-abc alice
Explications :
Storeécrit une paire clé/valeur.LoadOrStoreest une opération atomique. Si la clé existe déjà, la méthode retourne la valeur existante etloaded = true, sans écraser. C’est la bonne façon de gérer la situation check-then-act avecsync.Map.Rangeparcourt la map. La fonction de callback retournefalsepour arrêter tôt.
sync.Map a des limitations que vous devez connaître :
- Les clés et valeurs ont le type
any, donc vous perdez la vérification de type à la compilation et devez faire des type assertions partout. - Pas de
Len(). Pour compter les éléments, vous devez parcourir avecRange. sync.Mapest optimisée pour deux cas spécifiques. L’un est quand chaque clé est écrite une fois puis lue très souvent, par exemple un cache auquel on ne fait que ajouter. L’autre est quand les goroutines lisent et écrivent sur des ensembles de clés qui ne se chevauchent pas.
Note : En dehors de ces deux cas, une
mapordinaire associée à unMutexouRWMutexest généralement plus rapide, plus lisible et type-safe. Ne remplacez pas automatiquement chaque map parsync.Map.
7. Quel correctif choisir ?
| Situation | Recommandation |
|---|---|
| Lectures/écritures mélangées, logique conditionnelle | map + sync.Mutex |
| Surtout des lectures, écritures rares | map + sync.RWMutex (mesurer d’abord) |
| Cache en ajout uniquement, ou chaque goroutine a ses propres clés | sync.Map |
| Map utilisée dans une seule goroutine | map ordinaire, pas de verrou nécessaire |
Il existe une autre approche : faire en sorte qu’une seule goroutine soit propriétaire de la map, et les autres goroutines envoient des requêtes via un channel. Cette approche convient quand la mise à jour de la map est liée à une boucle de traitement existante. Pour un simple compteur, cette approche est plus verbeux sans avantages évidents.
Une note supplémentaire sur les maps en lecture seule : si la map est initialisée avant que les goroutines ne commencent et que personne n’écrit plus jamais après, plusieurs goroutines qui la lisent simultanément est sûr et ne nécessite pas de verrou.
8. Conclusion
Les maps en Go sont rapides et pratiques, mais ne sont pas sûres pour la concurrence. Quand plusieurs goroutines écrivent simultanément, ou lisent et écrivent, le runtime peut arrêter le programme avec fatal error: concurrent map writes. Cette erreur ne peut pas être récupérée avec recover. Le fait que le programme ne plante pas ne prouve pas non plus que le code est correct, donc lancez vos tests avec go test -race pour détecter les data races tôt.
Dans la plupart des cas, il suffit de wrapper la map dans une struct avec sync.Mutex, d’accéder via des méthodes avec pointer receiver, et de retourner des copies plutôt que la map elle-même. Utilisez sync.RWMutex quand la charge de lecture domine vraiment, et n’utilisez sync.Map que pour les cas d’usage exacts pour lesquels elle a été conçue.