9.7 Concurrent Map Access and sync.Map
Right, let’s talk about the moment you realized your beautifully concurrent Go program is occasionally, and spectacularly, shattering into a million pieces because ten goroutines decided to have a free-for-all on your map[string]int. You’ve just met the fatal error: concurrent map read and map write panic. It’s not a suggestion; it’s the runtime’s way of saying, “I have no idea what’s happening here, and I refuse to guess.” This is where we roll up our sleeves and get smart about shared state.
The core of the problem is that the native Go map isn’t atomic. A single write operation might be a multi-step process underneath the hood (like hashing a key, finding a bucket, and updating a pointer). If a read swoops in midway through that process, it reads inconsistent or partially written data. The result is memory corruption, and Go, rightly, prefers to panic rather than silently give you garbage. So, your first, most obvious tool is the humble mutex.
The Bread and Butter: sync.Mutex and sync.RWMutex
You don’t always need a fancy solution. Often, you just need a lock. Wrapping your map and a mutex into a little struct is the classic, straightforward approach. It gives you complete control.
type ProtectedMap struct {
mu sync.RWMutex
data map[string]int
}
func (m *ProtectedMap) Get(key string) (int, bool) {
m.mu.RLock() // Readers get a shared lock
defer m.mu.RUnlock()
value, ok := m.data[key]
return value, ok
}
func (m *ProtectedMap) Set(key string, value int) {
m.mu.Lock() // Writers get an exclusive lock
defer m.mu.Unlock()
m.data[key] = value
}
Why an RWMutex? Because it’s often a good bet. If your use case is “read-heavy” – meaning reads outnumber writes by a factor of 10, 100, or 1000 – an RWMutex allows any number of readers to proceed concurrently, as long as no one is writing. The moment a write comes in, it blocks new readers and waits for the existing ones to finish. It’s a performance optimization, but a meaningful one. For write-heavy maps, a regular sync.Mutex is often simpler and just as fast, as the exclusive lock is held constantly anyway.
The pitfall here is forgetting to lock. You must export methods like Get and Set and never allow direct access to the inner data map. If you do, someone will forget to lock it, and you’re back to panics. This approach is perfect when you have a well-defined set of operations and a known, structured data type.
The Specialized Tool: sync.Map
Now, meet sync.Map. It’s not a replacement for the map+mutex pattern. It’s a specialized tool for specific, often rare, situations. The Go authors created it for two primary use cases, which they document honestly:
- When the same key is written to over and over (e.g., a
map[int]intthat only has keys 1 through 10, but they’re updated millions of times). - When most goroutines only read and write disjoint sets of keys (e.g., a per-goroutine cache where each goroutine uses its own key).
Why is it different? Instead of one big lock, it uses a clever combination of atomic operations and mutexes to manage internal “shards” (called read and dirty maps). The magic is that for keys in the read map, a load can happen completely atomically without any mutex at all. This is where it gets its performance wins in those specific scenarios.
Here’s how you use it. Notice the API is… different. It feels very Java-esque, because it has to be.
var sm sync.Map
// Store a value
sm.Store("answer", 42)
// Load a value, checking if it existed
if value, loaded := sm.Load("answer"); loaded {
fmt.Println("The answer is:", value)
}
// This is the weird one: LoadOrStore.
// It's atomic. It either gets the existing value or stores your new one.
// Useful for initializing something only once per key.
value, loaded := sm.LoadOrStore("question", "What is six by nine?")
// loaded will be false if it was stored, true if it already existed.
// Delete a key
sm.Delete("question")
// And the weirdest: Range. You must provide a function to iterate.
// This is where they clearly made a choice for safety over elegance.
sm.Range(func(key, value interface{}) bool {
fmt.Println(key, value)
return true // return false if you want to stop iteration
})
The biggest pitfall with sync.Map is using it wrong. If you have a general-purpose map with random keys being written and read by everyone, the overhead of its internal machinery will likely make it slower than a simple mutex-protected map. It’s also less type-safe, leaning heavily on interface{} (pre-Go 1.18) or any, so you’re trading compile-time safety for runtime complexity.
So, Which One Do I Use?
Here’s the simple decision tree:
- Start with a
map + sync.RWMutex. Wrap it in a struct with methods. This is the correct choice 90% of the time. It’s understandable, debuggable, and performant enough. - Reach for
sync.Maponly if you have profiled your application and identified map contention as a bottleneck, and your usage pattern exactly matches one of its two sweet spots. Don’t pre-optimize with it. - If you’re on a recent Go version (1.19+), also consider sharding. This is where you create a slice of N protected maps and distribute keys between them based on their hash. It’s more code but can be the ultimate solution for extremely high-throughput, write-heavy scenarios where
sync.Mapisn’t a fit. It’s essentially building a simpler, more tailored version of whatsync.Mapdoes internally.
The key takeaway (no pun intended) is that concurrency isn’t magic. It’s about understanding the precise requirements of your access patterns and choosing the simplest tool that gets the job done reliably.