The leaderboard crashes under load
You are building a high-score tracker for a multiplayer game. Players submit scores from different machines. Your server receives a request, updates the map, and returns the rank. It works perfectly with a single player testing locally. You invite a friend. The server crashes instantly. The panic message screams fatal error: concurrent map writes.
The crash happens because Go maps are optimized for speed, not safety. They assume a single owner. When two goroutines touch the same map at the exact same instant, the internal structure can corrupt. Go detects this corruption and panics to prevent silent data loss. It is better to crash than to serve wrong data.
Maps are fast, not safe
Go maps are hash tables. They store keys and values in buckets. When you insert a key, the map calculates a hash, finds the bucket, and writes the value. This is incredibly fast for a single goroutine. The problem arises when the map grows.
Maps resize automatically when they get too full. Resizing allocates new buckets and moves entries from old buckets to new ones. This process happens incrementally. If one goroutine is in the middle of moving entries while another goroutine writes a new key, the pointer chains inside the map break. The map ends up pointing to freed memory or overlapping data.
Go includes a runtime check to catch this. The map implementation tracks whether a resize is in progress. If a write occurs during a resize, or if two writes collide, the runtime triggers a panic. This check protects your data integrity. The trade-off is that you must handle synchronization yourself.
Think of a map like a shared whiteboard in a busy kitchen. If two cooks try to write in the same square at the same time, the marker ink mixes and the message becomes garbage. If one cook is erasing a section while another writes, the new text gets wiped out. Go refuses to let that happen. It stops the program and points at the whiteboard.
Maps are fast. Mutexes are safe. Combine them.
The minimal fix
The standard solution is to wrap map access with a mutex. A mutex acts like a key. Only the goroutine holding the key can touch the map. Other goroutines wait until the key is returned.
package main
import (
"fmt"
"sync"
)
// Counter holds a thread-safe map of integers.
type Counter struct {
// mu protects data from concurrent access.
mu sync.Mutex
data map[string]int
}
// NewCounter returns an initialized Counter.
func NewCounter() *Counter {
return &Counter{
// Initialize the map to avoid nil map panics.
data: make(map[string]int),
}
}
// Increment safely increases the value for key.
func (c *Counter) Increment(key string) {
// Lock ensures only one goroutine modifies the map at a time.
c.mu.Lock()
// Defer guarantees the lock releases even if a panic occurs.
defer c.mu.Unlock()
// Read, increment, and write back while holding the lock.
c.data[key]++
}
func main() {
c := NewCounter()
var wg sync.WaitGroup
// Launch 100 goroutines to stress test the counter.
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Increment("hits")
}()
}
wg.Wait()
fmt.Println(c.data["hits"])
}
The receiver name c matches the type Counter. This is the Go convention for receiver names: use one or two letters that hint at the type, not this or self. The defer c.mu.Unlock() pattern is standard. It ensures the lock releases even if the function panics or returns early. Forgetting to unlock causes a deadlock. Using defer prevents that class of bug.
Lock the map, not the world. Keep critical sections small.
What happens inside the map
The compiler does not catch concurrent map access. Go's type system knows data is a map, but it does not track which goroutines access it. The race happens at runtime. When the panic fires, the stack trace points to the map operation. The error message is plain text: fatal error: concurrent map writes.
You can find these bugs during development using the race detector. Run your tests with go test -race. The race detector instruments your binary to track memory accesses. If two goroutines access the same variable without synchronization, and at least one is a write, the detector reports the race. The output shows the stack traces of the conflicting goroutines.
The race detector adds significant overhead. It slows execution and increases memory usage. Never run the race detector in production. Use it in CI pipelines or during local testing.
Map resizing is the most common trigger for the panic. Even if you only write to unique keys, the map may resize while another goroutine writes. The resize check catches the collision. Reading also requires protection. A read during a resize can see inconsistent state. The runtime may panic on a read if it detects a concurrent write. Always lock reads and writes.
The compiler won't save you from races. Run the race detector.
Real-world pattern: thread-safe wrapper
In production code, you rarely expose the map directly. You wrap the map in a struct and provide methods for access. This encapsulation ensures every access goes through the mutex. It also allows you to add validation or logging without changing callers.
// RequestLog tracks request counts per endpoint.
type RequestLog struct {
mu sync.Mutex
counts map[string]int
}
// LogRecord increments the counter for the given path.
func (r *RequestLog) LogRecord(path string) {
r.mu.Lock()
defer r.mu.Unlock()
// Increment is safe because the lock is held.
r.counts[path]++
}
// GetCount returns the current count for a path.
func (r *RequestLog) GetCount(path string) int {
r.mu.Lock()
defer r.mu.Unlock()
// Reading requires the lock too.
return r.counts[path]
}
// Reset clears all counters.
func (r *RequestLog) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
// Reassigning the map atomically replaces the old map.
r.counts = make(map[string]int)
}
The Reset method shows a useful trick. Reassigning the map variable while holding the lock is safe. The old map becomes unreachable and gets garbage collected. The new map starts fresh. This avoids iterating over the map to delete keys, which can be slow for large maps.
Convention aside: Go functions that take a context.Context should always have it as the first parameter, named ctx. If this log needed to support cancellation or timeouts, the methods would accept ctx context.Context first. For simple in-memory maps, context is often unnecessary unless the operation blocks.
Encapsulation prevents accidental unsynchronized access. Wrap the map.
Pitfalls and hidden traps
Mutexes solve the race condition, but they introduce new risks. Deadlocks, performance bottlenecks, and copy errors are common.
Copying a mutex breaks synchronization. If you pass a struct containing a mutex by value, the receiver gets a copy of the mutex. The copy has its own lock state. Locking the copy does not protect the original map. The compiler warns about this: sync.Mutex is not copyable. Always pass pointers to structs that contain mutexes, or embed the mutex in the struct and pass the struct by pointer.
// Bad: passing by value copies the mutex.
func process(log RequestLog) {
log.mu.Lock() // Locks the copy, not the original.
}
// Good: passing by pointer shares the mutex.
func process(log *RequestLog) {
log.mu.Lock() // Locks the shared mutex.
}
Holding the lock too long serializes all access. If you do heavy computation inside the lock, other goroutines wait. The application becomes single-threaded. Keep the locked section minimal. Read the value, unlock, then do the work. If you need to update the map based on complex logic, compute the result first, then lock and update.
Nested locks can cause deadlocks. If function A locks mutex X then calls function B which locks mutex Y, and function B locks mutex Y then calls function A which locks mutex X, the program hangs. The runtime detects this and panics with fatal error: all goroutines are asleep - deadlock!. Avoid calling back into the same struct while holding a lock. If you must nest locks, always acquire them in a consistent global order.
Reading without locking is a silent bug. The panic says concurrent map writes, but a read during a write also causes it. You must lock reads too. If you skip the lock on reads, the program may crash randomly under load. The crash might happen hours after deployment, making it hard to reproduce.
The worst goroutine bug is the one that never logs. Lock everything that touches the map.
Choosing the right tool
Maps with mutexes work for most cases. Go provides alternatives for specific patterns. Pick the tool that matches your access pattern.
Use a sync.Mutex when you need simple mutual exclusion for a shared map. This is the default choice for most concurrent map usage. It serializes all access and prevents races.
Use a sync.RWMutex when reads vastly outnumber writes and you want to allow concurrent reads. The read lock allows multiple goroutines to read simultaneously. Writers still get exclusive access. This improves throughput for read-heavy workloads.
Use sync.Map when you have high contention on a few hot keys and want to avoid locking overhead. sync.Map is a specialized structure optimized for concurrent access. It uses internal sharding and atomic operations. It is slower for simple cases but faster under heavy contention. It does not support iteration over all keys efficiently.
Use separate maps per goroutine when data can be partitioned by owner. If each goroutine works on its own subset of keys, you can avoid locks entirely. Merge the maps later if needed. This eliminates contention.
Use a channel to funnel updates when a single writer can process requests sequentially. Send updates to a channel. A dedicated goroutine reads from the channel and updates the map. This serializes writes without explicit locking. It works well for event streams.
Pick the lock that matches your access pattern. Measure before optimizing.