The lost update problem
You are building a rate limiter for an API. Every request increments a counter. You test it locally. It works. You deploy to production. The counter drifts. Two requests arrive at the exact same nanosecond. Both read the value as 42. Both write 43. The counter should be 44. You just lost an update.
This is the classic race condition. Go does not protect you from this by default. A map is not safe. A slice is not safe. An int is not safe. Go trusts you to manage concurrency. When you share data between goroutines, you must add synchronization yourself. The standard tool is sync.Mutex.
Think of a mutex as a key to a room. Only the goroutine holding the key can enter. When it leaves, it puts the key back. If another goroutine wants in, it waits for the key. No spinning. No wasted CPU cycles. The scheduler parks the waiting goroutine until the lock is released.
Wrapping data with a mutex
The pattern is simple. Wrap the shared data in a struct. Add a sync.Mutex field. Lock the mutex before reading or writing the data. Unlock it when you are done.
Here's the standard pattern: wrap the data in a struct, add a mutex field, and lock around every access.
package main
import (
"fmt"
"sync"
)
// Counter tracks a value safely across goroutines.
type Counter struct {
mu sync.Mutex // mu serializes access to count
count int // count is the protected state
}
// Inc increments the counter by one.
func (c *Counter) Inc() {
c.mu.Lock() // acquire the lock before touching count
defer c.mu.Unlock() // release the lock when the function returns
c.count++ // safe to modify now; no other goroutine can read/write
}
// Get returns the current count.
func (c *Counter) Get() int {
c.mu.Lock() // reads also need protection
defer c.mu.Unlock()
return c.count // return the value while still holding the lock
}
func main() {
c := &Counter{}
// prints: 100
fmt.Println(c.Get())
}
The receiver name c matches the type Counter. This is the Go convention. One or two letters, lowercase. Not this or self. The defer statement ensures the mutex unlocks even if the function panics. If you panic while holding a lock, the program crashes anyway, but deferring the unlock prevents the lock from staying stuck in a broken state during debugging.
How the lock works at runtime
When Inc runs, Lock checks the internal state of the mutex. If the mutex is free, the goroutine grabs it and proceeds. If another goroutine holds the lock, the current goroutine parks. The scheduler removes it from the run queue. It consumes zero CPU time while waiting.
When the first goroutine hits Unlock, the scheduler wakes one of the waiting goroutines. That goroutine acquires the lock and runs. This handoff is efficient. The mutex does not busy-wait. It relies on the OS and the Go runtime to manage the queue of waiters.
Holding a lock is cheap. Acquiring a lock is expensive. The cost comes from the atomic operations and potential context switches. Keep the critical section small. Do the minimum work inside the lock. Calculate, format, and network outside the lock.
Mutexes are cheap. Locks are expensive. Hold them for microseconds, not seconds.
When a map needs help
A standard map panics if you write to it from multiple goroutines. The compiler cannot catch this. The race detector can. If you need a concurrent map, you have two choices. Wrap a map with a mutex, or use sync.Map.
sync.Map is a specialized data structure. It is not a drop-in replacement for map. It shines in specific scenarios. Use it when you have high contention and different goroutines access distinct keys.
Here's how sync.Map handles concurrent access without a single global lock.
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map // sync.Map handles its own synchronization
// Store sets the value for a key.
m.Store("user:1", "alice")
m.Store("user:2", "bob")
// Load retrieves the value.
// ok is true if the key exists.
if val, ok := m.Load("user:1"); ok {
fmt.Println(val) // prints: alice
}
// Range iterates over all entries.
// The function runs for each key-value pair.
m.Range(func(key, value any) bool {
fmt.Printf("%s: %s\n", key, value)
return true // return true to continue iteration
})
}
sync.Map uses a read map and a dirty map. The read map is immutable. Goroutines can read from it without locking. When a key is missing from the read map, the map falls back to the dirty map, which requires a lock. Over time, the map promotes keys from the dirty map to the read map. This design makes reads blazing fast for hot keys. Writes are slower because they update the dirty map and eventually the read map.
If you write the same key constantly, sync.Map degrades. It is not optimized for heavy writes to the same key. It is optimized for many goroutines touching different keys. If your workload is write-heavy or you need to iterate over the map frequently, stick to a map with a sync.Mutex.
sync.Map is a specialist. Don't use it as a general map.
Pitfalls and compiler errors
Copying a mutex breaks synchronization. If you copy a struct containing a mutex, you get two independent locks. They no longer protect the same state. The compiler rejects this. You get an error like sync.Mutex contains unexported field because the mutex has internal fields that prevent copying. This is intentional. The type system helps you avoid a subtle bug.
Deadlocks happen when a goroutine tries to lock a mutex it already holds. The goroutine waits for itself. It never proceeds. The program hangs. The runtime detects this and panics with fatal error: all goroutines are asleep - deadlock!. Avoid recursive locking. If you need to call a method that locks from inside another method that locks, restructure the code. Extract the unlocked logic into a helper method.
The race detector is your friend. Run go run -race or go test -race. It instruments your code to detect data races at runtime. It prints a report showing the conflicting accesses. It is not a compiler error. It is a runtime check. Run it in CI. Catch races before they hit production.
The archive/zip package follows a common pattern. The constructor function zip.NewReader is safe to call from multiple goroutines. It allocates a new reader each time. The returned *zip.Reader is not safe. You cannot share a single reader across goroutines. If you need concurrent access to a zip file, create multiple readers or protect the single reader with a mutex. This distinction appears in many Go packages. Functions are often safe. Instances are often not.
The race detector is your friend. Run it in CI.
Decision matrix
Use a sync.Mutex when you need to protect a small amount of shared state or a standard map with mixed reads and writes.
Use a sync.RWMutex when reads vastly outnumber writes and the read operation is expensive enough to justify the overhead of a read-lock.
Use sync.Map when you have a map with high contention where different goroutines access distinct keys, and you don't need to iterate over the entire map frequently.
Use a channel when the data flows in one direction or you need to coordinate work between goroutines rather than just protecting a value.
Use atomic operations when you only need to increment a counter or swap a single value and want to avoid the overhead of a mutex.
Don't fight the type system. Wrap the value or change the design.