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.

Consider these scenarios

Where multiple goroutines need to access the same data structure:

server_x_1.go
// 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
    }
}
cache_x_1.go
// 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.

CHANNELS VS MUTEXES

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.

By the end of this chapter, you’ll understand
  • When mutexes are the right tool (and when they’re not)
  • sync.Mutex semantics: Lock(), Unlock(), and critical sections
  • The defer mu.Unlock() pattern and why it’s essential
  • sync.RWMutex for 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
What we’re NOT covering in Chapter 9
  • 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
Prerequisites

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:

counter_x_1.go
// 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:

  1. Same memory location ✓ (multiple goroutines access c.value)
  2. At least one write ✓ (Increment writes, Value reads)
  3. No synchronization ✓ (no coordination)

The solution—add a mutex:

counter_x_2.go
// 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.

MUTEX ZERO VALUE

sync.Mutex has a useful zero value—it’s ready to use without initialization. This is idiomatic Go:

example.go
// Illustrative snippet — not a complete program
counter := &Counter{}  // mu is valid and unlocked
counter.Increment()    // Works immediately
NOTE ON DEFER

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

CHANNELS VS MUTEXES: THE DECISION

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:

Channels vs Mutexes
Row
Data
Ownership
Mental model
Purpose
THE FOUR QUESTIONS CONNECTION

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?

  1. No communication happening. We’re not passing data between goroutines—we’re protecting a field from concurrent modification.
  2. 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.
  3. Simple access pattern. Read a field, modify a field. Channels would add complexity without benefit.
PROTECTING STRUCT STATE

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.

ATOMIC VS MUTEX DECISION BOUNDARY

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.

decision.go
// 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:

account_91_1.go
// 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:

cache_91_1.go
// 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.

SYNC.MAP VS MUTEX+MAP (PREVIEW)

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:

pipeline.go
// 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:

worker_91_1.go
// 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:

signal.go
// 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

counter_channel.go
// 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.

WHEN CHANNELS BECOME AWKWARD

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

workqueue.go
// 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:

worker_91_2.go
// 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:

metrics.go
// 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
    }
}
Analysis
events channel
Communication—decouples event producers from processing
shutdown channel
Signaling—broadcasts termination to the Run goroutine
mu and counters
Protection—guards map from concurrent read/write access

Each primitive serves its natural purpose. Forcing one paradigm for both jobs would make the code awkward.

QUICK DECISION CHECKLIST

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

“Channels are always more idiomatic”
Problem

Fighting the right tool

Better Approach

Use each for its strength

Mutex for signaling
Problem

Requires polling, wastes CPU

Better Approach

Use channels

Channel for simple counter
Problem

Goroutine + channel overhead

Better Approach

Use mutex or atomic

Holding lock during I/O
Problem

Blocks all other operations

Better Approach

Release lock before I/O (§9.2)

Channel when data should stay put
Problem

Extra goroutines, complex shutdown

Better Approach

Protect with mutex

Key Takeaways

  1. Mutexes protect internal state; channels move data between goroutines—this is the core distinction
  2. Both are valid Go—the proverb expresses preference for communication style, not prohibition of mutexes. Real systems use both.
  3. Awkwardness is a signal—if channels require dedicated goroutines for simple state, use a mutex
  4. sync.Mutex has a useful zero value—no initialization required
  5. 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:

mutex_92_1.go
// 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.

MUTEX STATES AND TRANSITIONS
  • Lock() on unlocked mutex: acquires immediately, returns
  • Lock() on locked mutex: blocks until holder calls Unlock()
  • Unlock() on locked mutex: releases, wakes one waiter
  • Unlock() on unlocked mutex: panic!
MUTEX STATES AND TRANSITIONS

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:

counter_92_1.go
// 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.

counter_92_2.go
// 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:

  1. If unlocked: Acquires the lock immediately and returns
  2. If locked: Blocks until the lock becomes available, then acquires it
mutex_92_2.go
// 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.

LOCK() BEHAVIOR WITH MULTIPLE GOROUTINES

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.

MUTEXES DON’T SUPPORT CONTEXT CANCELLATION

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.

MUTEX FAIRNESS

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:

mutex_92_3.go
// 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:

panic.go
// 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():

double_unlock.go
// 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:

counter_92_3.go
// 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:

Critical Section Properties
Exclusive
Only one goroutine executes it at a time
Atomic (to observers)
Other goroutines see before-state or after-state, never mid-update
Serialized
Concurrent calls execute one after another
BOTH READS AND WRITES NEED LOCKING

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.

