
Go Concurrency
sync.Map vs Mutex vs RWMutex
Introduction
When building high-performance applications in Golang, you’ll often need to manage shared data across multiple goroutines. Choosing the right data structure can significantly impact performance, especially when handling concurrent read/write operations.
In this article, we’ll dive into a benchmark comparison of three common approaches to concurrent map access in Go:
sync.Map(Go’s built-in concurrent map)map + sync.Mutex(Traditional locking mechanism)map + sync.RWMutex(Optimized for read-heavy workloads)
By the end, you’ll have a clear understanding of which method to use based on your workload.
Setting Up the Benchmark
To measure the performance of each approach, we run benchmarks using different dataset sizes: 1K, 10K, 100K, and 1M keys. Our tests measures 4 key metrics:
- Operations per second (higher is better)
- Execution time per operation (ns/op) (lower is better)
- Memory usage (B/op) (lower is better)
- Allocations per operation (lower is better)
Here’s the benchmarking code, if you are interested.
import (
"math/rand"
"sync"
"testing"
)var datasetSizes = []struct {
name string
numKeys int
}{
{"1K", 1000},
{"10K", 10000},
{"100K", 100000},
{"1M", 1000000},
}
func BenchmarkSyncMap(b *testing.B) {
for _, size := range datasetSizes {
b.Run(size.name, func(b *testing.B) {
var sm sync.Map
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(size.numKeys)
sm.Store(key, key)
sm.Load(key)
}
})
})
}
}
func BenchmarkMutexMap(b *testing.B) {
for _, size := range datasetSizes {
b.Run(size.name, func(b *testing.B) {
var m sync.Mutex
data := make(map[int]int)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(size.numKeys)
m.Lock()
data[key] = key
_ = data[key]
m.Unlock()
}
})
})
}
}
func BenchmarkRWMutexMap(b *testing.B) {
for _, size := range datasetSizes {
b.Run(size.name, func(b *testing.B) {
var rw sync.RWMutex
data := make(map[int]int)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
key := rand.Intn(size.numKeys)
if rand.Float32() < 0.9 {
rw.RLock()
_ = data[key]
rw.RUnlock()
} else {
rw.Lock()
data[key] = key
rw.Unlock()
}
}
})
})
}
}
This benchmark tests the three approaches under varying key sizes, simulating real-world read-heavy and write-heavy workloads.
Benchmark Results
Here’s what we found:
1. Execution Time per Operation (ns/op)
Press enter or click to view image in full size

Performance
2. Operations per Second (Ops/sec)
Press enter or click to view image in full size

Throughput
3. Allocations per Operation
Press enter or click to view image in full size

Allocations/ops
Note: I have not added the results from other key sizes(1K, 10K, 100K) because they follow pretty much the same pattern as it is for 1M keys.
Key Takeaways
- Use
sync.Mapwhen you need a concurrent-safe map and expect unpredictable read/write patterns. - Use
map + RWMutexfor read-heavy workloads, as it allows multiple readers to access data simultaneously. - Avoid
map + Mutexunless you have a write-heavy workload with low contention, as it slows down due to exclusive locking.
Note: You must have noticed the high allocations in case of SyncMap. This is because SyncMap optimizes for concurrent access and internally maintains an efficient structure which gets reallocated. This is a topic in itself for another time.
Closing Thoughts
Choosing the right concurrency mechanism in Golang can make a huge difference in performance. By understanding these trade-offs, you can design systems that scale efficiently under load.