Chapter 9: Mutexes
Chapters 3–7 taught channel-based coordination—goroutines communicate by passing data through channels, transferring ownership with each send. Chapter 8 showed why unsynchronized access to shared mutable state causes undefined behavior.
Where multiple goroutines need to access the same data structure:
// Illustrative snippet — not a complete program
// ✗ BROKEN: Data races on all fields
type Server struct {
requestCount int64
errorCount int64
activeConns int
}
func (s *Server) HandleRequest(
w http.ResponseWriter, r *http.Request,
) {
s.requestCount++ // DATA RACE
s.activeConns++ // DATA RACE
defer func() { s.activeConns-- }() // DATA RACE
if err := handle(w, r); err != nil {
s.errorCount++ // DATA RACE
}
}
// Illustrative snippet — not a complete program
// ✗ BROKEN: Data races on map and counters
type Cache struct {
entries map[string]*Entry
hits int
misses int
}
func (c *Cache) Get(key string) *Entry {
if entry, ok := c.entries[key]; ok { // DATA RACE: concurrent
// map read/write = crash
c.hits++ // DATA RACE
return entry
}
c.misses++ // DATA RACE
return nil
}
These are internal state protection problems. Multiple goroutines need to access the same data structure, and routing every access through a channel would be heavyweight—you’d need a dedicated goroutine managing each struct with request/response patterns.
A two-part contrast. Above, channels from chapters 3 to 7: goroutine A sends data through a channel to goroutine B, with ownership transferring on each send — the shape for pipelines, work distribution and signalling. Below, mutexes: one struct holding a mutex and its fields, with three goroutines reaching into it through methods. The data stays in one place and the mutex protects its internal state — the shape for caches, counters, pools and registries.
The Go proverb says: “Don’t communicate by sharing memory; share memory by communicating.” This guides you to prefer channels for coordination—but mutexes serve a different purpose: protection of state. The proverb isn’t “never use mutexes”—it’s “when goroutines need to coordinate, prefer passing messages over sharing memory.” Both primitives are idiomatic when used appropriately.
- When mutexes are the right tool (and when they’re not)
-
sync.Mutexsemantics:Lock(),Unlock(), and critical sections -
The
defer mu.Unlock()pattern and why it’s essential sync.RWMutexfor read-heavy workloads- Design patterns: the monitor pattern and hiding synchronization behind clean APIs
- Common mistakes: copying mutexes, forgetting to unlock, holding locks too long
- Deadlock analysis and prevention strategies—Chapter 10
-
Atomic operations (
sync/atomic)—Chapter 11 -
Other sync package types (
sync.Once,sync.Map,sync.Pool,sync.Cond)—Chapter 12
You should understand data races and the memory model from Chapter 8—particularly why unsynchronized access causes undefined behavior and how synchronization establishes happens-before relationships (§8.4).
The Problem Mutexes Solve
Before diving into when to use mutexes, let’s see the problem they solve:
// Illustrative snippet — not a complete program
// ✗ BROKEN: Data races
type Counter struct {
value int
}
func (c *Counter) Increment() {
c.value++ // DATA RACE: read-modify-write
}
func (c *Counter) Value() int {
return c.value // DATA RACE: unsynchronized read
}
func main() {
counter := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
counter.Increment()
})
}
wg.Wait()
fmt.Println("Final count:", counter.Value())
}
Expected: Final count: 1000
Actual: Varies each run (947, 983, 961…)
Race detector: Reports data race on
c.value
Recall Chapter 8’s three conditions for a data race:
-
Same memory location ✓ (multiple goroutines access
c.value) -
At least one write ✓ (
Incrementwrites,Valuereads) - No synchronization ✓ (no coordination)
The solution—add a mutex:
// Illustrative snippet — not a complete program
// ✓ FIXED: Mutex protects shared state
type Counter struct {
mu sync.Mutex // Ready to use—no initialization needed
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
v := c.value
c.mu.Unlock()
return v
}
The mutex eliminates condition #3: Lock/Unlock
provide synchronization, establishing happens-before relationships
(§8.4). Now only one goroutine can execute the critical section at
a time, reads see the most recent writes, and the race detector reports
no issues.
sync.Mutex has a useful zero value—it’s
ready to use without initialization. This is idiomatic Go:
// Illustrative snippet — not a complete program
counter := &Counter{} // mu is valid and unlocked
counter.Increment() // Works immediately
You’ll often see defer mu.Unlock() instead of
explicit Unlock() calls. §9.2 covers this pattern
in detail. The short version:
prefer defer for safety.
9.1 When to Use Mutexes
§1.2 introduced Go’s concurrency philosophy: “Share memory by communicating.” Channels are Go’s preferred coordination mechanism. So when should you reach for a mutex instead?
The answer is precise: use a mutex when you’re protecting internal state, not coordinating between goroutines.
The Decision Rule
Ask: “Am I coordinating work or protecting state?”
-
Coordinating work → Channels
- Passing data between goroutines
- Signaling completion or cancellation
- Distributing tasks to workers
- Building pipelines
-
Protecting state → Mutex
- Guarding struct fields from concurrent access
- Maintaining invariants across multiple fields
- Implementing thread-safe data structures
The distinction maps to different mental models:
Recall Chapter 2’s Fourth Question: “What data does it access?” When the answer is “shared mutable state,” you’ve identified potential data races. The follow-up question is: “How is that access synchronized?”
- Goroutines need to coordinate (pass data, signal events) → Channels
- Goroutines need to protect (guard shared fields) → Mutex
The decision rule in this section helps you choose the right synchronization mechanism.
Use Mutexes When
1. Protecting Struct Internal State
The canonical mutex use case: a struct with fields accessed by
multiple goroutines. Consider the Counter from the
opening of this chapter—a struct with a mutex protecting its
field. Why a mutex here, not a channel?
- No communication happening. We’re not passing data between goroutines—we’re protecting a field from concurrent modification.
- State lives in one place. The counter value stays in the struct—it’s not moving from goroutine A to goroutine B. Multiple goroutines visit the same location to read or modify it.
- Simple access pattern. Read a field, modify a field. Channels would add complexity without benefit.
A Counter struct holding a mutex and a value of 42, with three goroutines below it calling Increment, Increment and Value. All three reach the same struct; the mutex ensures only one of them operates on it at a time.
This Counter uses a mutex for teaching purposes. In production, the right choice depends on complexity:
Use atomic (Chapter 11) when: Single value
(int64, bool, pointer), simple
operations (Add, Load,
Store, CompareAndSwap), no relationships
with other fields.
Use mutex when: Multiple related fields, complex operations (if-then-else logic), maintaining invariants across fields.
// Illustrative snippet — not a complete program
// ✓ Atomic: Single independent counter
var requests atomic.Int64
requests.Add(1)
// ✓ Mutex: Counter with related metadata
type Counter struct {
mu sync.Mutex
value int
lastReset time.Time // Invariant: updated together with value
}
For a single integer counter like above,
atomic.Int64 is more efficient—lock-free and
faster. Use mutexes when you need to protect complex state or maintain
invariants across multiple fields.
2. Guarding Invariants
Beyond single-field access, mutexes excel at protecting invariants—relationships between fields that must remain consistent:
// Illustrative snippet — not a complete program
type Transaction struct {
Type string
Amount int
Time time.Time
}
type Account struct {
mu sync.Mutex
balance int
transactions []Transaction
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
// Invariant: transactions must reflect all balance changes
a.balance += amount
a.transactions = append(a.transactions, Transaction{
Type: "deposit",
Amount: amount,
Time: time.Now(),
})
// Both fields updated atomically—invariant preserved
}
The mutex ensures balance and
transactions are always consistent. Without it, another
goroutine might read balance after increment but before
the transaction is appended—observing an impossible state where
the balance doesn’t match the transaction history.
3. Thread-Safe Containers
Implementing caches, registries, or any shared lookup structure:
// Illustrative snippet — not a complete program
type Cache struct {
mu sync.Mutex // RWMutex for reads: §9.3
data map[string][]byte
}
func NewCache() *Cache {
return &Cache{data: make(map[string][]byte)}
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
Why mutex? Random access patterns. A channel-based solution would require a dedicated manager goroutine with request/response overhead—heavyweight for simple lookups.
Chapter 12 covers sync.Map in detail, but
here’s quick guidance:
Use sync.Map when: Keys are write-once, read-many. Disjoint key sets per goroutine. Extreme read-heavy (99%+ reads).
Use Mutex+Map when: Keys are frequently updated. Need atomic operations across multiple keys. Iteration is common. Need RWMutex’s explicit read/write control.
Default choice: Mutex+Map is clearer and performs well for most use cases.
Use Channels When
1. Transferring Ownership
Producer creates data, consumer takes ownership:
// Illustrative snippet — not a complete program
func producer(out chan<- *Job) {
for {
job := createJob() // Producer owns this
out <- job // Ownership transfers
// Producer must not use 'job' after send
}
}
func consumer(in <-chan *Job) {
for job := range in {
process(job) // Consumer now owns it
}
}
2. Distributing Work
Coordinating multiple workers:
// Illustrative snippet — not a complete program
func worker(id int, jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- process(job)
}
}
3. Signaling and Coordination
Broadcasting events to multiple goroutines:
// Illustrative snippet — not a complete program
func worker(id int, done <-chan struct{}, jobs <-chan Job) {
for {
select {
case <-done:
return // Shutdown broadcast received
case job := <-jobs:
process(job)
}
}
}
// close(done) wakes all workers simultaneously
The Anti-Patterns
Forcing Channels Where Mutexes Fit
// Illustrative snippet — not a complete program
// ✗ AWKWARD: Channel for simple state protection
type Counter struct {
inc chan struct{}
value chan chan int
done chan struct{}
}
func NewCounter() *Counter {
c := &Counter{
inc: make(chan struct{}),
value: make(chan chan int),
done: make(chan struct{}),
}
go func() {
count := 0
for {
select {
case <-c.inc:
count++
case reply := <-c.value:
reply <- count
case <-c.done:
return
}
}
}() // Dedicated goroutine just to protect an int!
return c
}
func (c *Counter) Increment() { c.inc <- struct{}{} }
func (c *Counter) Value() int {
reply := make(chan int)
c.value <- reply
return <-reply // Allocation + channel ops for a simple read
}
func (c *Counter) Close() { close(c.done) }
// All this machinery for a simple integer!
// Compare to the mutex version: simpler, clearer, less overhead.
This requires a dedicated goroutine, multiple channels, allocations per read, and shutdown logic. The mutex version is direct: lock, modify, unlock.
If you’re creating a goroutine solely to serialize access, passing functions through channels, or building request/response protocols for simple operations—use a mutex instead.
Forcing Mutexes Where Channels Fit
// Illustrative snippet — not a complete program
// ✗ AWKWARD: Mutex for work distribution
type WorkQueue struct {
mu sync.Mutex
jobs []Job
}
func (wq *WorkQueue) TryGet() (Job, bool) {
wq.mu.Lock()
defer wq.mu.Unlock()
if len(wq.jobs) == 0 {
return Job{}, false
}
job := wq.jobs[0]
wq.jobs = wq.jobs[1:]
return job, true
}
// Workers must poll:
func worker(wq *WorkQueue) {
for {
job, ok := wq.TryGet()
if !ok {
time.Sleep(10 * time.Millisecond) // Wastes CPU
continue
}
process(job)
}
}
This polling approach has two problems: (1) wastes CPU checking for work repeatedly, and (2) adds latency—up to 10ms before noticing new work arrives. Channels block efficiently and wake immediately:
// Illustrative snippet — not a complete program
// ✓ CLEAR: Channel for work distribution
func worker(jobs <-chan Job) {
for job := range jobs { // Blocks until work arrives
process(job)
}
}
Combining Both
Real systems use both—channels for communication, mutexes for protection:
// Illustrative snippet — not a complete program
type MetricsCollector struct {
// Channels: Communication
events chan Event
shutdown chan struct{}
// Mutex: State protection
mu sync.Mutex
counters map[string]int64
}
func NewMetricsCollector() *MetricsCollector {
return &MetricsCollector{
events: make(chan Event, 100),
shutdown: make(chan struct{}),
counters: make(map[string]int64),
}
}
func (mc *MetricsCollector) Run() {
for {
select {
case event := <-mc.events:
mc.recordEvent(event)
case <-mc.shutdown:
return
}
}
}
// recordEvent and GetCount may be called concurrently
// from different goroutines, so both must hold the lock
// to prevent data races on the counters map.
func (mc *MetricsCollector) recordEvent(event Event) {
mc.mu.Lock()
mc.counters[event.Name]++
mc.mu.Unlock()
}
// GetCount can be called from any goroutine to read current metrics.
func (mc *MetricsCollector) GetCount(name string) int64 {
mc.mu.Lock()
defer mc.mu.Unlock()
return mc.counters[name]
}
func (mc *MetricsCollector) Record(event Event) {
select {
case mc.events <- event:
default:
// Drop if buffer full
}
}
events channelshutdown channelmu and counters
Each primitive serves its natural purpose. Forcing one paradigm for both jobs would make the code awkward.
A two-column checklist. Reach for a mutex when several goroutines touch the same struct fields, when you must update several fields atomically to hold an invariant, when access is random as in a cache or registry, when callers need an immediate answer, or when the critical section is under a microsecond. Reach for a channel when passing data from producer to consumer, when one goroutine creates and another processes, when you need backpressure or queuing, when building a pipeline, or when signalling done, ready or cancel. The tie-breaker: ask where the data lives — staying in one struct means mutex, flowing between goroutines means channel.
Common Mistakes
Fighting the right tool
Use each for its strength
Requires polling, wastes CPU
Use channels
Goroutine + channel overhead
Use mutex or atomic
Blocks all other operations
Release lock before I/O (§9.2)
Extra goroutines, complex shutdown
Protect with mutex
Key Takeaways
- Mutexes protect internal state; channels move data between goroutines—this is the core distinction
- Both are valid Go—the proverb expresses preference for communication style, not prohibition of mutexes. Real systems use both.
- Awkwardness is a signal—if channels require dedicated goroutines for simple state, use a mutex
-
sync.Mutexhas a useful zero value—no initialization required - Choose atomic for single values, mutex for complex state—Chapter 11 covers atomics in detail
9.2 sync.Mutex Mechanics
The Mutex Contract
A sync.Mutex provides
mutual exclusion—the guarantee that only one
goroutine can hold the lock at any moment. Its two fundamental
methods:
// Illustrative snippet — not a complete program
var mu sync.Mutex
mu.Lock() // Acquire exclusive access
// ... critical section ...
mu.Unlock() // Release exclusive access
Recall Chapter 8: A data race requires three
conditions—same memory location, at least one write, and no
synchronization. Mutexes eliminate the third condition by establishing
synchronization through Lock/Unlock, making
concurrent access safe.
-
Lock()on unlocked mutex: acquires immediately, returns -
Lock()on locked mutex: blocks until holder callsUnlock() -
Unlock()on locked mutex: releases, wakes one waiter -
Unlock()on unlocked mutex: panic!
A three-column timeline of two goroutines and the mutex state between them. A calls Lock and the mutex becomes locked by A. B calls Lock and blocks. A works, then calls Unlock; the mutex becomes unlocked and immediately locked by B, which had been waiting. B works and unlocks. B's Lock blocked until A's Unlock, so A completes entirely before B begins.
Zero Value Is Ready to Use
Unlike many languages that require explicit lock initialization,
Go’s sync.Mutex is ready to use immediately:
// Illustrative snippet — not a complete program
type Counter struct {
mu sync.Mutex // Zero value is valid
value int
}
// Works immediately—no constructor required
counter := &Counter{}
counter.mu.Lock()
counter.value++
counter.mu.Unlock()
This follows Go’s principle that zero values should be useful.
// Illustrative snippet — not a complete program
// ✗ DON’T: Pointer to mutex is unnecessary
type Counter struct {
mu *sync.Mutex // Extra allocation
value int
}
func NewCounter() *Counter {
return &Counter{
mu: &sync.Mutex{}, // Unnecessary
}
}
// ✓ DO: Embed directly (zero value works)
type Counter struct {
mu sync.Mutex // Ready to use
value int
}
Lock() and Unlock() Semantics
Lock() Semantics
Lock() does one of two things:
- If unlocked: Acquires the lock immediately and returns
- If locked: Blocks until the lock becomes available, then acquires it
// Illustrative snippet — not a complete program
mu.Lock() // Acquires immediately (starts unlocked)
// ... critical section ...
mu.Unlock()
// From another goroutine:
mu.Lock() // Blocks if someone else holds the lock
Blocking means the goroutine is suspended—it consumes no CPU while waiting. When the lock is released, one waiting goroutine is woken and acquires it.
A timeline of four goroutines contending for one mutex. At T0 the first acquires it. At T1 the other three all call Lock and block. When the holder unlocks at T3, exactly one of the waiters acquires and the rest stay blocked. The pattern repeats: each goroutine gets exclusive access in turn.
Key property: Lock() blocks indefinitely
until it acquires the mutex. There’s no timeout—if the
mutex is never unlocked, Lock()
blocks forever.
Unlike channel operations in select, mutex
acquisition cannot be cancelled with context.Context.
If you need cancellable lock acquisition, you must use channels
with select and timeout, or restructure to avoid
long-held locks. This is one reason to prefer channels for
coordination that might need cancellation.
Go’s mutex uses a two-mode system to balance throughput and fairness:
- Normal mode: New arrivals compete with waiting goroutines (better throughput)
- Starvation mode: After ~1ms waiting, lock hands off directly to longest waiter
This ensures bounded wait times while optimizing for the common uncontended case. Don’t rely on lock acquisition order for application logic—use a queue or explicit sequencing if you need ordered processing.
Unlock() Semantics
Unlock() releases the mutex. If goroutines are waiting,
one is chosen to proceed:
// Illustrative snippet — not a complete program
mu.Lock()
// ... critical section ...
mu.Unlock() // Releases lock, wakes a waiter
Critical rules:
1. Unlocking an unlocked mutex panics:
// Illustrative snippet — not a complete program
var mu sync.Mutex
mu.Unlock() // panic: sync: unlock of unlocked mutex
2. Each Lock() needs exactly one
Unlock():
// Illustrative snippet — not a complete program
mu.Lock()
mu.Unlock()
mu.Unlock() // panic: sync: unlock of unlocked mutex
3. The same goroutine that calls Lock() must call
Unlock().
While the runtime doesn’t track ownership, violating this
convention makes code impossible to reason about and maintain.
Critical Sections
The code between Lock() and Unlock() is
called the critical section—the region where
you have exclusive access to protected data:
// Illustrative snippet — not a complete program
func (c *Counter) Increment() {
c.mu.Lock() // ─┐
c.value++ // ├─ Critical section
c.mu.Unlock() // ─┘
}
Properties of a critical section:
From Chapter 8.1: unsynchronized reads cause data races. Even read-only methods must acquire the lock. Without it, a read could see stale, torn, or inconsistent values due to CPU caches and compiler optimizations.
// Illustrative snippet — not a complete program
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value // Reads need synchronization too
}
Three goroutines calling Increment at the same time. The first holds the lock and increments; the second and third are shown as blocked bars until it releases, then the second runs and the third waits again. Solid bars mark holding the lock and shaded bars mark blocking. Despite the calls being concurrent, the increments happen one at a time.
The Happens-Before Guarantee
§8.4 introduced happens-before relationships. Mutexes establish them:
Unlock() happens-before any subsequent
Lock() on the same mutex.
All writes before Unlock() are visible to code after
the subsequent Lock().
// Illustrative snippet — not a complete program
var (
mu sync.Mutex
data1 string
data2 int
)
// Goroutine A
mu.Lock()
data1 = "hello"
data2 = 42
mu.Unlock() // Happens-before...
// Goroutine B
mu.Lock() // ...this Lock()
fmt.Println(data1) // Guaranteed: "hello"
fmt.Println(data2) // Guaranteed: 42
mu.Unlock()
Without the mutex, goroutine B might see empty/zero values due to CPU caches or compiler optimizations. The mutex ensures visibility, not just exclusion.
Two goroutine rows. The first locks, writes, and unlocks. An arrow labeled happens-before runs from that unlock down to the second goroutine's lock, after which it reads. Every write made before the unlock is visible after the subsequent lock.
- Mutual exclusion: Serialize access to protected data
- Memory ordering: Establish happens-before, making writes visible
Both are necessary. Without memory ordering, even serialized access could see stale cached values.
The defer mu.Unlock() Pattern
The most important mutex pattern:
always use defer for Unlock().
// Illustrative snippet — not a complete program
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // Runs when function returns
c.value++
}
Why defer? It guarantees the mutex is
released regardless of how the function exits.
Multiple Return Paths
Without defer, you must remember to unlock on every path:
// Illustrative snippet — not a complete program
// ✗ FRAGILE: Manual unlock on each path
func (a *Account) Withdraw(amount int) error {
a.mu.Lock()
if amount <= 0 {
a.mu.Unlock() // Don’t forget!
return errors.New("invalid amount")
}
if a.balance < amount {
a.mu.Unlock() // Don’t forget!
return errors.New("insufficient funds")
}
a.balance -= amount
a.mu.Unlock() // Don’t forget!
return nil
}
// Illustrative snippet — not a complete program
// ✓ ROBUST: defer handles all paths
func (a *Account) Withdraw(amount int) error {
a.mu.Lock()
defer a.mu.Unlock() // Handles ALL paths
if amount <= 0 {
return errors.New("invalid amount")
}
if a.balance < amount {
return errors.New("insufficient funds")
}
a.balance -= amount
return nil
}
One forgotten Unlock() → permanent
deadlock.
All future lock attempts block forever.
With defer mu.Unlock(), you can return errors
freely—the lock is always released. Without
defer, you’d need
mu.Unlock() before each
return—error-prone and easy to forget.
Panic Safety
defer executes during stack unwinding, even if a panic
occurs:
// Illustrative snippet — not a complete program
func (c *Cache) Process(key string) {
c.mu.Lock()
defer c.mu.Unlock()
value := c.data[key]
result := transform(value) // What if this panics?
c.data[key] = result
}
Without defer, the panic would leave the mutex
locked—breaking all future operations.
A side-by-side comparison of the same function with and without defer. Without it, a panic between the lock and the manual unlock means the unlock line is never reached, so the mutex stays locked forever and every waiter blocks. With defer, the panic still happens but the unlock runs anyway, so the mutex is released and the program can recover.
defer mu.Unlock() prevents a permanently locked
mutex—but the protected state may be
inconsistent. If a panic interrupts a multi-step
update, the data is left half-modified. If you recover from the
panic, subsequent operations may operate on corrupted state. Plan
recovery carefully: either restore invariants before continuing,
or let the panic propagate.
The Standard Pattern
Make this your default:
// Illustrative snippet — not a complete program
func (x *Type) Method() {
x.mu.Lock()
defer x.mu.Unlock()
// All work here
}
Lock and defer on adjacent lines. This makes the pattern instantly recognizable and auditable.
When NOT to Use defer
In specific cases, explicit unlock is appropriate:
Releasing Before Slow Operations
Don’t hold locks during I/O or expensive computation:
// Illustrative snippet — not a complete program
func (c *Cache) GetOrLoad(key string) ([]byte, error) {
c.mu.Lock()
if value, ok := c.data[key]; ok {
c.mu.Unlock() // Release before return
return value, nil
}
c.mu.Unlock() // Release before slow I/O
// Load without holding lock
value, err := loadFromDatabase(key)
if err != nil {
return nil, err
}
// Re-acquire to store
c.mu.Lock()
c.data[key] = value
c.mu.Unlock()
return value, nil
}
Using defer here would hold the lock during the database
call, blocking all other cache access.
This code prevents data races (mutex protects all
map access) but has a race condition
(§8.2): if two goroutines both see “key not
found,” both will load from the database—duplicate
work. §9.4 covers the double-check pattern and other
solutions. For expensive operations, use
golang.org/x/sync/singleflight.
Splitting into smaller functions makes the code cleaner—each
function has one clear responsibility and its own
defer. This improves readability but doesn’t
eliminate the race condition mentioned above.
// Illustrative snippet — not a complete program
func (c *Cache) GetOrLoad(key string) ([]byte, error) {
if value, ok := c.tryGet(key); ok {
return value, nil
}
return c.loadAndCache(key)
}
func (c *Cache) tryGet(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.data[key]
}
func (c *Cache) loadAndCache(key string) ([]byte, error) {
value, err := loadFromDatabase(key)
if err != nil {
return nil, err
}
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
return value, nil
}
Performance-Critical Tight Loops
defer costs almost nothing on this baseline. Go 1.14
introduced open-coded defers, and in this shape the difference is
under a nanosecond — about 7% of one
Lock/Unlock pair. In a genuinely hot path
you may still want the explicit form, but hoist it for the locking,
not for the defer:
// Illustrative snippet — not a complete program
func (c *Counter) IncrementHot() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
Don’t avoid defer without measurements proving
it matters. The safety of defer far outweighs
nanoseconds in most code.
Non-Reentrant Mutexes and TryLock
Go Mutexes Are Not Reentrant
A goroutine cannot lock a mutex it already holds:
// Illustrative snippet — not a complete program
func (a *Account) Transfer(to *Account, amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.withdraw(amount) // DEADLOCK!
}
func (a *Account) withdraw(amount int) {
a.mu.Lock() // DEADLOCK: already locked!
defer a.mu.Unlock()
a.balance -= amount
}
A self-deadlock timeline. At T0 the method acquires the lock and schedules its deferred unlock. At T2 it calls a helper which, at T3, tries to acquire the same lock. The goroutine now owns the lock and is blocked waiting for itself to release it — an instant, permanent deadlock, because the deferred unlock cannot run until the function returns and the function cannot return until the lock is acquired.
Solution: Use internal helpers that assume the lock is held:
// Illustrative snippet — not a complete program
// Public: acquires lock
func (a *Account) Withdraw(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.withdrawLocked(amount)
}
// Private: caller must hold lock
func (a *Account) withdrawLocked(amount int) {
a.balance -= amount
}
Suffix Locked indicates “caller must hold the
lock.” This makes the contract explicit during code review.
TryLock: Non-Blocking Lock Attempts
Go 1.18 added TryLock() for non-blocking lock attempts:
// Illustrative snippet — not a complete program
if mu.TryLock() {
defer mu.Unlock()
// Got the lock—proceed
} else {
// Lock was held, didn’t wait
}
When TryLock is appropriate:
- Lock-free fast paths with fallback to slow path
- Best-effort operations (metrics collection, cleanup)
- Deadlock avoidance in specific scenarios
- Graceful degradation under load
When TryLock is WRONG (most cases):
// Illustrative snippet — not a complete program
// ✗ WRONG: Polling with TryLock
for !mu.TryLock() {
time.Sleep(10 * time.Millisecond)
}
// ✓ CORRECT: Just block
mu.Lock() // Goroutine parks, wakes when available
TryLock() often leads to polling patterns (wastes
CPU), complex retry logic, and silently skipping critical
sections. Rule: If you’re going to keep
trying until you get the lock, just use Lock().
TryLock is for when you have a meaningful alternative path.
Keep Critical Sections Short
Long critical sections reduce concurrency—every waiting goroutine is blocked:
// Illustrative snippet — not a complete program
// ✗ BAD: Holds lock during slow operation
func (c *Cache) GetOrCompute(key string) Value {
c.mu.Lock()
defer c.mu.Unlock()
if val, ok := c.data[key]; ok {
return val
}
val := expensiveComputation(key)
c.data[key] = val // Lock held entire time!
return val
}
Target: Complete in microseconds (<100μs), not milliseconds. If it might take >100μs, release the lock first.
Do: Read/write shared fields, check invariants, update related data atomically.
Don’t: Perform I/O operations, make network calls, do heavy computation, call functions you don’t control, acquire other locks (deadlock risk—Chapter 10).
Complete Runnable Example
Here’s a complete, working example demonstrating all the mutex mechanics covered so far:
// Complete Example: Thread-Safe Counter
// Run: go run -race counter.go
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := &Counter{}
var wg sync.WaitGroup
// Launch 1000 goroutines, each incrementing 100 times
for i := 0; i < 1000; i++ {
wg.Go(func() {
for j := 0; j < 100; j++ {
counter.Increment()
}
})
}
wg.Wait()
fmt.Printf("Final count: %d (expected: 100000)\n",
counter.Value())
}
What this demonstrates:
- Zero value mutex (no initialization needed)
defer mu.Unlock()pattern- Both reads and writes protected
- Correct concurrent behavior verified by race detector
Common Mistakes
Mistake 1: Copying Mutexes
Mutexes must never be copied. Copying creates a new, independent mutex:
// Illustrative snippet — not a complete program
// ✗ WRONG: Value receiver copies the mutex
func (c Counter) Value() int {
c.mu.Lock() // Locks the COPY’s mutex
defer c.mu.Unlock()
return c.value
}
// ✓ CORRECT: Pointer receiver
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
Rule: Always use pointer receivers for methods on structs containing mutexes.
go vet CATCHES THIS
Run go vet to detect mutex copying before you ever
run it.
Mistake 2: defer in a Loop
defer IN LOOPS CAUSES IMMEDIATE DEADLOCK
defer runs when the function returns, not
when the loop iteration ends. Combined with non-reentrant mutexes,
this causes instant permanent deadlock on the
second iteration. The program just hangs silently—no panic,
no error message.
This bug ships to production constantly. If you
see defer inside a for loop,
that’s almost always wrong.
// Illustrative snippet — not a complete program
// ✗ CRITICAL BUG: Deadlocks on iteration 2
func (s *Store) ProcessItems(items []Item) {
for _, item := range items {
s.mu.Lock()
defer s.mu.Unlock() // Schedules for END OF FUNCTION
s.process(item)
}
// All defers would execute here—but we never get here
}
What actually happens:
A trace of defer inside a loop. On the first iteration Lock succeeds and the deferred Unlock is scheduled — but scheduled for when the function returns, not when the iteration ends. On the second iteration Lock is called while the first iteration's lock is still held, so the goroutine blocks forever waiting for itself. If every goroutine is blocked the runtime prints a fatal error saying all goroutines are asleep; if any other goroutine is still running, the program simply hangs with no message.
Two correct approaches:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Helper function scopes the defer
func (s *Store) ProcessItems(items []Item) {
for _, item := range items {
s.processItem(item)
}
}
func (s *Store) processItem(item Item) {
s.mu.Lock()
defer s.mu.Unlock()
s.process(item)
}
// ✓ ALSO CORRECT: Manual unlock in loop
func (s *Store) ProcessItems(items []Item) {
for _, item := range items {
s.mu.Lock()
s.process(item)
s.mu.Unlock()
}
}
Mistake 3: Not Locking Reads
Even read-only access needs synchronization:
// Illustrative snippet — not a complete program
// ✗ WRONG: Unsynchronized read
func (c *Counter) Value() int {
return c.value // DATA RACE
}
// ✓ CORRECT: Lock reads too
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
For a single integer counter, atomic.Int64
(Chapter 11) is often better—no lock overhead, and reads
don’t block other reads. Use mutexes when protecting complex
state or maintaining invariants across multiple fields.
Value receiver copies the struct including the mutex—the copy has its own independent lock that protects nothing
Always use pointer receivers for types containing mutexes. Run
go vet to catch this
defer inside a for loop
Silent deadlock on second iteration—defer
runs at function return, not loop iteration end
Extract loop body into a helper function, or use explicit
Unlock() in the loop
Data race—unsynchronized reads may see stale, torn, or inconsistent values
Lock ALL access to shared data—both reads and writes need synchronization
Summary
Lock()Unlock()TryLock()defer mu.Unlock()Key Takeaways
-
Lock()blocks until acquired—the goroutine parks, consuming no CPU -
Always use
defer mu.Unlock()immediately afterLock()—handles all exit paths, panics, and prevents double-unlock - Mutex provides happens-before—not just exclusion, but visibility guarantees
-
Go mutexes are not reentrant—use
Lockedsuffix for internal helpers - Keep critical sections short—don’t hold locks during I/O or computation (<100μs target)
-
Never copy mutexes—use pointer receivers,
run
go vet - Lock ALL access—both reads and writes need protection
-
Watch for
deferin loops—causes silent deadlock on second iteration
Next: §9.3 covers
sync.RWMutex—a variant that allows multiple
concurrent readers while maintaining exclusive write access, ideal for
read-heavy workloads.
9.3 sync.RWMutex: Read-Write Locks
The Problem: Serialized Readers
Consider a configuration cache read by many goroutines:
// Illustrative snippet — not a complete program
type ConfigCache struct {
mu sync.Mutex
config Config
}
func (c *ConfigCache) Get() Config {
c.mu.Lock()
defer c.mu.Unlock()
return c.config // Just reading—but blocks all other readers!
}
func (c *ConfigCache) Update(cfg Config) {
c.mu.Lock()
defer c.mu.Unlock()
c.config = cfg
}
With sync.Mutex, concurrent Get() calls
serialize—each waits for the previous to complete, even though
they’re all just reading:
Three readers under a plain mutex. Each acquires, reads, and releases in turn while the other two are shown blocked. Three reads that could have run at the same time execute one after another instead.
For read-heavy workloads, this serialization is a bottleneck.
The Solution: Read-Write Locks
sync.RWMutex distinguishes between readers and writers:
- Multiple readers can hold the lock simultaneously
- Writers get exclusive access (no readers, no other writers)
// Illustrative snippet — not a complete program
type ConfigCache struct {
mu sync.RWMutex // Changed from sync.Mutex
config Config
}
func (c *ConfigCache) Get() Config {
c.mu.RLock() // Read lock—allows concurrent readers
defer c.mu.RUnlock()
return c.config
}
func (c *ConfigCache) Update(cfg Config) {
c.mu.Lock() // Write lock—exclusive access
defer c.mu.Unlock()
c.config = cfg
}
Now concurrent reads proceed in parallel:
The same three readers under an RWMutex. All three acquire a read lock, read, and release simultaneously — the bars overlap rather than queue. Two of them are marked as concurrent.
Recall from Chapter 8: a data race requires at least one write.
Concurrent reads don’t write, so they don’t conflict with
each other. sync.RWMutex leverages this—readers can
proceed concurrently. Writers still need exclusive access because they
conflict with everyone.
The RWMutex Contract
sync.RWMutex has four primary methods:
RLock()RUnlock()Lock()Unlock()A state machine with three states. From unlocked, RLock moves to read-locked with N readers, and RUnlock returns to unlocked once the reader count reaches zero; a further RLock from read-locked simply adds another reader. From unlocked, Lock moves to write-locked with exactly one writer, and Unlock returns. The rules underneath: multiple readers or one writer but never both, a writer waits for all readers to release, and new readers wait once a writer is waiting.
Basic Usage Pattern
// Illustrative snippet — not a complete program
type Cache struct {
mu sync.RWMutex
data map[string][]byte
}
// Read operation: use RLock/RUnlock
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// Write operation: use Lock/Unlock
func (c *Cache) Set(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
// Another write operation
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
The pattern:
-
Methods that only read →
RLock()/RUnlock() -
Methods that modify →
Lock()/Unlock()
Happens-Before Guarantees
Like sync.Mutex, sync.RWMutex establishes
happens-before relationships:
-
Unlock()happens-before subsequentLock()(write → write) -
Unlock()happens-before subsequentRLock()(write → read) -
RUnlock()happens-before subsequentLock()(read → write)
RLock() does not establish
happens-before with other RLock() operations.
Concurrent readers may see data in any order relative to each
other—this is fine because they’re all reading the
same consistent state.
Reader/Writer Interactions
Understanding how readers and writers interact is crucial for
reasoning about RWMutex behavior.
Multiple Concurrent Readers
When only readers are active, they all proceed concurrently:
A timeline of three readers and the RWMutex state. Readers A, B and C acquire read locks in turn, so the state counts up to three readers, all of them reading at once. As each releases, the count falls back to two, then one, then unlocked. All three hold the lock simultaneously.
Writer Waits for Readers
A writer must wait for all current readers to release:
A timeline of two readers and a writer. Both readers hold read locks when the writer calls Lock, so the state shows two readers plus a writer waiting. The writer stays blocked as each reader releases, and only acquires once the reader count reaches zero.
Writer Priority: New Readers Block
This is critical: once a writer is waiting, new readers block behind it:
A timeline demonstrating writer priority. Reader A holds a read lock when the writer calls Lock and begins waiting. Reader B then arrives and calls RLock — and is made to wait too, even though readers can normally share. When A releases, the writer acquires, writes and unlocks, and only then does reader B get in. A reader arriving after a writer has begun waiting must queue behind it, which is what stops a steady stream of readers starving writers indefinitely.
Without priority, a steady stream of readers could starve writers indefinitely—each new reader arrives before all current readers finish, so the writer never gets a turn.
Writer priority ensures forward progress: once a writer starts waiting, no new readers can “cut in line.” Existing readers complete, writer proceeds, then waiting readers continue.
Even in read-heavy systems, occasional writes temporarily pause all new reads. If your writes are infrequent but your reads are latency-sensitive, profile to ensure writer priority doesn’t cause unacceptable read latency spikes.
When to Use RWMutex
RWMutex helps when
all three conditions are met:
Three conditions, all of which must hold before RWMutex is worth reaching for: reads vastly outnumber writes at 90% or more, the read critical section does more than trivial work, and many goroutines read concurrently. If any one is false, a plain mutex is likely simpler and faster.
Good fit examples:
- Configuration cache: Loaded once, read by every request
- Feature flags: Updated rarely, checked constantly
- User session store: Sessions read frequently, updated on login/logout
- DNS cache: Entries read on every request, refresh every few minutes
When NOT to Use RWMutex
1. Write-Heavy Workloads
If writes are frequent, readers constantly wait for writers:
// Illustrative snippet — not a complete program
// ✗ Poor fit: 50% writes negates RWMutex benefit
type Counter struct {
mu sync.RWMutex // Just use sync.Mutex
value int
}
func (c *Counter) Increment() { // Write: ~50% of calls
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *Counter) Value() int { // Read: ~50% of calls
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
With 50/50 read/write ratio, you lose reader concurrency benefits and gain RWMutex overhead.
2. Trivial Critical Sections
If your critical section is just a field access, the RWMutex overhead may exceed the benefit:
// Illustrative snippet — not a complete program
// ✗ May not benefit: Critical section is trivial
func (c *Config) GetTimeout() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.timeout // Single field read—tiny
}
For single-field access, sync.Mutex is often just as fast
or faster, and atomic operations (Chapter 11) may be
better still.
3. Low Concurrency
If only 1–2 goroutines ever read simultaneously, reader parallelism doesn’t help:
// Illustrative snippet — not a complete program
// ✗ Low concurrency: RWMutex overhead without benefit
// Only 2 goroutines ever access this
type SingletonCache struct {
mu sync.RWMutex // Overkill—use sync.Mutex
data map[string]string
}
Start with sync.Mutex. Switch to
RWMutex only if:
- Profiling shows lock contention is a bottleneck
- Reads outnumber writes significantly (90%+)
- Read operations take measurable time (>100ns)
- Multiple readers need access simultaneously
- Benchmarks confirm improvement
Read/write ratio rules of thumb: 50/50 → Use Mutex; 80/20 → Benchmark both; 95/5+ → RWMutex likely beneficial. Actual numbers vary by hardware and workload.
Performance Characteristics
Mutex Lock/Unlock
10.8 ns, RWMutex Lock/Unlock 18.9 ns,
RWMutex RLock/RUnlock 9.3 ns — each a
freshly declared lock in its own benchmark, stable to about
0.2 ns across three passes and unchanged from
-benchtime=200ms through 3s. Sizes are exact rather than
approximate: unsafe.Sizeof reports 8 and 24 bytes. The
absolute numbers move with the toolchain — Go 1.24
rewrote the mutex fast path — but the ordering does not.
Key insight: RWMutex has higher per-operation cost. It only wins when reader concurrency provides enough benefit to overcome this overhead.
The Lock Upgrade Trap
A common mistake: trying to “upgrade” from read lock to write lock:
// Illustrative snippet — not a complete program
// ✗ DEADLOCK: Cannot upgrade RLock to Lock
func (c *Cache) GetOrCreate(key string) *Entry {
c.mu.RLock()
if entry, ok := c.data[key]; ok {
c.mu.RUnlock()
return entry
}
// Key not found—need to create
c.mu.Lock() // DEADLOCK! Waiting for readers (including us)
defer c.mu.Unlock()
// ...
}
Why it deadlocks:
The lock-upgrade deadlock. A goroutine holding a read lock calls Lock, which waits for all readers to release — but the caller is itself one of those readers. The circular dependency spelled out: Lock waits for RUnlock, and RUnlock cannot happen until Lock returns. Neither can proceed.
The solution: Release then acquire (with double-check)
// Illustrative snippet — not a complete program
// ✓ CORRECT: Release RLock before acquiring Lock
func (c *Cache) GetOrCreate(key string) *Entry {
// First check with read lock
c.mu.RLock()
if entry, ok := c.data[key]; ok {
c.mu.RUnlock()
return entry
}
c.mu.RUnlock() // Must release before write lock
// Acquire write lock
c.mu.Lock()
defer c.mu.Unlock()
// CRITICAL: Must check again—state may have changed!
if entry, ok := c.data[key]; ok {
return entry // Another goroutine created it
}
// Now safe to create
entry := &Entry{Key: key}
c.data[key] = entry
return entry
}
Between RUnlock() and Lock(), another
goroutine might have:
- Acquired the write lock
- Created the entry
- Released the lock
Without the second check, you’d overwrite their work. This is the double-check pattern—§9.4 covers it in detail.
TryRLock and TryLock
Go 1.18 added non-blocking variants:
// Illustrative snippet — not a complete program
if c.mu.TryRLock() {
defer c.mu.RUnlock()
// Got read lock
} else {
// Lock held by writer—do something else
}
if c.mu.TryLock() {
defer c.mu.Unlock()
// Got write lock
} else {
// Lock held—do something else
}
Like Mutex.TryLock() (§9.2), these are rarely the
right choice—they often lead to polling patterns and complex
retry logic. Use them only when you have a meaningful alternative
action.
Common Patterns
Pattern 1: Snapshot for Long Operations
Copy data under lock, process outside:
// Illustrative snippet — not a complete program
func (c *Cache) ProcessAll() {
// Take snapshot under read lock
c.mu.RLock()
snapshot := make(map[string][]byte, len(c.data))
for k, v := range c.data {
snapshot[k] = v // Safe when values are replaced,
// not mutated in place
}
c.mu.RUnlock()
// Process without holding lock
for key, value := range snapshot {
expensiveProcess(key, value)
}
}
This allows readers and writers to proceed while processing happens.
Pattern 2: Stats with Read Lock
Aggregate multiple fields atomically:
// Illustrative snippet — not a complete program
type Stats struct {
mu sync.RWMutex
requests int64
errors int64
bytes int64
}
func (s *Stats) Snapshot() (requests, errors, bytes int64) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.requests, s.errors, s.bytes // Consistent snapshot
}
func (s *Stats) RecordRequest(size int64, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.requests++
s.bytes += size
if err != nil {
s.errors++
}
}
If you don’t need atomic snapshots across multiple fields,
individual atomic.Int64 values (Chapter 11) are more
efficient. Use RWMutex when you need consistency across fields.
Common Mistakes
Mistake 1: Modifying Under RLock
RLock is for reading only—modifications are data
races:
// Illustrative snippet — not a complete program
// ✗ DATA RACE: Modifying under read lock
func (c *Cache) BadIncrement(key string) {
c.mu.RLock()
defer c.mu.RUnlock()
c.data[key]++ // DATA RACE! Other readers see partial update
}
// ✓ CORRECT: Use write lock for modifications
func (c *Cache) GoodIncrement(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key]++
}
Mistake 2: RLock/Unlock Mismatch
Each lock type must be paired with its matching unlock:
// Illustrative snippet — not a complete program
// ✗ PANIC: Wrong unlock method
func (c *Cache) Bad() {
c.mu.RLock()
defer c.mu.Unlock() // WRONG! Should be RUnlock()
// ...
}
// ✓ CORRECT: Matching pairs
func (c *Cache) Good() {
c.mu.RLock()
defer c.mu.RUnlock() // Correct
// ...
}
RLock + Unlock or Lock +
RUnlock will panic at runtime.
Mistake 3: Using Write Lock for Read Operations
Using Lock() when RLock() suffices defeats
the purpose:
// Illustrative snippet — not a complete program
// ✗ DEFEATS PURPOSE: Write lock for read-only operation
func (c *Cache) Get(key string) []byte {
c.mu.Lock() // Should be RLock!
defer c.mu.Unlock()
return c.data[key]
}
// ✓ CORRECT: Read lock for read operations
func (c *Cache) Get(key string) []byte {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
Mistake 4: Attempting Lock Upgrade
Calling Lock() while holding RLock() causes
deadlock. See The Lock Upgrade Trap above for the
full explanation and correct pattern.
Mistake 5: Forgetting Double-Check After Upgrade
After releasing RLock() and acquiring
Lock(), state may have changed. Always re-check the
condition. See the GetOrCreate example in
The Lock Upgrade Trap
above.
RLockMultiple readers can hold RLock simultaneously—concurrent modifications are data races
Use Lock()/Unlock() for any
operation that modifies data
RLock/Unlock mismatch
Wrong unlock method causes panic or undefined behavior
Always pair RLock/RUnlock and
Lock/Unlock
Serializes reads unnecessarily, defeating the purpose of RWMutex
Use RLock()/RUnlock() for read-only
methods
RLock →
Lock)
Deadlock—waiting for yourself to release the read lock
Release RLock first, then acquire
Lock, then double-check state
Race condition—another goroutine may have changed state in the gap
Always re-check the condition after acquiring the write lock
Benchmarking Mutex vs RWMutex
Don’t guess—measure your actual workload:
// Illustrative snippet — not a complete program
type Entry struct {
Name, Region string
Weight int
}
// A read that does real work. §9.3's own rule is "> 100ns" — a
// single map lookup is the case where RWMutex does not pay.
func lookup(m map[string]Entry, key string) int {
total := 0
for i := 0; i < 40; i++ {
if e, ok := m[fmt.Sprintf("%s-%d", key, i)]; ok {
total += e.Weight + len(e.Region)
}
}
return total
}
type MutexCache struct {
mu sync.Mutex
data map[string]Entry
}
func (c *MutexCache) Get(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return lookup(c.data, key)
}
type RWMutexCache struct {
mu sync.RWMutex
data map[string]Entry
}
func (c *RWMutexCache) Get(key string) int {
c.mu.RLock()
defer c.mu.RUnlock()
return lookup(c.data, key)
}
func BenchmarkMutexCache(b *testing.B) {
cache := &MutexCache{data: makeTestData()}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
cache.Get("key")
}
})
}
func BenchmarkRWMutexCache(b *testing.B) {
cache := &RWMutexCache{data: makeTestData()}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
cache.Get("key")
}
})
}
Run with various CPU counts:
Example results (read-heavy, high concurrency):
Example results (write-heavy or low concurrency):
Those two runs are the same code and the same workload; only
-cpu changed. With eight cores the readers overlap and
RWMutex wins by nearly 5×. With one, there is no
concurrency to exploit and its extra bookkeeping makes it the slower
of the two. The results depend entirely on your workload —
always benchmark before deciding.
-cpu=8 and -cpu=1. Note what the read does
— forty map lookups, around 5 µs of work. Swap it
for a single lookup and the same benchmark gives
50.7 ns against 44.0 ns, a 1.15× difference, which
is the “trivial critical section” case above arriving
exactly on cue.
Summary
RLock()RUnlock()Lock()Unlock()TryRLock()TryLock()RLocker()sync.Locker that calls
RLock/RUnlock—useful for
interfaces expecting sync.Locker
Key Takeaways
- RWMutex allows multiple readers OR one writer—never both simultaneously
-
Use
RLock/RUnlockfor reads,Lock/Unlockfor writes—always match lock types with their corresponding unlock - Writer priority prevents writer starvation—waiting writers block new readers
-
Cannot upgrade
RLocktoLock—release first, reacquire, then double-check state -
Start with
sync.Mutex—RWMutex has higher overhead and only helps for read-heavy workloads (90%+); switch only when benchmarks confirm improvement -
Never modify under
RLock—that’s a data race - Keep read critical sections meaningful—trivial operations don’t benefit from RWMutex
- Always benchmark your specific workload—theoretical analysis isn’t enough
Next: §9.4 covers mutex design patterns—the monitor pattern, encapsulation, atomic operations, callbacks, and structuring code for safe concurrent access.
9.4 Mutex Design Patterns
§§9.2 and 9.3 covered mutex mechanics—how
Lock(), Unlock(), RLock(), and
RUnlock() work. This section covers
design: how to structure code that uses mutexes
correctly, maintainably, and safely.
The central principle is encapsulation: hide synchronization details behind clean APIs so callers can’t make mistakes.
The Monitor Pattern
The monitor pattern is the foundation of mutex-based design: combine data and the synchronization that protects it into a single abstraction with controlled access.
// Illustrative snippet — not a complete program
// Monitor pattern: State + synchronization encapsulated together
type Counter struct {
mu sync.Mutex // Synchronization
value int // Protected state
}
// All access through methods
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
The key insight: Callers interact with
Increment() and Value()—they never see
the mutex or the internal field. Synchronization is an implementation
detail.
The monitor pattern drawn as one type. Private state on top — the mutex and the value — and public methods below it, with three goroutines calling in through Increment and Value. Callers see only methods, the synchronization is invisible to them, and it is impossible either to forget the lock or to reach a field without it.
Encapsulation: Never Export Mutexes
Exporting a mutex lets callers bypass your synchronization:
// Illustrative snippet — not a complete program
// ✗ BAD: Exported mutex and data
type Cache struct {
Mu sync.RWMutex // Exported!
Data map[string][]byte // Exported!
}
// Caller can bypass synchronization:
cache.Data["key"] = value // No lock—DATA RACE!
// Or lock incorrectly:
cache.Mu.Lock()
value := cache.Data["key"]
// Forgot to unlock—all future operations block forever
// Illustrative snippet — not a complete program
// ✓ GOOD: Unexported mutex and data, exported methods
type Cache struct {
mu sync.RWMutex // Unexported
data map[string][]byte // Unexported
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
// Callers MUST use methods—no way to access data without lock
Never export mutex fields or the data they protect. Encapsulation isn’t just style—it’s safety.
Field Ordering Conventions
Go doesn’t enforce mutex placement, but conventions improve readability:
Convention 1: Mutex Before Protected Fields
// Illustrative snippet — not a complete program
type Account struct {
// Mutex first, then what it protects
mu sync.Mutex
balance int
transactions []Transaction
// Unprotected fields after a blank line
id string
name string
}
Convention 2: Comments for Complex Protection
When protection relationships aren’t obvious:
// Illustrative snippet — not a complete program
type Server struct {
// Configuration (immutable after construction)
addr string
timeout time.Duration
// Connection state (protected by connMu)
connMu sync.RWMutex
conns map[string]*Conn
// Metrics (protected by metricsMu)
metricsMu sync.Mutex
requests int64
errors int64
}
Convention 3: Naming
- Single mutex:
mu -
Multiple mutexes: descriptive names (
connMu,metricsMu,cacheMu)
// Illustrative snippet — not a complete program
// Single mutex—use 'mu'
type Counter struct {
mu sync.Mutex
value int
}
// Multiple mutexes—use descriptive names
type Server struct {
connMu sync.RWMutex
conns map[string]*Conn
metricsMu sync.Mutex
requests int64
}
The *Locked Suffix Pattern
§9.2 showed that Go mutexes aren’t reentrant—calling
Lock() twice deadlocks. The solution: helper methods that
assume the lock is held.
// Illustrative snippet — not a complete program
type Account struct {
mu sync.Mutex
balance int
transactions []Transaction
}
// Public: acquires lock
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.depositLocked(amount)
}
// Public: acquires lock
func (a *Account) Withdraw(amount int) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.withdrawLocked(amount)
}
// Public: acquires lock, uses helpers for atomicity
func (a *Account) Transfer(to *Account, amount int) error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.withdrawLocked(amount); err != nil {
return err
}
to.mu.Lock()
defer to.mu.Unlock()
to.depositLocked(amount)
return nil
}
// Private helper: caller MUST hold lock
func (a *Account) depositLocked(amount int) {
a.balance += amount
a.transactions = append(a.transactions, Transaction{
Type: "deposit",
Amount: amount,
Time: time.Now(),
})
}
// Private helper: caller MUST hold lock
func (a *Account) withdrawLocked(amount int) error {
if a.balance < amount {
return errors.New("insufficient funds")
}
a.balance -= amount
a.transactions = append(a.transactions, Transaction{
Type: "withdraw",
Amount: amount,
Time: time.Now(),
})
return nil
}
This Transfer method holds two locks simultaneously.
If two goroutines transfer between the same accounts in opposite
directions (a.Transfer(b, 100) and
b.Transfer(a, 50)), it deadlocks. Chapter 10
covers lock ordering—the technique for
making multi-lock acquisition safe.
*Locked Helpers
It tells readers (and your future self) that calling this method without holding the lock is a bug.
Returning Data Safely
When returning data from mutex-protected methods, consider whether callers might modify it.
Approach 1: Return Copies (Safest)
// Illustrative snippet — not a complete program
func (c *Cache) GetConfig() Config {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy—caller can't modify our internal state
return c.config // Config is a struct, so this copies
}
Approach 2: Return Pointers with Care
// Illustrative snippet — not a complete program
func (c *Cache) GetEntry(key string) *Entry {
c.mu.RLock()
defer c.mu.RUnlock()
// Returns pointer to internal data!
// Caller could modify without holding lock—document the contract
return c.entries[key]
}
If you return a pointer to internal data, the caller can modify it without holding the lock. Either:
- Return a copy
- Document clearly that the returned pointer is read-only
- Accept that callers might create races (usually a bad choice)
Approach 3: Defensive Copy for Slices/Maps
// Illustrative snippet — not a complete program
func (r *Registry) GetAllServices() []*Service {
r.mu.RLock()
defer r.mu.RUnlock()
// Copy the slice—caller can't affect our internal slice
result := make([]*Service, 0, len(r.services))
for _, svc := range r.services {
result = append(result, svc)
}
return result
}
The above copies the slice but not the
*Service pointers—callers still reference the
same Service
objects. Deep copies are expensive. Usually, shallow copies with
documented “don’t modify returned data”
contracts are acceptable.
// Illustrative snippet — not a complete program
// Deep copy (if Service is safe to copy)
result := make([]Service, len(r.services))
for i, svc := range r.services {
result[i] = *svc // Copy the Service struct
}
Protecting Invariants
Mutexes excel at maintaining invariants—relationships between fields that must always hold:
// Illustrative snippet — not a complete program
type Pool struct {
mu sync.Mutex
available []*Conn
inUse map[*Conn]bool
total int // Invariant: total == len(available) + len(inUse)
}
func (p *Pool) Acquire() (*Conn, error) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.available) == 0 {
return nil, errors.New("no connections available")
}
// Temporarily breaks invariant
conn := p.available[len(p.available)-1]
p.available = p.available[:len(p.available)-1]
// Restores invariant
p.inUse[conn] = true
// Invariant holds: total == len(available) + len(inUse)
return conn, nil
}
func (p *Pool) Release(conn *Conn) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.inUse, conn)
p.available = append(p.available, conn)
// Invariant maintained
}
Key insight: Inside a critical section, you can temporarily violate invariants while updating related fields—just restore consistency before releasing the lock. Other goroutines never see the inconsistent state.
During development, you can add assertions inside critical sections to verify invariants hold. Remove in production or use build tags.
// Illustrative snippet — not a complete program
func (p *Pool) Acquire() (*Conn, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Assert invariant (remove in production or use build tags)
if p.total != len(p.available)+len(p.inUse) {
panic("pool invariant violated")
}
// ... rest of implementation
}
Embedding vs Named Field
Go allows embedding sync.Mutex directly:
// Illustrative snippet — not a complete program
// Embedding: Lock/Unlock become methods of Counter
type Counter struct {
sync.Mutex // Embedded
value int
}
counter := &Counter{}
counter.Lock() // Works—but exported!
counter.Unlock()
Don’t do this. Embedding exports
Lock() and Unlock() to callers:
Lock()/Unlock() exported
Lock()/Unlock() private to
type
// Illustrative snippet — not a complete program
// ✗ BAD: Embedding exports Lock/Unlock
type Counter struct {
sync.Mutex
value int
}
// ✓ GOOD: Named field keeps Lock/Unlock private
type Counter struct {
mu sync.Mutex
value int
}
For unexported (internal) types, embedding is acceptable since
Lock/Unlock aren’t truly
“exported” if the struct itself isn’t exported.
However, named fields are still clearer.
Check-Then-Act: Data Races vs Race Conditions
A common source of confusion: code that’s data-race-free can still have race conditions. Understanding this distinction is essential for correct mutex design.
Recall §8.2:
- Data race: Unsynchronized memory access → undefined behavior
- Race condition: Timing-dependent correctness → wrong but defined behavior
The Problem: Check-Then-Act Gap
// Illustrative snippet — not a complete program
func (c *Cache) GetOrLoad(key string) ([]byte, error) {
c.mu.RLock()
val, ok := c.data[key]
c.mu.RUnlock() // ← Gap here
if ok {
return val, nil
}
val, err := loadFromDatabase(key) // ← May run concurrently
if err != nil {
return nil, err
}
c.mu.Lock()
c.data[key] = val // ← Last write wins
c.mu.Unlock()
return val, nil
}
Analysis:
- ✓ No data race: Mutex protects all map access
- ✗ Race condition: Two goroutines may both see “key not found” and both load from the database
A check-then-act race between two goroutines. Both take a read lock, both find the key missing, both release, and both then load the same value from the database. Each takes the write lock in turn and stores its result, so the second overwrites the first. There is no data race — every access is locked — but the work was done twice.
Impact Depends on the Operation
Idempotent reads (database SELECT):
- Both goroutines get the same value
- Performance issue only—duplicate work
- Often acceptable
Side effects (database INSERT, API calls with rate limits):
- Duplicate side effects
- Correctness issue
- Must be prevented
Expensive computation (1+ seconds):
- Wasted resources
- Severe performance issue
- Should be prevented
Non-idempotent reads with state changes (queue pop):
- Two goroutines might get the same item
- Correctness AND consistency issue
- Must be prevented
Solutions
For acceptable duplicate work:
- Use the simple pattern above
- Last write wins (values are identical anyway)
For preventing duplicate work:
- Double-check pattern (next section)
-
singleflight(golang.org/x/sync/singleflight) - Per-key locking
The Double-Check Pattern
When releasing a read lock and acquiring a write lock, you create a gap where another goroutine might complete the work. The double-check pattern handles this:
The same race drawn as a window. Between goroutine A releasing its read lock and acquiring the write lock there is a gap, marked RACE WINDOW, during which B performs its own failed lookup and starts computing. Both compute, and B's store overwrites A's. Closing it needs a second check after the write lock is acquired.
The pattern:
// Illustrative snippet — not a complete program
func (c *Cache) GetOrCompute(key string) Value {
// First check (read lock)
c.mu.RLock()
if val, ok := c.data[key]; ok {
c.mu.RUnlock()
return val
}
c.mu.RUnlock()
// Compute without holding lock
computed := expensiveComputation(key)
// Second check (write lock) — CRITICAL
c.mu.Lock()
defer c.mu.Unlock()
if val, ok := c.data[key]; ok {
return val // Another goroutine computed it!
}
c.data[key] = computed
return computed
}
Why the second check is essential:
Between RUnlock() and Lock(), another
goroutine may have:
- Acquired the write lock
- Computed the value
- Stored it in the cache
- Released the lock
Without the second check:
- You’d overwrite their result
- Duplicate computation was wasted
- Non-deterministic “last writer wins” behavior
If the “duplicate work” is cheap and idempotent (e.g., incrementing a counter), the double-check overhead may not be worth it. Double-check matters most for:
- Expensive computations (>1ms)
- Operations with side effects
- Non-idempotent operations
For very expensive operations where even one duplicate is
unacceptable, use
singleflight
(golang.org/x/sync/singleflight) instead.
Providing Atomic Compound Operations
Individual thread-safe operations don’t compose into thread-safe sequences:
// Illustrative snippet — not a complete program
// Each method is thread-safe...
cache.Get(key)
cache.Set(key, value)
// ...but this sequence is NOT atomic:
val, ok := cache.Get(key)
if !ok {
val = compute(key)
cache.Set(key, val) // Race: another goroutine might have set it
}
A timeline of a non-atomic compound operation. Both goroutines call Get, both see the key missing, both compute a value, and both call Set. The second Set overwrites the first — and because the two computations ran separately, the values may not even be the same.
Solution: Provide atomic compound operations:
// Illustrative snippet — not a complete program
// Atomic get-or-set operation
func (c *Cache) GetOrSet(
key string, value []byte,
) (actual []byte, loaded bool) {
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.data[key]; ok {
return existing, true // Was already present
}
c.data[key] = value
return value, false // We stored it
}
// Atomic increment-if-exists (for a type with map[string]int data)
func (c *HitCounter) IncrementIfExists(
key string,
) (newValue int, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
if val, exists := c.data[key]; exists {
c.data[key] = val + 1
return val + 1, true
}
return 0, false
}
// Atomic compare-and-swap
func (c *Cache) CompareAndSwap(key string, old, new []byte) bool {
c.mu.Lock()
defer c.mu.Unlock()
if current, ok := c.data[key]; ok && bytes.Equal(current, old) {
c.data[key] = new
return true
}
return false
}
When designing a thread-safe type, ask: “What compound operations will callers need?” Provide them as atomic methods rather than forcing callers to work around non-atomic sequences.
Avoiding Callbacks Under Lock
Calling user-provided callbacks while holding a lock is dangerous:
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Callback under lock
func (r *Registry) Notify(event string) {
r.mu.Lock()
defer r.mu.Unlock()
if handler, ok := r.handlers[event]; ok {
handler() // What if handler calls back into Registry?
}
}
A callback deadlock traced through the call stack. Notify acquires the registry's lock and invokes a handler. The handler calls back into Unregister on the same registry, which tries to acquire the lock Notify is still holding. Notify cannot release it until the handler returns, and the handler cannot return until it gets the lock.
Three problems with callbacks under lock:
- Deadlock risk: Callback might call back into your type
- Unpredictable duration: Callback might be slow, blocking all other operations
- Panic propagation: Callback panic leaves lock held (unless using defer)
Solution: Copy and release before calling
// Illustrative snippet — not a complete program
// ✓ SAFE: Release lock before callback
func (r *Registry) Notify(event string) {
r.mu.RLock()
handler, ok := r.handlers[event]
r.mu.RUnlock() // Release before callback
if ok {
handler() // Safe—no lock held
}
}
This pattern also provides panic safety—if
handler() panics, the lock is already released, so
the Registry remains usable after recovery.
We can’t use defer r.mu.RUnlock() here because
we need to release the lock before calling the handler,
not when the function returns.
For multiple handlers:
// Illustrative snippet — not a complete program
func (r *Registry) NotifyAll(event string) {
// Copy handlers under lock
r.mu.RLock()
handlers := make([]func(), len(r.handlers[event]))
copy(handlers, r.handlers[event])
r.mu.RUnlock()
// Call without lock
for _, h := range handlers {
h()
}
}
Keep Critical Sections Small
Long critical sections reduce concurrency and can cause timeout failures:
// Illustrative snippet — not a complete program
// ✗ BAD: Lock held during network call
func (c *Cache) GetOrFetch(key string) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if val, ok := c.data[key]; ok {
return val, nil
}
// Lock held for entire network call (100ms+)!
val, err := fetchFromRemote(key)
if err != nil {
return nil, err
}
c.data[key] = val
return val, nil
}
Two versions of a cache lookup. In the bad one the lock is held across a 100-millisecond network call, so the second and third goroutines each wait the full duration. In the good one the lock is taken only to check the cache and released before the network call, then re-taken to store the result — so all three goroutines check and fetch concurrently.
Better approach:
// Illustrative snippet — not a complete program
// ✓ GOOD: Minimal critical section
func (c *Cache) GetOrFetch(key string) ([]byte, error) {
// Quick check under read lock
c.mu.RLock()
val, ok := c.data[key]
c.mu.RUnlock()
if ok {
return val, nil
}
// Fetch without holding lock
val, err := fetchFromRemote(key)
if err != nil {
return nil, err
}
// Brief write lock to store
c.mu.Lock()
c.data[key] = val // Race condition—see double-check
c.mu.Unlock()
return val, nil
}
Do:
- Read/write protected fields
- Check invariants
- Update related data atomically
Don’t:
- Network I/O
- Database queries
- File operations
- Heavy computation
- User callbacks
- Acquire other locks (deadlock risk—Chapter 10)
Lock Granularity Trade-off
When designing types with multiple concerns, you must decide: one lock or many?
Coarse-grained (one mutex):
- Simpler to reason about
- No deadlock risk between your own locks
- Lower concurrency (all operations serialize)
Fine-grained (multiple mutexes):
- Higher concurrency (independent operations proceed in parallel)
- More complex to reason about
- Deadlock risk if locks acquired in inconsistent order
Rule: Start coarse, refine only if profiling shows contention.
// Illustrative snippet — not a complete program
// Coarse-grained: Simple, safe
type Server struct {
mu sync.Mutex
config Config
conns map[string]*Conn
metrics Metrics
}
// Fine-grained: Higher concurrency, more complexity
type Server struct {
configMu sync.RWMutex
config Config
connsMu sync.RWMutex
conns map[string]*Conn
metricsMu sync.Mutex
metrics Metrics
}
When to consider fine-grained locking:
- Profiling shows contention on the coarse lock
- Different access patterns: config is read-heavy, metrics is write-heavy
- Logical independence: updating metrics shouldn’t block connection lookups
- High concurrency: many goroutines competing for access
Multiple locks introduce deadlock risk. If you ever need to hold multiple locks simultaneously, you must define and follow a strict acquisition order. Chapter 10 covers this in detail.
Detecting Lock Contention
Before optimizing lock granularity, measure whether contention is actually a problem:
The race detector finds data races, but not lock contention (time spent waiting for locks). If you suspect mutex contention is hurting performance, enable mutex profiling and analyze with pprof. This shows which locks have goroutines waiting and for how long. Chapter 19 covers profiling in depth.
// Illustrative snippet — not a complete program
import _ "net/http/pprof"
func main() {
// Enable mutex profiling
runtime.SetMutexProfileFraction(1)
// Start pprof server
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... your application ...
}
Constructor Patterns
When Zero Value Works
If your type only contains a mutex and simple fields, no constructor is needed:
// Illustrative snippet — not a complete program
type Counter struct {
mu sync.Mutex
value int
}
// Zero value works fine
counter := &Counter{}
counter.Increment() // Works immediately
When Constructor Is Required
If your type contains maps, channels, or requires initialization:
// Illustrative snippet — not a complete program
type Cache struct {
mu sync.RWMutex
data map[string][]byte // Maps must be initialized!
}
// ✗ WRONG: Zero value has nil map
cache := &Cache{}
cache.Set("key", value) // panic: assignment to nil map
// ✓ CORRECT: Use constructor
func NewCache() *Cache {
return &Cache{
data: make(map[string][]byte),
}
}
cache := NewCache()
cache.Set("key", value) // Works
Your type needs a constructor if it contains:
- Maps (must be initialized with
make) - Channels (must be initialized with
make) - Pointers that must point to allocated memory
- Fields with non-zero default values
- Dependencies that must be injected
Testing Concurrent Types
Testing mutex-protected types requires verifying both correctness and race-freedom:
// Illustrative snippet — not a complete program
func TestCounterConcurrent(t *testing.T) {
counter := &Counter{}
var wg sync.WaitGroup
goroutines := 100
increments := 1000
for i := 0; i < goroutines; i++ {
wg.Go(func() {
for j := 0; j < increments; j++ {
counter.Increment()
}
})
}
wg.Wait()
expected := goroutines * increments
if got := counter.Value(); got != expected {
t.Errorf("Expected %d, got %d", expected, got)
}
}
Run with race detector:
Stress testing pattern:
// Illustrative snippet — not a complete program
func TestCacheStress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
cache := NewCache()
var wg sync.WaitGroup
// Concurrent readers and writers
for i := 0; i < 50; i++ {
// Writer
wg.Go(func() {
for j := 0; j < 1000; j++ {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, []byte(fmt.Sprintf("value-%d", j)))
}
})
// Reader
wg.Go(func() {
for j := 0; j < 1000; j++ {
key := fmt.Sprintf("key-%d", i)
cache.Get(key)
}
})
}
wg.Wait()
}
- Test with more goroutines than CPU cores
- Mix readers and writers
-
Run multiple times:
go test -race -count=100 -
Use
-shortflag for quick tests, full tests in CI - Check both correctness (final values) and race-freedom (no detector warnings)
Complete Example: Registry
Here’s a complete, production-quality example demonstrating all patterns:
// Illustrative snippet — not a complete program
// Registry manages named services with thread-safe access.
type Registry struct {
mu sync.RWMutex
services map[string]*Service
}
// Service represents a registered service.
type Service struct {
Name string
Endpoint string
healthy bool
}
// NewRegistry creates an empty registry.
func NewRegistry() *Registry {
return &Registry{
services: make(map[string]*Service),
}
}
// Register adds a service. Returns error if name already exists.
func (r *Registry) Register(svc *Service) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.services[svc.Name]; exists {
return fmt.Errorf("service %q already registered", svc.Name)
}
r.services[svc.Name] = svc
return nil
}
// Unregister removes a service. Returns false if not found.
func (r *Registry) Unregister(name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.services[name]; !exists {
return false
}
delete(r.services, name)
return true
}
// Get retrieves a service by name.
func (r *Registry) Get(name string) (*Service, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
svc, ok := r.services[name]
return svc, ok
}
// GetOrRegister returns existing service or registers new one.
// Uses double-check pattern to avoid duplicate registration.
func (r *Registry) GetOrRegister(svc *Service) *Service {
// First check with read lock
r.mu.RLock()
if existing, ok := r.services[svc.Name]; ok {
r.mu.RUnlock()
return existing
}
r.mu.RUnlock()
// Acquire write lock
r.mu.Lock()
defer r.mu.Unlock()
// Double-check: another goroutine may have registered it
if existing, ok := r.services[svc.Name]; ok {
return existing
}
r.services[svc.Name] = svc
return svc
}
// List returns all registered service names.
// Returns a copy to prevent caller from modifying internal state.
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.services))
for name := range r.services {
names = append(names, name)
}
return names
}
// Count returns the number of registered services.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.services)
}
// SetHealthy updates service health status.
func (r *Registry) SetHealthy(name string, healthy bool) bool {
r.mu.Lock()
defer r.mu.Unlock()
svc, ok := r.services[name]
if !ok {
return false
}
svc.healthy = healthy
return true
}
// GetHealthy returns only healthy services.
func (r *Registry) GetHealthy() []*Service {
r.mu.RLock()
defer r.mu.RUnlock()
var healthy []*Service
for _, svc := range r.services {
if svc.healthy {
healthy = append(healthy, svc)
}
}
return healthy
}
What this demonstrates:
mu, services—callers use methods
NewRegistry()—initializes map
RLock
GetOrRegister()—handles race window
GetOrRegister()—check + register atomically
List()—returns copy of names
Register()—returns error for duplicate
Design Checklist
When designing mutex-protected types:
A design checklist in five groups. Encapsulation: the mutex and the protected fields are unexported and all access goes through methods. Method design: every method touching protected data takes the lock, reads as well as writes, using defer, with a Locked suffix on internal helpers. Critical sections: no I/O, no network calls, no callbacks, and no other lock acquired inside. RWMutex: RLock for reads, Lock for modifications, never an upgrade attempt, and always a second check after re-acquiring. Atomic operations: provide the compound operations callers need, such as GetOrCreate or CompareAndSwap.
Summary
*Locked suffixKey Takeaways
- Encapsulation is safety—unexported mutex + methods = impossible to misuse
-
Use
*Lockedsuffix for internal helpers—documents “caller must hold lock” - Provide atomic compound operations—individual ops don’t compose safely
- Never call callbacks under lock—copy data, release, then call
- Keep critical sections minimal—I/O and computation belong outside
- Double-check after RLock → Lock upgrade—state may have changed
- Return copies for safety—or document that pointers must not be modified
- Start with one lock—add granularity only when profiling proves contention is real
Chapter 9 Self-Check
This section provides quick reference materials for mutex usage and self-check questions to verify your understanding.
Quick Reference Card
A one-page reference card for the chapter: the Mutex and RWMutex method sets side by side, the rule that RWMutex needs 90% or more reads and a critical section above 100 nanoseconds before it pays, and the tools — go test -race to find data races, go vet to catch copied mutexes, and pprof mutex profiling to find contention.
Pre-Commit Checklist
Before committing code with mutexes, verify:
A pre-commit checklist for any type carrying a mutex: the mutex is unexported, every access path locks, defer is used for unlocking, reads are protected as well as writes, no I/O or callbacks happen under the lock, no upgrade from RLock to Lock is attempted, and the type has been exercised under the race detector.
Self-Check Questions
Test your understanding of mutex usage and design. Click each question to reveal the answer.
Channel. This is a work coordination problem, not a state protection problem. You’re distributing tasks from producers to workers—data flows between goroutines. Channels provide natural blocking (workers wait for work), backpressure (bounded channel limits queue), and clean shutdown (close channel to signal workers). A mutex would require polling or condition variables—awkward and wasteful.
2. This code deadlocks. Why?
// Illustrative snippet — not a complete program
func (a *Account) Transfer(to *Account, amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.withdrawLocked(amount)
}
func (a *Account) withdrawLocked(amount int) {
a.mu.Lock() // ?
defer a.mu.Unlock()
a.balance -= amount
}
Self-deadlock due to non-reentrant mutex.
Transfer calls withdrawLocked, which
tries to call Lock() on the same mutex that
Transfer already holds. Go mutexes are not
reentrant—a goroutine cannot lock a mutex it already
holds. The goroutine blocks forever waiting for itself.
Fix: The Locked suffix means
“caller must hold the lock”—the method should
NOT acquire the lock:
// Illustrative snippet — not a complete program
func (a *Account) withdrawLocked(amount int) {
// No locking—caller already holds a.mu
a.balance -= amount
}
3. A colleague says “I only read the counter, so I don’t need to lock.” Why is this incorrect?
// Illustrative snippet — not a complete program
func (c *Counter) Value() int {
return c.value // "It's just a read!"
}
Data race: unsynchronized read. From Chapter 8, a data race requires: (1) same memory location, (2) at least one write, (3) no synchronization. Even though this goroutine only reads, other goroutines write. Without synchronization, this read may see:
- Stale cached values (CPU cache coherency)
- Torn values (partial writes on some architectures)
- Values that “don’t exist yet” (compiler/CPU reordering)
The mutex establishes happens-before relationships that guarantee visibility of writes. Always lock reads of shared data.
sync.RWMutex for a
counter that’s incremented 50% of the time and read 50% of
the time?
RWMutex overhead without benefit. RWMutex has higher per-operation overhead than Mutex (~19ns vs ~11ns for Lock on go1.26.1, plus internal bookkeeping for reader counting). The benefit is concurrent reads—but with 50% writes, readers frequently block waiting for writers anyway. You pay the overhead without gaining concurrency.
Rule: RWMutex benefits workloads with 90%+ reads. For balanced read/write, use plain Mutex.
5. You see this code in a pull request. What bug does it contain?
// Illustrative snippet — not a complete program
func (s *Store) ProcessAll(items []Item) {
for _, item := range items {
s.mu.Lock()
defer s.mu.Unlock()
s.process(item)
}
}
Deadlock on second iteration—defer in
loop.
defer executes when the function returns,
not when the loop iteration ends.
A trace of defer inside a loop. On the first iteration Lock succeeds and the deferred Unlock is scheduled — but for when the function returns, not when the iteration ends. On the second iteration Lock is called while the first iteration's lock is still held, so the goroutine blocks forever waiting for itself. If every goroutine is blocked the runtime prints a fatal error saying all goroutines are asleep; if any other goroutine is still running, the program hangs with no message.
Fix: Use a helper function or explicit unlock:
// Illustrative snippet — not a complete program
func (s *Store) ProcessAll(items []Item) {
for _, item := range items {
s.processItem(item) // Helper function scopes the defer
}
}
func (s *Store) processItem(item Item) {
s.mu.Lock()
defer s.mu.Unlock()
s.process(item)
}
6. What’s wrong with this type definition?
// Illustrative snippet — not a complete program
type Cache struct {
sync.Mutex
data map[string][]byte
}
Embedding exports Lock() and
Unlock().
By embedding sync.Mutex, the Lock and
Unlock methods become part of
Cache’s method set:
// Illustrative snippet — not a complete program
cache := &Cache{data: make(map[string][]byte)}
cache.Lock() // Anyone can call this!
// Forgot to unlock... permanent deadlock
Callers can bypass your methods and access the lock directly, leading to bugs. Use a named field instead:
// Illustrative snippet — not a complete program
type Cache struct {
mu sync.Mutex // Named field—Lock/Unlock stay private
data map[string][]byte
}
7. Can you “upgrade” a read lock to a
write lock on sync.RWMutex? Why or why not?
// Illustrative snippet — not a complete program
c.mu.RLock()
// ... discovered we need to write ...
c.mu.Lock() // Can we do this?
No—it causes deadlock.
Lock() waits for all readers to release, but
you are a reader. You’re waiting for yourself to
call RUnlock(), but you can’t because
you’re blocked on Lock().
A three-line trace of the lock-upgrade deadlock. The goroutine acquires a read lock, making it one of the readers. It then calls Lock, which waits for every reader to release — including the caller itself. The dependency is circular and neither side can proceed.
Fix: Release the read lock first, then acquire write lock, then double-check state:
// Illustrative snippet — not a complete program
c.mu.RLock()
needsWrite := checkCondition()
c.mu.RUnlock() // Release first
if needsWrite {
c.mu.Lock()
defer c.mu.Unlock()
if checkCondition() { // Double-check! State may have changed
// Now safe to write
}
}
8. Your method needs to read two fields atomically.
Should it use two separate RLock() calls?
// Illustrative snippet — not a complete program
func (s *Server) GetStats() (requests int, errors int) {
s.mu.RLock()
requests = s.requests
s.mu.RUnlock()
s.mu.RLock()
errors = s.errors
s.mu.RUnlock()
return requests, errors
}
No—this is not atomic. Two separate lock acquisitions create a gap where a writer could modify the data:
A nine-step timeline of a goroutine taking two separate read locks around a writer. It reads requests as 100 and releases. The writer then acquires the write lock and updates both requests and errors to 101 and 5. The reader takes a second read lock and reads errors as 5. The two values it collected — requests 100 and errors 5 — never existed together, because they were read either side of a write. Each read was individually safe; the pair is an inconsistent snapshot.
Fix: One lock acquisition for both fields:
// Illustrative snippet — not a complete program
func (s *Server) GetStats() (requests int, errors int) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.requests, s.errors // Atomic snapshot
}
9. What’s the difference between these two method signatures for a type containing a mutex?
// Illustrative snippet — not a complete program
func (c Counter) Value() int // A: value receiver
func (c *Counter) Value() int // B: pointer receiver
Value receiver copies the mutex! The method
operates on a copy of Counter, which has its own
independent mutex. Locking the copy’s mutex doesn’t
protect the original. Other goroutines can modify the
original’s value while this method reads the
copy’s stale value.
Always use pointer receivers for types
containing mutexes. go vet catches this:
10. You’re designing a thread-safe registry. Should you export the mutex and map fields, or only provide methods?
// Illustrative snippet — not a complete program
// Option A: Exported fields
type Registry struct {
Mu sync.RWMutex
Services map[string]*Service
}
// Option B: Unexported fields with methods
type Registry struct {
mu sync.RWMutex
services map[string]*Service
}
func (r *Registry) Get(name string) (*Service, bool) { ... }
func (r *Registry) Register(svc *Service) error { ... }
Option B—unexported fields with methods. This is the monitor pattern.
Option A (exported) allows:
-
Bypassing synchronization:
r.Services["key"] = svc(no lock!) -
Incorrect locking:
r.Mu.Lock()then forgetting to unlock - Implementation coupling: callers depend on internal structure
Option B (unexported) guarantees:
- All access goes through methods that handle locking correctly
- Impossible to forget to lock or unlock
- Implementation can change (switch to sync.Map, add caching) without affecting callers
- Invariants are protected by design
11. What’s wrong with this code? Will
go vet catch it?
// Illustrative snippet — not a complete program
func (c *Cache) GetAll() map[string][]byte {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data // Return internal map
}
Returns internal map—callers can modify without
lock.
go vet won’t catch this—it’s a
design issue, not a copy issue. The method returns a reference
to internal state.
Fix: Return a copy:
// Illustrative snippet — not a complete program
func (c *Cache) GetAll() map[string][]byte {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string][]byte, len(c.data))
for k, v := range c.data {
result[k] = v
}
return result
}
12. This registry calls handlers under lock. What’s the risk?
// Illustrative snippet — not a complete program
func (r *Registry) Notify(event string) {
r.mu.Lock()
defer r.mu.Unlock()
if handler, ok := r.handlers[event]; ok {
handler()
}
}
Deadlock risk from callback re-entry. If the handler calls back into the Registry:
A call-stack trace of a callback deadlock. Notify acquires the registry's lock and calls the handler. The handler calls Unregister on the same registry, which tries to acquire that same lock. Notify cannot release it until the handler returns, and the handler cannot return until it gets the lock.
Also: unknown duration (handler might be slow), panic propagation.
Fix: Copy handler reference, release lock, then call:
// Illustrative snippet — not a complete program
func (r *Registry) Notify(event string) {
r.mu.RLock()
handler, ok := r.handlers[event]
r.mu.RUnlock() // Release BEFORE calling
if ok {
handler() // Safe—no lock held
}
}
Chapter Summary
Chapter 9 covered mutexes—Go’s mechanism for protecting shared mutable state:
§9.1: When to Use Mutexes
- Mutexes protect state; channels coordinate work
- Decision rule: “Am I coordinating work or protecting state?”
- Both are idiomatic Go when used appropriately
§9.2: sync.Mutex Mechanics
-
Lock()blocks until acquired;Unlock()releases - Always use
defer mu.Unlock()for safety -
Go mutexes are not reentrant—use
*Lockedhelpers - Keep critical sections short (<100μs)
§9.3: sync.RWMutex
- Multiple concurrent readers OR one exclusive writer
- Use only for read-heavy workloads (90%+ reads)
- Cannot upgrade RLock to Lock—release first, then double-check
§9.4: Design Patterns
- Monitor pattern: encapsulate state + synchronization
- Never export mutexes or protected fields
- Provide atomic compound operations
- Never call callbacks under lock
- Double-check pattern for RLock → Lock upgrades
Key principles to remember:
Key Principles
- Encapsulation is safety—unexported mutex + methods = correct by construction
-
defer mu.Unlock()always—handles all exit paths and panics - Lock ALL access—reads need protection too
- Keep critical sections minimal—no I/O, no callbacks, no computation
- Start simple—plain Mutex first, RWMutex only when profiling proves it helps
-
Test with
-race—catches races that careful review misses
Exercise 9.1 — Let the Readers Overlap
Pick the lock that knows a reader from a writer
This cache is correct. Every field access is under the
lock, reads included, and go test -race has nothing
to say about it. It is also serializing every lookup —
§9.3’s opening picture, three reads that could have
run at once queueing behind one another.
The second test measures whether reads actually overlap, so a fix that keeps the exclusive lock fails it even though nothing races. That is the distinction this chapter is built on: the race detector tells you a program is safe, never that it is right.
package ch09
import (
"sync"
"time"
)
// TODO(reader): This cache is correct. Run `go test -race ./...`
// and the race detector has nothing to say about it — every field
// access is under the lock, reads included.
//
// It is also serializing every read. §9.3 opens with exactly this
// picture: three lookups that could have run at once, queued behind
// one another because a sync.Mutex does not distinguish a reader from
// a writer.
//
// Swap the lock for the one that does. Two things to get right:
//
// 1. Reads take the read lock and writes take the write lock. Using
// Lock for a read compiles, passes -race, and buys you nothing —
// that is §9.3's "Mistake 3".
// 2. Do not try to upgrade. If you find yourself holding RLock and
// wanting Lock, release first and re-check after acquiring, or you
// get §9.3's lock-upgrade deadlock.
//
// The second test measures whether reads actually overlap, so a fix
// that keeps the exclusive lock will fail it even though nothing
// races.
type Cache struct {
mu sync.Mutex
data map[string]string
}
func New() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
// Get looks up a key. The sleep stands in for a read that costs
// something — §9.3's threshold for RWMutex paying its way.
func (c *Cache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
time.Sleep(2 * time.Millisecond)
v, ok := c.data[key]
return v, ok
}
package ch09
import (
"fmt"
"sync"
"testing"
"time"
)
// Correctness first: whatever lock you choose, the cache must still
// hand back what was stored, with the race detector satisfied.
func TestCacheStoresAndReturns(t *testing.T) {
c := New()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Go(func() { c.Set(fmt.Sprint("k", i), fmt.Sprint("v", i)) })
}
wg.Wait()
for i := 0; i < 20; i++ {
got, ok := c.Get(fmt.Sprint("k", i))
if !ok || got != fmt.Sprint("v", i) {
t.Fatalf("Get(k%d) = %q,%v; want %q,true",
i, got, ok, fmt.Sprint("v", i))
}
}
}
// The point of the exercise. Ten readers, each of which takes 2ms
// inside Get. Serialized that is ~20ms; overlapping it is ~2ms. The
// threshold sits well clear of both so a slow machine cannot fail it
// by accident.
func TestConcurrentReadsOverlap(t *testing.T) {
c := New()
c.Set("k", "v")
const readers = 10
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < readers; i++ {
wg.Go(func() { c.Get("k") })
}
wg.Wait()
elapsed := time.Since(start)
if elapsed > 10*time.Millisecond {
t.Fatalf("%d concurrent reads took %v; they are being "+
"serialized. A read lock would let them overlap. "+
"See §9.3.", readers, elapsed.Round(time.Millisecond))
}
}
// A smoke test that writes still happen and still complete. Real
// exclusivity is enforced by -race and by Go's own concurrent-map-write
// panic, both of which fire long before an assertion here would; this
// exists to stop the obvious cheat of making Set shared too, which
// would let the readers overlap and then explode here.
func TestWritesStayExclusive(t *testing.T) {
c := New()
var mu sync.Mutex
inWrite, maxConcurrent := 0, 0
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Go(func() {
mu.Lock()
inWrite++
if inWrite > maxConcurrent {
maxConcurrent = inWrite
}
mu.Unlock()
c.Set(fmt.Sprint("k", i), "v")
mu.Lock()
inWrite--
mu.Unlock()
})
}
wg.Wait()
if maxConcurrent == 0 {
t.Fatal("no writes observed")
}
}
go test -race ./... in
code/ch09/ reports ok for all three tests.
The third one is there to stop the obvious cheat — making
Set shared as well would let the readers overlap and
break the writes.
Lock
for a read compiles, passes the detector and buys you nothing
(Mistake 3); and if you ever hold RLock and want
Lock, release first and re-check afterwards rather than
upgrading in place (Mistake 4).
labs/go-concurrency/code/ch09/. A worked answer sits in
solution/cache.go.txt.
Further reading
-
sync.Mutexandsync.RWMutex— short enough to read in full, and the source for two things §9.2 and §9.3 lean on: that a mutex is not associated with a goroutine, so one may lock and another unlock, and that a blockedLockcall excludes new readers. - Effective Go — Share by communicating — the source of the proverb §9.1 spends its first page correcting. Worth reading for what it actually says, which is narrower and more useful than the slogan it became.
- Introducing the Go Race Detector — chapter 8’s tool, revisited here as the thing that verifies a mutex is doing its job. It will not tell you the lock is too coarse, which is what §9.3 and this chapter’s exercise are about.
-
Profiling Go Programs
— background for “Detecting Lock Contention.”
The mutex profile is off by default;
runtime.SetMutexProfileFractionturns it on, and it is the only honest way to answer “is this lock the problem?” -
sync.Map— the specialised alternative to a mutex-guarded map, and its documentation is unusually direct about when not to reach for it. For most caches the plain map plusRWMutexof this chapter is both faster and clearer.
You can now choose between a mutex and a channel on something better
than instinct, say what Lock guarantees and what it
costs, and tell the case where RWMutex earns its
overhead from the far more common case where it does not. Every lock
in this chapter was taken and released by one goroutine, in one
order. Chapter 10 removes that assumption: what
happens when two goroutines each hold what the other needs, why
consistent lock ordering is the whole of the cure, and how to read
the fatal error: all goroutines are asleep that Go
prints when it notices — and what it means when Go does not
notice at all.