Map in Go und der Fehler concurrent map writes
1. Übersicht
Map ist die am häufigsten verwendete Datenstruktur in Go nach Slice. Maps sind jedoch nicht sicher, wenn mehrere Goroutinen gleichzeitig darauf zugreifen. Wenn Sie von mehreren Goroutinen aus in eine Map schreiben, kann das Programm mit der Meldung fatal error: concurrent map writes abrupt beendet werden.
In diesem Artikel werden die grundlegenden Operationen mit Maps wiederholt. Danach wird erklärt, warum dieser Fehler auftritt, wie Sie ihn mit dem Race Detector erkennen und drei Lösungen: sync.Mutex, sync.RWMutex, sync.Map. Die Beispiele verwenden Go 1.22 oder höher.
2. Map in Go: Wichtige Punkte
2.1. Grundlegende Operationen
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))
}
Erklärung:
map[string]int{...}erstellt eine Map mit Schlüsseln vom Typstringund Werten vom Typint.stock["grape"]gibt den Zero Value (0) zurück, wenn der Schlüssel nicht existiert. Die Variableokzeigt Ihnen, ob der Schlüssel tatsächlich in der Map enthalten ist.deleteentfernt einen Schlüssel. Der Aufruf vondeletemit einem nicht existierenden Schlüssel ist harmlos.
0 false
2
2.2. Nil Map
Die Deklaration var m map[K]V ohne Initialisierung gibt Ihnen eine Nil-Map. Das Lesen aus einer Nil-Map funktioniert, aber das Schreiben führt zu Panik:
package main
func main() {
var orders map[string]int
_ = orders["A-001"] // OK, gibt 0 zurück
orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map
Die Lösung besteht darin, die Map immer mit make(map[string]int) oder mit einem Literal map[string]int{} zu initialisieren. Dieser Fehler tritt häufig auf, wenn die Map ein Feld in einer Struktur ist und Sie die Initialisierung im Konstruktor vergessen haben.
2.3. Map ist ein Referenztyp, Reihenfolge beim Durchlauf zufällig
Wenn Sie eine Map einer anderen Variablen zuweisen oder an eine Funktion übergeben, verweisen beide auf eine zugrunde liegende Datenstruktur. Daher teilen sich zwei Goroutinen, die „die gleiche Map” empfangen, tatsächlich einen gemeinsamen Speicherbereich. Dies ist die Wurzel des Problems im nächsten Abschnitt.
Darüber hinaus wird die Reihenfolge beim Durchlauf mit for k, v := range m absichtlich durch die Runtime durcheinander gebracht. Schreiben Sie keinen Code oder Tests, die von dieser Reihenfolge abhängen. Falls Sie eine feste Reihenfolge benötigen, extrahieren Sie die Schlüssel, sortieren Sie sie und durchlaufen Sie sie dann.
3. Der Fehler concurrent map writes
3.1. Fehler reproduzieren
Angenommen, Sie zählen Zugriffe pro Benutzer, wobei jede Anfrage auf einer Goroutine läuft:
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))
}
Wenn Sie es mehrmals ausführen, werden Sie mit hoher Wahrscheinlichkeit folgendes sehen:
fatal error: concurrent map writes
goroutine 23 [running]:
main.main.func1()
/tmp/visits/main.go:17 +0x...
...
exit status 2
Die Zeile visits[...]++ besteht aus einer Lese- und einer Schreiboperation. Wenn 100 Goroutinen das gleichzeitig tun, können mehrere Goroutinen die interne Struktur der Map (Bucket, Wachstumsprozess) gleichzeitig ändern. Die Go-Runtime hat einen einfachen Überprüfungsmechanismus: Die Map behält ein Flag „wird geschrieben”. Wenn eine Goroutine feststellt, dass dieses Flag bereits gesetzt ist, wenn sie mit dem Schreiben beginnt, stoppt die Runtime das Programm sofort.
Es gibt eine ähnliche Variante, wenn eine Goroutine liest, während eine andere Goroutine schreibt:
fatal error: concurrent map read and map write
3.2. Warum Sie nicht recover können
Viele Menschen versuchen, ihren Code mit recover zu umhüllen:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("wiederhergestellt:", r)
}
}()
visits["user-1"]++
}()
Dieser Ansatz funktioniert nicht. concurrent map writes ist kein panic. Dies ist ein fatal error, das von der Runtime durch throw ausgelöst wird und den gesamten Prozess beendet. recover kann es nicht abfangen. Diese Wahl ist absichtlich: Wenn die Map beschädigt ist, ist das Weiterlaufen des Programms gefährlicher als ein kompletter Stopp.
Hinweis: Der Erkennungsmechanismus der Runtime ist nur „best-effort”. Dass das Programm nicht abstürzt, bedeutet nicht, dass der Code sicher ist. Es kann sein, dass Sie nur noch nicht zum richtigen Zeitpunkt auf eine Kollision zweier Goroutinen gestoßen sind, und die Daten sind stattdessen stillschweigend falsch geworden.
3.3. Frühzeitige Erkennung mit Race Detector
Ein zuverlässigeres Werkzeug ist der Race Detector, der mit dem Flag -race aktiviert wird:
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...
==================
Der Race Detector zeigt genau die Lese- und Schreibzeile an, die in Konflikt stehen. Sie sollten -race aktivieren, wenn Sie Tests in CI ausführen. Programme, die mit -race ausgeführt werden, sind langsamer und verbrauchen mehr Speicher. Daher wird dieses Flag normalerweise nicht für Production-Builds verwendet.
4. Erste Lösung: sync.Mutex
Der häufigste und verständlichste Weg ist, die Map in einer Struktur mit einem Mutex zu verpacken und den Zugriff auf die Map nur über Methoden zu ermöglichen:
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)
}
Erklärung:
- Das Feld
countswird in Kleinbuchstaben geschrieben, daher können Codes außerhalb des Pakets nicht direkt auf die Map zugreifen. IncundGetsperren beide den Mutex, sodass immer nur eine Goroutine mit der Map interagiert.Snapshotgibt eine Kopie mitmaps.Clonezurück (verfügbar seit Go 1.21). Wenn Siec.countsdirekt zurückgeben würden, würde der Aufrufer die Map ohne Sperrung durchlaufen, und Sie wären wieder beim ursprünglichen Fehler.- Der Konstruktor
NewVisitCounterinitialisiert die Map und vermeidet den Nil-Map-Fehler aus Abschnitt 2.2.
4.1. Tests mit -race schreiben
Go verwendet nicht JUnit oder AssertJ. Das Standard-Paket testing ist für dieses Beispiel ausreichend:
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, erwartet 100000", got)
}
}
go test -race ./...
ok example.com/visits 1.214s
Der Test überprüft zwei Dinge. Erstens sollte das Ergebnis genau 100.000 sein, was bedeutet, dass keine Inkremente verloren gehen. Zweitens sollte der Race Detector keine DATA RACE melden. Versuchen Sie, c.mu.Lock() in Inc zu entfernen und führen Sie den Test erneut aus. Der Test wird sofort fehlschlagen.
4.2. Häufige Fehler mit Mutex
Kopieren einer Struktur, die einen Mutex enthält. Wenn Sie eine Methode mit einem Value Receiver deklarieren, wie func (c VisitCounter) Inc(...), wird bei jedem Methodenaufruf eine Kopie des Mutex verwendet. Die Sperrung der Kopie schützt nichts. Verwenden Sie immer einen Pointer Receiver. go vet wird Sie in diesem Fall mit dem copylocks-Check warnen:
./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex
Sperren zu lange halten. Rufen Sie nicht HTTP auf, fragen Sie keine Datenbank ab oder schreiben Sie keine Dateien, während Sie die Sperren halten. Rufen Sie die benötigten Daten ab, entsperren Sie, und führen Sie dann die langsamen Operationen durch.
Aufgespaltenes Check-then-act. Der folgende Code hat zwei separate Sperrvorgänge, daher können zwischen diesen zwei Goroutinen eingreifen:
if counter.Get("bob") == 0 {
counter.Inc("bob") // es könnte sein, dass eine andere Goroutine bereits erhöht hat
}
Wenn Sie eine „Check-if-not-exists-then-create”-Logik benötigen, schreiben Sie sie als eine Methode und halten Sie die Sperrung während der gesamten Methode.
5. Zweite Lösung: sync.RWMutex
Für Daten, die sehr häufig gelesen werden, aber selten geschrieben werden, wie einen Konfigurationscache oder eine Produktpreistabelle, ermöglicht sync.RWMutex mehreren Goroutinen, parallel zu lesen:
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 ermöglicht mehreren Goroutinen, gleichzeitig zu lesen. Lock ist eine exklusive Sperrung: Sie wartet, bis alle Reader fertig sind, und blockiert neue Reader, bis das Schreiben abgeschlossen ist.
Hinweis:
RWMutexist nicht immer schneller alsMutex. Es hat höhere Verwaltungskosten. Wenn der Schreibanteil erheblich ist oder wenn der Code in der Sperrung sehr kurz ist, istMutexnormalerweise genauso schnell oder schneller. Messen Sie mit einem Benchmark, bevor Sie wechseln.
Ein häufiger Fehler ist der Aufruf von p.mu.RLock() und dann das Aufrufen einer anderen Methode, die ebenfalls RLock aufruft. Wenn zwischen diesen beiden ein Writer wartet, kann das Programm einen Deadlock verursachen. Der Grund ist, dass RWMutex neue Reader blockiert, wenn bereits ein Writer in der Warteschlange ist.
6. Dritte Lösung: sync.Map
Das Paket sync stellt sync.Map bereit, eine Map, die sicher für Concurrency ist und keine manuelle Sperrungsverwaltung erfordert:
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
Erklärung:
Storeschreibt ein Schlüssel/Wert-Paar.LoadOrStoreist eine atomare Operation. Wenn der Schlüssel bereits existiert, gibt die Methode den alten Wert zurück undloaded = true, und überschreibt ihn nicht. Dies ist der richtige Weg, um die Check-then-act-Situation mitsync.Mapzu handhaben.Rangedurchläuft die Map. Die Callback-Funktion gibtfalsezurück, um früher zu beenden.
sync.Map hat Einschränkungen, die Sie kennen sollten:
- Schlüssel und Wert haben den Typ
any, daher verlieren Sie die Typprüfung zur Compile-Zeit und müssen überall Type Assertions durchführen. - Es gibt kein
Len(). Um Elemente zu zählen, müssen Sie mitRangedurchlaufen. sync.Mapist für zwei spezifische Fälle optimiert. Erstens wird jeder Schlüssel einmal geschrieben und dann sehr oft gelesen, z.B. ein nur wachsender Cache. Zweitens lesen und schreiben Goroutinen auf nicht überlappenden Sätzen von Schlüsseln.
Hinweis: Außer in den beiden oben genannten Fällen ist eine
MapmitMutexoderRWMutexnormalerweise schneller, lesbarer und typsicherer. Ersetzen Sie nicht standardmäßig alle Maps durchsync.Map.
7. Welche Lösung sollten Sie wählen?
| Situation | Empfehlung |
|---|---|
| Lesen/Schreiben gemischt, Logik mit Bedingungen | map + sync.Mutex |
| Überwiegend Lesen, Schreiben selten | map + sync.RWMutex (vorher messen) |
| Cache nur hinzufügen, oder jede Goroutine nutzt separate Schlüssel | sync.Map |
| Map nur in einer Goroutine verwendet | Normale map, keine Sperrung erforderlich |
Es gibt noch einen anderen Weg: Lassen Sie eine einzige Goroutine die Map besitzen, und andere Goroutinen senden Anfragen über Kanäle. Dieser Ansatz ist sinnvoll, wenn die Map-Aktualisierung mit einer bestehenden Verarbeitungsschleife verknüpft ist. Bei einem einfachen Zähler ist dieser Ansatz wortreicher als Mutex und bringt keinen klaren Vorteil.
Ein zusätzlicher Hinweis zu Read-Only Maps: Wenn die Map vor dem Start der Goroutinen initialisiert wird und danach niemand schreibt, ist das gleichzeitige Lesen mehrerer Goroutinen sicher und erfordert keine Sperren.
8. Fazit
Maps in Go sind schnell und bequem, aber nicht sicher für Concurrency. Wenn mehrere Goroutinen gleichzeitig schreiben oder eine Goroutine liest, während eine andere schreibt, kann die Runtime das Programm mit fatal error: concurrent map writes beenden. Dieser Fehler kann nicht mit recover behandelt werden. Dass das Programm nicht abstürzt, beweist nicht, dass der Code richtig ist. Führen Sie daher Tests mit go test -race aus, um Data Races frühzeitig zu erkennen.
In den meisten Fällen müssen Sie nur die Map in einer Struktur mit sync.Mutex verpacken, über Methoden mit Pointer Receivern darauf zugreifen und eine Kopie statt der ursprünglichen Map zurückgeben. Verwenden Sie sync.RWMutex, wenn die Leselast wirklich überwiegt, und verwenden Sie sync.Map nur für die genauen Szenarien, für die es entworfen wurde.