When Does sync.Map Actually Beat a Map and a Mutex?
Swap a plain mutex-guarded map for sync.Map, benchmark it, and the boring version often wins. Not narrowly either, often by enough that people assume they've made a mistake.
The story is always the same. Someone parallelises code that touches a map, sees the word "concurrent" attached to sync.Map, swaps it in, and compares it against a plain map[K]V guarded by a sync.Mutex.
They haven't made a mistake. sync.Map was never meant to be a faster map. It's a specialised structure for two narrow access patterns, and the doc comment says so directly. Nobody reads it, because the type name already sounds like an answer to "how do I use a map from multiple goroutines".
It's built for exactly two access patterns
The documentation for sync.Map is unusually blunt about its own scope. It's optimised for two situations:
- The entry for a given key is only ever written once but read many times, "as in caches that only grow".
- Multiple goroutines read, write and overwrite entries for disjoint sets of keys, so each goroutine is mostly stepping on its own data rather than everyone else's.
Outside those two shapes, an ordinary map with a mutex or sync.RWMutex next to it will typically match or beat it. You also keep full type safety, because sync.Map still stores everything as any. It predates generics and nobody has gone back to retrofit them onto the exported type.
Read-heavy, disjoint keys: sync.Map pulls ahead
Here's the case sync.Map is actually built for: a cache populated once, then hammered with reads from many goroutines, each looking at a different key.
func BenchmarkRWMutexCache(b *testing.B) {
var mu sync.RWMutex
m := make(map[int]string, 1000)
for i := 0; i < 1000; i++ {
m[i] = "value"
}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
mu.RLock()
_ = m[i%1000]
mu.RUnlock()
i++
}
})
}
func BenchmarkSyncMapCache(b *testing.B) {
var m sync.Map
for i := 0; i < 1000; i++ {
m.Store(i, "value")
}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
m.Load(i % 1000)
i++
}
})
}
On a machine with enough cores, sync.Map tends to pull ahead here. Actually, this bit is interesting, because the reason isn't really about maps at all. It's about what "read lock" costs.
An uncontended RWMutex.RLock still has to touch shared memory. It increments a reader count with an atomic operation, and RUnlock decrements it again. Under enough parallel readers, that shared counter becomes a cache line bouncing between cores, and the "free" lock starts costing real time even though nobody is ever blocked.
A read hit on sync.Map, by contrast, never writes to shared state at all. It's an atomic pointer load and nothing else. Same asymptotic idea as an uncontended mutex, different constant.
Everyone on the same key: the mutex wins
Now the other shape, the one that catches people out: several goroutines hammering the same key.
func BenchmarkMutexCounter(b *testing.B) {
var mu sync.Mutex
m := make(map[int]int)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
m[1]++
mu.Unlock()
}
})
}
func BenchmarkSyncMapCounter(b *testing.B) {
var m sync.Map
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
v, _ := m.LoadOrStore(1, 0)
m.Store(1, v.(int)+1)
}
})
}
Here the "disjoint keys" assumption is violated on purpose. Every goroutine wants to write the same entry, so there's no way to avoid serialising the updates, and the plain mutex generally comes out ahead.
Run both benchmarks yourself before trusting either outcome as gospel. The exact numbers depend heavily on core count, contention, and, as of comparatively recently, which Go release you're on.
Go 1.24 replaced the internals
Up to Go 1.23, sync.Map kept two maps: a read-only one accessed via an atomic pointer, and a "dirty" one behind an actual mutex.
- A hit against the read map cost nothing.
- A miss, or a write to a new key, had to take the mutex and touch the dirty map.
- Entries only got promoted into the fast read path once enough misses had piled up to make the promotion worth an amortised copy.
That promotion delay meant the "disjoint keys" use case had a warm-up cost: brand new keys started life on the slow path.
Go 1.24 swapped the implementation for a lock-free hash trie (HashTrieMap), the same one that had already shipped internally for the unique package in Go 1.23. It's tracked as golang/go#70683.
It branches 16 ways at each level of the trie, chosen because the Go team found smaller branching factors cost 50% or more in load performance. Reads walk the trie with atomic pointer loads and never lock anything.
Writes still lock, but only the specific node they need to modify, not a single shared dirty map. Two goroutines writing genuinely disjoint keys are far less likely to collide on the same lock, and there's no ramp-up period before that benefit kicks in.
To compare old and new behaviour on 1.24 or later, the previous implementation is still reachable via GOEXPERIMENT=nosynchashtriemap at build time.
This quietly moves the goalposts on every "sync.Map vs mutex" benchmark ever written, arguably including the ones above. The qualitative guidance (two narrow use cases, ordinary map otherwise) hasn't changed. The exact break-even point has, so benchmarks run against Go 1.22 and against Go 1.24 onward can reasonably disagree.
The type-safety cost is still there
None of this touches the ergonomic cost, which the trie rewrite didn't fix and wasn't trying to. sync.Map's keys and values are any, so every Load needs a type assertion, and a wrong assumption about what's in the map is a runtime panic instead of a compiler error.
If you want the concurrency behaviour with the type safety back, wrap it:
type Cache[K comparable, V any] struct {
m sync.Map
}
func (c *Cache[K, V]) Load(key K) (V, bool) {
v, ok := c.m.Load(key)
if !ok {
var zero V
return zero, false
}
return v.(V), true
}
func (c *Cache[K, V]) Store(key K, value V) {
c.m.Store(key, value)
}
There's no Len, and Range isn't a snapshot
There's also no Len(). If you need a count, you Range over the whole thing and tally it yourself, which is O(n).
Per the documentation, that count isn't even guaranteed to reflect one consistent snapshot. If a key is stored or deleted while Range is running, the call "may reflect any mapping for that key from any point during the Range call".
That's a reasonable trade for a lock-free scan, but it's a different guarantee from what people usually assume "concurrent map" means. Check your code isn't quietly relying on Range behaving like a point-in-time copy.
The API has grown, the advice hasn't
The API has slowly gained the primitives you'd want for lock-free update loops without going back to Load plus a manual retry:
CompareAndSwapandCompareAndDeletearrived in Go 1.20, alongsideSwap.Clearfollowed in Go 1.23.
None of that changes the underlying advice. Reach for a plain map behind a mutex by default, and only swap in sync.Map once you've checked your access pattern looks like one of the two it was built for. Ideally, benchmark against the Go version you're shipping rather than one you remember reading about.