The whiteboard and the marker
You are building a service that caches configuration data. Thousands of requests arrive every second to read the current settings. Once in a while, an admin updates a value. If you protect the cache with a standard mutex, every read blocks every other read. Your service serializes all traffic and grinds to a halt. You need a way to let readers share access while keeping writers exclusive.
Go provides sync.RWMutex for this pattern. The name breaks down to "Read-Write Mutex." A standard mutex blocks everyone. An RWMutex allows multiple readers to proceed simultaneously, but a writer gets exclusive access. Think of a whiteboard in a busy office. Ten people can stand around and read the notes at the same time. No problem. The moment someone grabs a marker to write, everyone else steps back. The writer works alone. When the marker goes down, the crowd returns.
Goroutines are cheap. Locks are coordination. Use the lock that matches the shape of your access.
How RWMutex works
sync.RWMutex tracks two states: active readers and exclusive writers. When a goroutine calls RLock, the mutex increments a reader count and allows the goroutine to proceed. If another goroutine calls RLock immediately, it also succeeds. Both readers run in parallel.
When a goroutine calls Lock, the mutex waits until all current readers finish. Then it blocks any new readers. The writer runs alone. Unlock releases the writer and wakes up waiting readers or writers.
The runtime manages the state internally. You do not need to track counts manually. The mutex handles the bookkeeping.
Minimal example
Here is the skeleton of a read-heavy store. The struct holds a map and a mutex. Methods use RLock for reads and Lock for writes.
package main
import (
"sync"
)
// Cache holds a map protected by a read-write mutex.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// Get retrieves a value by key.
func (c *Cache) Get(key string) string {
// RLock allows concurrent readers.
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
// Set updates a value.
func (c *Cache) Set(key, value string) {
// Lock blocks all readers and other writers.
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
The receiver name c matches the type Cache. Go convention prefers short receiver names, usually one or two letters. The mutex field mu is lowercase. Go convention keeps lock fields private. Exporting a mutex invites external code to lock your struct, which breaks encapsulation and creates race conditions you cannot debug.
Defer the unlock. Always.
Walkthrough
When Get calls RLock, the mutex records that a reader is active. If another goroutine calls Get, it also acquires the read lock. Both proceed. The map reads happen in parallel.
When Set calls Lock, the mutex checks for active readers. If readers exist, the writer waits. Once all readers call RUnlock, the writer acquires the lock. New readers that arrive while the writer holds the lock must wait.
Maps are not thread-safe. If you iterate a map without a lock while another goroutine writes, the runtime panics with fatal error: concurrent map read and map write. The RWMutex prevents this by serializing access. Holding the lock guarantees the map structure remains stable during the operation.
You cannot upgrade a read lock to a write lock. If you need to read a value, compute a new value, and write it back, you must acquire the write lock from the start. Trying to call Lock while holding RLock deadlocks immediately. The runtime detects this and crashes with fatal error: all goroutines are asleep - deadlock!. The mutex knows which goroutine holds it and refuses to let the same goroutine re-enter. This pattern forces you to think about atomicity. If the operation must be atomic, use Lock.
Locks protect state. Channels coordinate flow. Pick the tool that matches the shape of your data.
Realistic example
Embedding the mutex is a common Go idiom. It lets you call mu.Lock() directly on the struct. This reduces boilerplate in methods.
package main
import (
"sync"
)
// Store embeds the mutex so methods can call mu.Lock() directly.
type Store struct {
sync.RWMutex
items map[int]float64
}
// Average calculates the mean of all values.
func (s *Store) Average() float64 {
s.mu.RLock()
defer s.mu.RUnlock()
// Sum values while holding the read lock.
var total float64
count := 0
for _, v := range s.items {
total += v
count++
}
if count == 0 {
return 0
}
return total / float64(count)
}
Embedding exposes the methods of sync.RWMutex on Store. This includes Lock, Unlock, RLock, and RUnlock. It also exposes the Locker and RLocker interfaces. This is usually fine, but be aware that external code can now lock your struct directly. If you need to prevent external locking, hold the mutex as a field instead of embedding it.
Iteration order in maps is random. Do not rely on order. If you need sorted keys, collect them first or use a different structure. The read lock protects the map from concurrent modification, but it does not stabilize the iteration order.
Embed the mutex for convenience, but watch your interfaces.
Pitfalls and costs
RWMutex is not free. Managing reader counts adds overhead. If reads are trivial, the lock overhead dominates the operation. A standard mutex can be faster when reads are nanoseconds long because it avoids the bookkeeping of tracking multiple readers.
Writer starvation is a risk. If readers arrive constantly, a writer might wait indefinitely. Go's runtime mitigates this by giving writers priority once they arrive. Heavy read traffic can still delay writes. If your workload has frequent writes, RWMutex loses its advantage. Readers block often, and the complexity does not pay off.
Deadlocks happen when goroutines wait for each other. The compiler cannot detect deadlocks. The runtime panics. Use defer to ensure locks release. If a function has multiple return paths, defer guarantees the lock releases. Forgetting defer is the fastest way to introduce a deadlock that only appears under load.
The worst goroutine bug is the one that never logs.
When to use RWMutex
Use sync.RWMutex when reads vastly outnumber writes and the read operation is expensive enough to benefit from concurrency.
Use sync.Mutex when writes are frequent or reads are trivial: the overhead of managing reader state often outweighs the benefit.
Use atomic operations from sync/atomic when you only need to update a single integer or pointer and do not need to protect a larger data structure.
Use channels when the data flow is producer-consumer rather than shared state: channels coordinate goroutines without explicit locking.
Locks protect state. Channels coordinate flow. Pick the tool that matches the shape of your data.