counter_92_4.go
// 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
}
CRITICAL SECTION SERIALIZATION

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:

THE MUTEX ORDERING RULE

Unlock() happens-before any subsequent Lock() on the same mutex. All writes before Unlock() are visible to code after the subsequent Lock().

happens_before.go
// 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.

MUTEX HAPPENS-BEFORE

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.

MUTEXES PROVIDE TWO GUARANTEES
  1. Mutual exclusion: Serialize access to protected data
  2. 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().

counter_92_5.go
// 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:

account_92_1.go
// 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
}
account_92_2.go
// 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.

ERROR HANDLING UNDER LOCK

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:

cache_92_1.go
// 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.

defer ENSURES UNLOCK ON ALL PATHS

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.

PANIC SAFETY ≠ STATE SAFETY

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:

pattern.go
// 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:

cache_92_2.go
// 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.

RACE CONDITION IN THIS PATTERN

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.

CONSIDER RESTRUCTURING FOR CLARITY

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.

cache_92_3.go
// 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:

counter_92_6.go
// Illustrative snippet — not a complete program
func (c *Counter) IncrementHot() {
    c.mu.Lock()
    c.value++
    c.mu.Unlock()
}
BENCHMARK FIRST

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:

account_92_3.go
// 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
}
SELF-DEADLOCK TIMELINE

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:

account_92_4.go
// 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
}
NAMING CONVENTION

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:

trylock_92_1.go
// 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:

When TryLock is WRONG (most cases):

trylock_92_2.go
// 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
USE TRYLOCK SPARINGLY

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:

cache_92_4.go
// 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
}
CRITICAL SECTION DURATION TARGETS

Target: Complete in microseconds (<100μs), not milliseconds. If it might take >100μs, release the lock first.

Row
Field read/write
Map lookup
Small struct copy
Hash computation
Database query
Network call
File I/O
CRITICAL SECTION GUIDELINES

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:

counter_92_7.go
// 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())
}
Terminal
$ go run -race counter.go
Final count: 100000 (expected: 100000)

What this demonstrates:


Common Mistakes

Mistake 1: Copying Mutexes

Mutexes must never be copied. Copying creates a new, independent mutex:

counter_92_8.go
// 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.

Terminal
$ go vet
main.go:14:9: Value passes lock by value: main.Counter contains sync.Mutex

Mistake 2: defer in a Loop

CRITICAL: 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.

store_92_1.go
// 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:

trace

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:

store_92_2.go
// 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:

counter_92_9.go
// 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 SIMPLE COUNTERS: CONSIDER ATOMICS

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.


Copying mutexes (value receivers)
Problem

Value receiver copies the struct including the mutex—the copy has its own independent lock that protects nothing

Fix

Always use pointer receivers for types containing mutexes. Run go vet to catch this

defer inside a for loop
Problem

Silent deadlock on second iteration—defer runs at function return, not loop iteration end

Fix

Extract loop body into a helper function, or use explicit Unlock() in the loop

Not locking read-only access
Problem

Data race—unsynchronized reads may see stale, torn, or inconsistent values

Fix

Lock ALL access to shared data—both reads and writes need synchronization


Summary

§9.2 Reference
Lock()
Acquire exclusive access; blocks if mutex is held
Unlock()
Release exclusive access; panics if not locked
TryLock()
Non-blocking attempt; returns false if held (Go 1.18+)
Critical section
Code between Lock and Unlock—exclusive access guaranteed
Happens-before
Unlock happens-before subsequent Lock (visibility guarantee)
defer mu.Unlock()
Standard pattern—handles all exit paths and panics
Zero value
Ready to use; no initialization required
Not reentrant
Same goroutine locking twice causes deadlock

Key Takeaways

  1. Lock() blocks until acquired—the goroutine parks, consuming no CPU
  2. Always use defer mu.Unlock() immediately after Lock()—handles all exit paths, panics, and prevents double-unlock
  3. Mutex provides happens-before—not just exclusion, but visibility guarantees
  4. Go mutexes are not reentrant—use Locked suffix for internal helpers
  5. Keep critical sections short—don’t hold locks during I/O or computation (<100μs target)
  6. Never copy mutexes—use pointer receivers, run go vet
  7. Lock ALL access—both reads and writes need protection
  8. Watch for defer in 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:

config_mutex.go
// 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:

sync.Mutex: ALL ACCESS SERIALIZED

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:

config_rwmutex.go
// 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:

sync.RWMutex: CONCURRENT READS

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:

