← All Posts

Benchmarking Golang Concurrent Maps

Go map choice for building high performance production grade system

Published by Subrat Prasad on 2025-03-12

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

  1. Use sync.Map when you need a concurrent-safe map and expect unpredictable read/write patterns.
  2. Use map + RWMutex for read-heavy workloads, as it allows multiple readers to access data simultaneously.
  3. Avoid map + Mutex unless 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.