Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • sync-map
  • concurrency
  • mutex
  • performance
  • benchmarking

Go's sync.Map: When It Actually Beats a Mutex-Protected Map

Every so often someone parallelises a piece of code that touches a map, sees the word "concurrent" attached to sync.Map, swaps it in, and benchmarks the result against the boring version: a plain map[K]V guarded by a sync.Mutex. The boring version wins. Not narrowly either, often by a wide enough margin that people assume they've made a mistake.

They haven't. sync.Map was never meant to be a faster map. It's a specialised structure for two narrow access patterns, and the standard library documentation says so directly, in the doc comment nobody reads because the type name already sounds like an answer to "how do I use a map from multiple goroutines".

The two cases it's actually built for

The documentation for sync.Map is unusually blunt about its own scope. It's optimised for two situations: when the entry for a given key is only ever written once but read many times, "as in caches that only grow", or when multiple goroutines read, write and overwrite entries for disjoint sets of keys, so that 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, and you keep full type safety into the bargain, because sync.Map still stores everything as any. It predates generics and nobody has gone back to retrofit them onto the exported type.

Here's the read-heavy, disjoint-key case, the one 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, and the reason is worth sitting with for a second because it 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 "lock" you thought was free 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.

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 of these outcomes as gospel, though, because the exact numbers depend heavily on core count, contention, and, as of comparatively recently, which Go release you're on.

The bit that's actually interesting: Go 1.24 replaced the internals

Up to Go 1.23, sync.Map worked by keeping 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, and 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 one built on the same lock-free hash trie (HashTrieMap) that had already shipped internally for the unique package in Go 1.23, 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, so 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. If you need to compare old and new behaviour on 1.24 or later, the previous implementation is still reachable via GOEXPERIMENT=nosynchashtriemap at build time.

This is one of those changes that quietly moves the goalposts on every "sync.Map vs mutex" benchmark ever written, including, arguably, the ones above. The qualitative guidance (two narrow use cases, ordinary map otherwise) hasn't changed, but the exact break-even point has, so a benchmark run against Go 1.22 and one run against Go 1.24 onward can reasonably disagree.

What you give up either way

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 also no Len(). If you need a count, you Range over the whole thing and tally it yourself, which is O(n) and, per the documentation, not 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, and it's worth checking your code isn't quietly relying on Range behaving like a point-in-time copy.

CompareAndSwap and CompareAndDelete arrived in Go 1.20 alongside Swap, and Clear followed in Go 1.23, so the API has slowly grown the primitives you'd want for lock-free update loops without going back to Load plus a manual retry. 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 actually looks like one of the two it was built for, ideally with a benchmark against the Go version you're shipping rather than one you remember reading about.