← Debugging Guides

阅读中文版本

Go — fatal error: concurrent map writes

Handle fatal error: concurrent map writes, which the race detector may miss, understand why it is unrecoverable, and protect or shard the map correctly.

advanceddebugginggoconcurrencymaps

Symptoms

  • fatal error: concurrent map writes followed by a goroutine dump, with the process terminating without recover taking effect
  • The crash is intermittent under load and reproducible only at certain concurrency levels
  • go test -race may pass while production still crashes, because the runtime check and the detector sample differently

Possible causes

  • Multiple goroutines write to the same map without a mutex, and the runtime internal check trips
  • A map is shared through a global variable or struct field accessed from handlers concurrently
  • One goroutine writes while another only reads — still a race for maps, still fatal when flagged
  • A "readonly in practice" map becomes written by a cache-warming or lazy-init path added later

Diagnosis steps

Read the dump for both parties

go run ./app 2>&1 | grep -B2 -A8 "concurrent map writes" | head -40

Interpretation

The dump lists the goroutine performing the flagged write and its stack — the code owner to fix. The check flags one writer, not both, so audit all writers of that map.

Find every access path

grep -Rn "cache\[" src/ | head -20

Interpretation

Map indexing syntax on a shared name enumerates every read and write site that must agree on one synchronization strategy.

Confirm with the race detector anyway

go test -race -count=10 ./...

Interpretation

Even when the fatal error is the first signal, -race often surfaces the exact conflicting pair during tests, shortening the audit.

Root cause

Go maps are not safe for concurrent use. The runtime inserts checks that detect concurrent map access and call fatal error, which terminates the process and cannot be caught by recover. The check samples accesses, so violations slip through tests and surface under production concurrency.

Fix

Guard every access with a single sync.Mutex (or RWMutex when reads dominate), or replace the map with sync.Map for disjoint key sets and mostly-read workloads, or shard the map into per-shard locks for write-heavy parallelism. Then keep -race in CI for the package.

Risk note

A recover() around the crash recovers nothing — fatal error bypasses it. Do not build supervision as a substitute for synchronization.

Verify the fix

Reproduce the concurrency pattern in a stress test with -race and without, assert zero crashes across repeated runs, and keep the race-enabled suite in CI.

Symptom walkthrough

This crash differs from a panic in the most important way — there is no recovering from it:

fatal error: concurrent map writes

goroutine 87 [running]:
main.(*Cache).Set(...)
        /app/src/cache.go:44

fatal error means the runtime killed the process; any recover() up the stack is ignored. The dump names one writing goroutine, but the bug belongs to all unsynchronized writers of that map. Note the detector asymmetry: go test -race may pass repeatedly while production crashes, because the runtime check and the race detector sample accesses differently — a green race run does not clear this specific pattern.

Fix in practice

One map, one lock, every access agrees:

type Cache struct {
    mu sync.RWMutex
    m  map[string]Entry
}

func (c *Cache) Get(k string) (Entry, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    e, ok := c.m[k]
    return e, ok
}

func (c *Cache) Set(k string, e Entry) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = e
}

For mostly-read caches with disjoint keys, sync.Map removes the boilerplate:

var cache sync.Map // key string, value Entry
cache.Store(k, e)
v, ok := cache.Load(k)

For write-heavy parallelism, shard: N shards each with its own mutex, chosen by hash of the key. Whatever you pick, the invariant is total — one unsynchronized access path reintroduces the fatal.

Verification

  Write a stress test that hammers the map from many goroutines exactly like production does, run it both with and without -race, repeated (-count=20) to overcome sampling. Zero crashes across both modes is the bar. Then keep the race-enabled suite in CI for this package permanently: the sampling gap between the runtime check and the detector is exactly why this bug reaches production after green test runs.