RWMutex Methods
RLock()
Acquire read (shared) lock
RUnlock()
Release read lock
Lock()
Acquire write (exclusive) lock
Unlock()
Release write lock
RWMutex STATES

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

cache_93_1.go
// 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:

Happens-Before Guarantees

Like sync.Mutex, sync.RWMutex establishes happens-before relationships:

  1. Unlock() happens-before subsequent Lock() (write → write)
  2. Unlock() happens-before subsequent RLock() (write → read)
  3. RUnlock() happens-before subsequent Lock() (read → write)
NOTE ON READ-READ ORDERING

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:

MULTIPLE CONCURRENT READERS

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:

WRITER WAITS FOR READERS

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:

WRITER PRIORITY

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.

WHY WRITER PRIORITY?

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.

IMPLICATION FOR READ-HEAVY SYSTEMS

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:

WHEN TO USE RWMutex

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:

When NOT to Use RWMutex

1. Write-Heavy Workloads

If writes are frequent, readers constantly wait for writers:

counter_rwmutex.go
// 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:

trivial_section.go
// 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:

low_concurrency.go
// 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
}
RWMutex DECISION CHECKLIST

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

sync.Mutex vs sync.RWMutex
Row
Memory
Per-operation overhead
Lock time (uncontended)
Concurrent reads
Complexity
Best for
Measured go1.26.1, darwin/amd64, uncontended: 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:

lock_upgrade_bad.go
// 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:

LOCK UPGRADE DEADLOCK

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)

lock_upgrade_fixed.go
// 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
}
WHY THE SECOND CHECK IS ESSENTIAL

Between RUnlock() and Lock(), another goroutine might have:

  1. Acquired the write lock
  2. Created the entry
  3. 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:

trylock_93_1.go
// 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:

snapshot.go
// 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:

stats.go
// 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++
    }
}
FOR SIMPLE STATS: CONSIDER ATOMICS

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:

rlock_modify.go
// 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:

mismatch.go
// 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:

wrong_lock.go
// 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.

Modifying under RLock
Problem

Multiple readers can hold RLock simultaneously—concurrent modifications are data races

Fix

Use Lock()/Unlock() for any operation that modifies data

RLock/Unlock mismatch
Problem

Wrong unlock method causes panic or undefined behavior

Fix

Always pair RLock/RUnlock and Lock/Unlock

Write lock for read-only operations
Problem

Serializes reads unnecessarily, defeating the purpose of RWMutex

Fix

Use RLock()/RUnlock() for read-only methods

Attempting lock upgrade (RLockLock)
Problem

Deadlock—waiting for yourself to release the read lock

Fix

Release RLock first, then acquire Lock, then double-check state

Forgetting double-check after upgrade
Problem

Race condition—another goroutine may have changed state in the gap

Fix

Always re-check the condition after acquiring the write lock

Benchmarking Mutex vs RWMutex

Don’t guess—measure your actual workload:

cache_bench_test.go
// 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:

Terminal
$ go test -bench=. -benchmem -cpu=1,2,4,8

Example results (read-heavy, high concurrency):

Output (read-heavy, 8 CPUs)
BenchmarkMutexCache-8    77978    5721 ns/op
BenchmarkRWMutexCache-8  398376    1159 ns/op  ← 4.9× faster

Example results (write-heavy or low concurrency):

Output (low concurrency, 1 CPU)
BenchmarkMutexCache     88376    5620 ns/op
BenchmarkRWMutexCache   83547    5952 ns/op  ← slower

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.

Measured go1.26.1, darwin/amd64: the listing above, run with -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

RWMutex Method Reference
RLock()
Acquire read lock—concurrent with other readers
RUnlock()
Release read lock
Lock()
Acquire write lock—exclusive access
Unlock()
Release write lock
TryRLock()
Non-blocking read lock (Go 1.18+)
TryLock()
Non-blocking write lock (Go 1.18+)
RLocker()
Returns a sync.Locker that calls RLock/RUnlock—useful for interfaces expecting sync.Locker

Key Takeaways

  1. RWMutex allows multiple readers OR one writer—never both simultaneously
  2. Use RLock/RUnlock for reads, Lock/Unlock for writes—always match lock types with their corresponding unlock
  3. Writer priority prevents writer starvation—waiting writers block new readers
  4. Cannot upgrade RLock to Lock—release first, reacquire, then double-check state
  5. Start with sync.Mutex—RWMutex has higher overhead and only helps for read-heavy workloads (90%+); switch only when benchmarks confirm improvement
  6. Never modify under RLock—that’s a data race
  7. Keep read critical sections meaningful—trivial operations don’t benefit from RWMutex
  8. 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.

