Mutexes in Go: when to use sync.Mutex vs sync.RWMutex
Go’s sync.Mutex has a tiny API: Lock() and Unlock(). The decisions around it are less tiny. When should you use a mutex instead of a channel? When does sync.RWMutex actually help? What happens if you copy a mutex? This post covers the practical answers.
The zero value is ready to use
A sync.Mutex zero value is an unlocked mutex. No constructor, no init function. You can embed it directly in a struct and start using it:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
No allocation. No NewSafeCounter() needed. This is one of those Go design choices where the zero value does useful work.
Use pointer receivers when the lock protects receiver state
Methods that lock a mutex stored on the receiver should use pointer receivers. If Increment() had a value receiver, each call would get a fresh copy of the struct, including a fresh copy of the mutex. The method would lock the copy, not the original SafeCounter.
The compiler usually won’t stop you, and the race detector only catches races that actually happen in the code path under test. Run go test -race on code that uses shared state, and run go vet too; its copylocks check is specifically designed to catch accidental copies of lock values.
Defer unlock: tradeoffs worth knowing
You’ll see defer mu.Unlock() in nearly every Go codebase. It’s the right default — it guarantees the lock is released even if a panic occurs, and it’s easier to read.
But defer has a cost. It is small in normal application code, and in most code it doesn’t matter. In hot paths, such as tight loops running millions of iterations, you might want to unlock manually:
func (c *SafeCounter) IncrementHot() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
The real risk with skipping defer is forgetting to unlock on an early return. If your function has multiple exit points, use defer. The performance difference won’t matter in 99% of cases. I wrote more about this tradeoff in How to defer in Golang like a pro.
Never copy a mutex
This is one of the easiest mutex mistakes to miss in Go. The sync package documents that a mutex must not be copied after first use. If you pass a struct containing a live mutex by value, the copy and the original become independent locks guarding state that may no longer be coherent.
// DON'T DO THIS
func process(c SafeCounter) {
c.Increment() // This locks a copy. The original is unprotected.
}
Go has a tool for this: go vet reports a warning when a value containing a lock is copied. Run go vet in CI. It catches a class of mistakes that can be hard to spot in review.
sync.RWMutex: when reads dominate
A sync.RWMutex allows multiple concurrent readers via RLock() / RUnlock(), while writers still get exclusive access via Lock() / Unlock(). Sounds great on paper. There’s a catch.
RWMutex has more bookkeeping than a plain Mutex. If your read-to-write ratio is low, a plain Mutex is often faster and easier to reason about. The crossover point depends on contention, CPU count, and how long you hold the lock. As a rough guide, reach for RWMutex when reads heavily outnumber writes and the read-side critical section does enough work to justify the extra coordination.
type Config struct {
mu sync.RWMutex
settings map[string]string
}
func (c *Config) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.settings[key]
return val, ok
}
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.settings[key] = value
}
There’s also a scheduling detail worth knowing. Go’s RWMutex blocks new readers when a writer is already waiting on readers, so the writer can eventually acquire the lock. You can see that in src/sync/rwmutex.go: a writer announces that it is pending, then waits for active readers to drain. That is the behavior you want for progress, but it also means a pending writer can add latency for new readers.
Choosing between mutexes, atomics, and channels
This decision comes up constantly. Here’s how I’d break it down:
Use sync/atomic when you’re protecting a single value — a counter, a flag, a pointer — and the operation is a simple load, store, or add. Atomics have no lock overhead and no deadlock risk. They can’t protect multi-field updates or invariants across variables.
Use sync.Mutex when you need to protect a group of fields, a map, a slice, or any operation where multiple steps must happen atomically. Most shared state in Go programs falls here.
Use channels when you’re coordinating goroutines — sending work, signaling completion, building pipelines. Channels are about communication. Mutexes are about shared memory. The Go proverb “share memory by communicating” points toward channels, but it doesn’t mean mutexes are wrong. A map guarded by a mutex is often simpler and faster than funneling every read and write through a channel. For more on how channels work, see Go channels: send, receive, close, and range without surprises.
A useful heuristic: if you’d describe the problem as “protect this data,” use a mutex. If you’d describe it as “coordinate these goroutines,” use a channel.
Scope the lock tightly
A common mistake is holding a mutex while doing I/O or calling into slow functions:
// Bad: holding the lock during a network call
func (s *Service) Fetch(key string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return http.Get(s.urls[key]) // Every other goroutine blocks here
}
Instead, copy what you need from the shared state, release the lock, then do the slow work:
func (s *Service) Fetch(key string) (string, error) {
s.mu.Lock()
url := s.urls[key]
s.mu.Unlock()
resp, err := http.Get(url)
// ...
}
This reduces contention dramatically. The lock is held only long enough to read the shared map, instead of across the whole network call.
Embedding vs. named fields
You’ll see both patterns. Embedding (sync.Mutex as an unnamed field) promotes Lock() and Unlock() to the struct, which can be confusing — callers of your struct can lock it directly. A named field (mu sync.Mutex) keeps locking internal. Prefer the named field unless you have a specific reason to export the locking behavior.
Context cancellation and mutexes
If a goroutine is waiting on a mutex, it can’t be cancelled via context. Lock() blocks until the mutex is available — there’s no LockContext(). If you need cancellable locking, you need a different pattern: a channel-based semaphore, or restructuring the work so goroutines don’t contend on the same lock.
Quick reference
| Scenario | Tool |
|---|---|
| Single counter or flag | sync/atomic |
| Protect a map or struct fields | sync.Mutex |
| Many readers, rare writes, heavy read work | sync.RWMutex |
| Coordinate goroutine lifecycle | Channels |
| Need cancellable waiting | Channels or semaphore.Weighted |
Mutexes are straightforward, but the details matter. Run go vet to catch copied locks. Run go test -race to catch data races. Keep critical sections short. And when you’re staring at sync.RWMutex wondering if it’s worth the complexity, benchmark it; the answer is often “not yet.”