Why I stopped reaching for sync.Map
There’s a certain point in every Go developer’s life where they discover sync.Map and think “oh nice, a thread-safe map, I’ll just use this everywhere.” I went through that phase for about four months in 2021, and then I spent the next year ripping it out of codebases. This is my attempt to save you the trip.
The pitch for sync.Map in the documentation is actually pretty clear if you read it carefully. It says, roughly: use this if the keys are mostly written once and read many times, OR if multiple goroutines read/write/delete disjoint sets of keys. For anything else — the mixed read/write workload that most people actually have — a plain map with a sync.RWMutex is faster. But nobody reads docs, and the name sync.Map looks exactly like what everyone wants.
Here’s the shape of the problem. sync.Map internally maintains two maps: a “read” map that’s lock-free for reads, and a “dirty” map that gets promoted to the read map every so often. It uses atomic.Value swaps to flip between them. The upshot is that reads of stable keys are very cheap, but any write goes through a mutex AND has to coordinate with the read map, which means writes are roughly 2-3x slower than a plain locked map.
I wrote a quick benchmark to demonstrate:
func BenchmarkLockedMap(b *testing.B) {
m := make(map[int]int)
var mu sync.RWMutex
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
if i%10 == 0 {
mu.Lock()
m[i%1024] = i
mu.Unlock()
} else {
mu.RLock()
_ = m[i%1024]
mu.RUnlock()
}
i++
}
})
}
func BenchmarkSyncMap(b *testing.B) {
var m sync.Map
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
if i%10 == 0 {
m.Store(i%1024, i)
} else {
m.Load(i % 1024)
}
i++
}
})
}
With 10% writes, on my M1 with GOMAXPROCS=8, the locked map is about 1.6x faster than sync.Map. At 1% writes, sync.Map pulls ahead slightly. At 50% writes, the locked map is nearly 3x faster. The crossover point where sync.Map wins is roughly “fewer than 5% writes AND high contention AND mostly-stable keys.”
The worst case for sync.Map — and this one is genuinely surprising — is when keys are unique per write. Every new key goes into the dirty map and eventually gets promoted, which means every Load on a new key takes the slow path. I saw this in a caching layer where a colleague had used sync.Map with request IDs as keys. Since IDs are unique per request, the “read” map never warmed up. The service was spending real CPU on map internals that would have been zero-cost with a locked plain map.
There’s also the API ergonomics. sync.Map uses interface{} everywhere (even after Go got generics, sync.Map did not; there was a proposal to add a typed version, but it’s still in the hopper as of 1.21). Every Load returns (interface{}, bool) and you have to type-assert. Every Store boxes into an interface{}. That allocation isn’t free either, especially for int keys, which otherwise would cost nothing.
My current rule is something like:
- If you need a concurrent map and you don’t have a benchmark, use
map[K]V+sync.RWMutex. It’s fine. It’s been fine for a decade. - If you have a benchmark and it shows lock contention, try sharding your map into N buckets before you try
sync.Map. - If you’ve tried sharding and you still have a problem, consider
sync.Map— but only after you’ve confirmed your workload matches one of the two patterns the docs call out.
For what it’s worth, a sharded map looks like:
const shards = 16
type ShardedMap[V any] struct {
shards [shards]struct {
sync.RWMutex
m map[string]V
}
}
func (s *ShardedMap[V]) shard(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32()) % shards
}
That gives you most of the contention benefit without any of the surprising semantics. And in 2023, with generics, it’s a reasonable library to have in your toolbox. I keep one in an internal utils package and I’ve replaced sync.Map with it twice this year.
I don’t hate sync.Map. I just think it’s badly named. If it were called sync.AppendOnlyCache or sync.StableKeyMap, nobody would reach for it by default, and we’d all be better off.