counter_94_1.go
// 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

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.

Benefits of the Monitor Pattern
Impossible to forget locking
Methods handle it internally
Impossible to bypass locking
Fields are unexported
Clear API surface
Callers see intent, not mechanics
Changeable implementation
Can switch mutex types, add caching, etc.
Testable
Can mock or substitute the whole type

Encapsulation: Never Export Mutexes

Exporting a mutex lets callers bypass your synchronization:

cache_94_1.go
// 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
cache_94_2.go
// 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
Exported vs Unexported Mutex
Aspect
Access control
Unlock safety
Implementation
Bug prevention
RULE

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

account_94_1.go
// 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:

server_94_1.go
// 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

naming.go
// 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.

account_94_2.go
// 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
}
LOCK ORDERING

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.

Benefits of *Locked Helpers
Avoids self-deadlock
Helpers don’t acquire locks
Code reuse
Multiple public methods share logic
Clear contract
Suffix documents “lock must be held”
Composable
Can combine helpers in atomic operations
THE LOCKED SUFFIX IS A CONTRACT

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)

cache_94_3.go
// 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

cache_94_4.go
// 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]
}
POINTER RETURNS ARE DANGEROUS

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

registry_94_1.go
// 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
}
SHALLOW VS DEEP COPY

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.

registry_94_2.go
// 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:

pool_94_1.go
// 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.

INVARIANT CHECKING IN DEVELOPMENT

During development, you can add assertions inside critical sections to verify invariants hold. Remove in production or use build tags.

pool_94_2.go
// 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:

counter_94_2.go
// 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:

Embedding vs Named Field
Aspect
Method visibility
Caller behavior
Lock control
Encapsulation
counter_94_3.go
// 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
}
EXCEPTION

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:

The Problem: Check-Then-Act Gap

cache_94_5.go
// 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:

CHECK-THEN-ACT RACE CONDITION

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):

Side effects (database INSERT, API calls with rate limits):

Expensive computation (1+ seconds):

Non-idempotent reads with state changes (queue pop):

Solutions

For acceptable duplicate work:

For preventing duplicate work:

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 RACE WINDOW

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:

cache_94_6.go
// 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:

  1. Acquired the write lock
  2. Computed the value
  3. Stored it in the cache
  4. Released the lock

Without the second check:

WHEN TO SKIP DOUBLE-CHECK

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:

cache_94_7.go
// 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
}
NON-ATOMIC COMPOUND OPERATION

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:

cache_94_8.go
// 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
}
DESIGN PRINCIPLE

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:

registry_94_3.go
// 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?
    }
}
CALLBACK DEADLOCK TIMELINE

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:

  1. Deadlock risk: Callback might call back into your type
  2. Unpredictable duration: Callback might be slow, blocking all other operations
  3. Panic propagation: Callback panic leaves lock held (unless using defer)

Solution: Copy and release before calling

registry_94_4.go
// 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
    }
}
PANIC SAFETY BONUS

This pattern also provides panic safety—if handler() panics, the lock is already released, so the Registry remains usable after recovery.

NOTE

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:

registry_94_5.go
// 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:

cache_94_9.go
// 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
}
IMPACT OF LARGE CRITICAL SECTIONS

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:

cache_94_10.go
// 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
}
WHAT BELONGS IN CRITICAL SECTIONS

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?

LOCK GRANULARITY TRADE-OFF

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.

server_94_2.go
// 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:

  1. Profiling shows contention on the coarse lock
  2. Different access patterns: config is read-heavy, metrics is write-heavy
  3. Logical independence: updating metrics shouldn’t block connection lookups
  4. High concurrency: many goroutines competing for access
WARNING

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:

DETECTING LOCK CONTENTION

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.

main.go
// 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 ...
}
Terminal
$ go tool pprof http://localhost:6060/debug/pprof/mutex

Constructor Patterns

When Zero Value Works

If your type only contains a mutex and simple fields, no constructor is needed:

counter_94_4.go
// 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:

cache_94_11.go
// 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
CONSTRUCTOR CHECKLIST

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:

counter_test.go
// 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:

Terminal
$ go test -race ./...
$ go test -race -count=100 ./... # Run multiple times to catch intermittent races

Stress testing pattern:

cache_test.go
// 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()
}
TESTING BEST PRACTICES
  • Test with more goroutines than CPU cores
  • Mix readers and writers
  • Run multiple times: go test -race -count=100
  • Use -short flag 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:

registry_94_6.go
// 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:

Patterns Demonstrated
Monitor pattern
Entire design—encapsulated state and synchronization
Unexported fields
mu, services—callers use methods
Constructor
NewRegistry()—initializes map
RWMutex
Read-heavy operations use RLock
Double-check pattern
GetOrRegister()—handles race window
Atomic compound operation
GetOrRegister()—check + register atomically
Defensive copy
List()—returns copy of names
Error handling
Register()—returns error for duplicate

Design Checklist

When designing mutex-protected types:

MUTEX DESIGN CHECKLIST

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

§9.4 Pattern Summary
Monitor pattern
Encapsulate state + synchronization in one type
Unexported fields
Prevent callers from bypassing synchronization
*Locked suffix
Internal helpers that assume lock is held
Defensive copies
Prevent callers from modifying internal state
Double-check pattern
Handle race window when upgrading RLock → Lock
Atomic operations
Provide compound operations callers need
Release before callback
Prevent deadlock from callback re-entry
Minimal critical sections
Don’t hold locks during I/O or computation

Key Takeaways

  1. Encapsulation is safety—unexported mutex + methods = impossible to misuse
  2. Use *Locked suffix for internal helpers—documents “caller must hold lock”
  3. Provide atomic compound operations—individual ops don’t compose safely
  4. Never call callbacks under lock—copy data, release, then call
  5. Keep critical sections minimal—I/O and computation belong outside
  6. Double-check after RLock → Lock upgrade—state may have changed
  7. Return copies for safety—or document that pointers must not be modified
  8. 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

CHAPTER 9 QUICK REFERENCE

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:

PRE-COMMIT MUTEX CHECKLIST

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.

1. You’re building a worker pool that distributes tasks to 10 workers. Should you use a mutex or a channel? Why?

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?

account_sc_1.go
// 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
}
Reveal Answer

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:

account_sc_2.go
// 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?

counter_sc_1.go
// Illustrative snippet — not a complete program
func (c *Counter) Value() int {
    return c.value  // "It's just a read!"
}
Reveal Answer

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.

4. What’s wrong with using 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?

store_sc_1.go
// 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)
    }
}
Reveal Answer

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:

store_sc_2.go
// 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?

cache_sc_1.go
// Illustrative snippet — not a complete program
type Cache struct {
    sync.Mutex
    data map[string][]byte
}
Reveal Answer

Embedding exports Lock() and Unlock(). By embedding sync.Mutex, the Lock and Unlock methods become part of Cache’s method set:

cache_sc_2.go
// 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:

cache_sc_3.go
// 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?

cache_sc_4.go
// Illustrative snippet — not a complete program
c.mu.RLock()
// ... discovered we need to write ...
c.mu.Lock()  // Can we do this?
Reveal Answer

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:

cache_sc_5.go
// 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?

server_sc_1.go
// 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
}
Reveal Answer

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:

server_sc_2.go
// 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?

counter_sc_2.go
// Illustrative snippet — not a complete program
func (c Counter) Value() int     // A: value receiver
func (c *Counter) Value() int    // B: pointer receiver
Reveal Answer

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:

Output
main.go:14:9: Value passes lock by value: main.Counter contains sync.Mutex

10. You’re designing a thread-safe registry. Should you export the mutex and map fields, or only provide methods?

registry_sc_1.go
// 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 { ... }
Reveal Answer

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?

cache_sc_6.go
// 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
}
Reveal Answer

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:

cache_sc_7.go
// 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?

registry_sc_2.go
// 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()
    }
}
Reveal Answer

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:

registry_sc_3.go
// 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

§9.2: sync.Mutex Mechanics

§9.3: sync.RWMutex

§9.4: Design Patterns


Key principles to remember:

Key Principles

  1. Encapsulation is safety—unexported mutex + methods = correct by construction
  2. defer mu.Unlock() always—handles all exit paths and panics
  3. Lock ALL access—reads need protection too
  4. Keep critical sections minimal—no I/O, no callbacks, no computation
  5. Start simple—plain Mutex first, RWMutex only when profiling proves it helps
  6. Test with -race—catches races that careful review misses


Exercise 9.1 — Let the Readers Overlap

Your move

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.

ch09/cache.go
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
}
ch09/cache_test.go
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")
	}
}
Done when: 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.
Two traps from §9.3: using 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).
Where the files are: labs/go-concurrency/code/ch09/. A worked answer sits in solution/cache.go.txt.

Further reading

Next

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.