Chapter 10: Deadlocks

Chapter 9 taught you to protect shared state with mutexes—Lock(), Unlock(), and the critical sections between them. You learned the essential patterns: defer mu.Unlock(), the monitor pattern, and keeping critical sections short. But Chapter 9 deliberately deferred one critical topic: what happens when locks go wrong.

A deadlock is the dark mirror of synchronization. Where correct synchronization coordinates goroutines, deadlock freezes them. Where mutexes protect data, misused mutexes trap goroutines forever. Where channels enable communication, blocked channels silence programs permanently.

TWO FAILURE MODES OF SYNCHRONIZATION

Three columns contrasting missing synchronization, correct synchronization and misstructured synchronization. Missing sync gives data races (chapter 8): wrong values read, found by the race detector, fixed by adding sync. Correct sync gives safe code: proper ordering, race-free, production-ready. Misstructured sync gives deadlocks (this chapter): infinite wait, progress stops, fixed by redesign.

Consider this production incident:

service_a_10_x_1.go
// Illustrative snippet — not a complete program
// Two services that need to update each other's state
func (a *ServiceA) UpdateWithB(b *ServiceB) {
    a.mu.Lock()
    defer a.mu.Unlock()
    // ... update a's state ...
    b.NotifyChange(a)  // Calls into ServiceB while holding a.mu
}

func (b *ServiceB) NotifyChange(a *ServiceA) {
    b.mu.Lock()
    defer b.mu.Unlock()
    // ... update b's state ...
    a.RecordNotification()  // Calls back into ServiceA
}

func (a *ServiceA) RecordNotification() {
    a.mu.Lock()  // Tries to lock a.mu → already held → DEADLOCK
    defer a.mu.Unlock()
    // ...
}

This code passes review. It compiles. And it is worse than it looks.

Trace the call chain: UpdateWithB takes a.mu, calls b.NotifyChange, which takes b.mu and calls back into a.RecordNotification, which tries to take a.mu again—on the same goroutine, which is already holding it. This does not need production load, two goroutines, or an unlucky interleaving. One goroutine, on the very first call, hangs every time.

That is the first lesson of this chapter, and it runs against the intuition most people bring to concurrency bugs. We are trained by data races to expect the rare and the timing-dependent. Deadlocks include that family—§10.3's lock-ordering bugs really do wait for the unlucky interleaving—but they also include this one, which is deterministic, reproducible, and still shipped, because the cycle is spread across three methods in two files and no single function looks wrong.

The runtime catches this particular one immediately:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
main.(*ServiceA).RecordNotification(...)
main.(*ServiceB).NotifyChange(...)
main.(*ServiceA).UpdateWithB(...)

Note ServiceA appearing twice in one stack. That signature—the same type entering the stack twice—is the fastest deadlock diagnosis there is, and §10.4 builds a workflow around it.

The version that does wait for production load is the one where the two locks are taken in opposite orders by two different goroutines. That one passes tests, runs correctly for months, and then stops making progress at 3 AM with the only symptom being “service unresponsive.” We build it in §10.1 and diagnose it in §10.4.

DATA RACES VS DEADLOCKS

A two-part contrast. A data race: goroutine A writes and goroutine B reads the same shared memory, producing a wrong value that is bounded but unpredictable; the program keeps running on a value it should not have seen, and the race detector finds it. A deadlock: goroutine A waits for B while B waits for A, so both freeze and no progress is possible.

Deadlocks are particularly insidious because:

By the end of this chapter, you’ll understand:

What we’re NOT covering in Chapter 10:

Prerequisites: You should understand mutex semantics from Chapter 9—particularly Lock()/Unlock() behavior, critical sections, and why Go mutexes aren’t reentrant. You should also understand channel blocking semantics from Chapters 3–4 and goroutine coordination from Chapter 2.

WHICH GO ARE WE ON?

Every listing, exit code, and goroutine dump in this chapter was run on Go 1.25 or later. Three things in particular changed recently enough to matter when you compare this chapter against older material or an older toolchain:

  • wg.Go (Go 1.25) replaces the wg.Add(1) / defer wg.Done() pair used throughout this book’s earlier drafts. Both are correct; wg.Go is harder to get wrong.
  • Goroutine wait reasons became primitive-specific in recent releases. A goroutine blocked on a mutex now reports [sync.Mutex.Lock]. Older books, blog posts, and Stack Overflow answers show [semacquire] for the same state — it is no longer printed for sync primitives, and grepping for it will find nothing. §10.4 uses the current names.
  • The mutex implementation moved to internal/sync (Go 1.24), so stack frames read internal/sync.(*Mutex).lockSlow rather than sync.(*Mutex).lockSlow, and the mutex address you use to correlate goroutines now sits on the lockSlow frame.

Everything else here—the Coffman conditions, the detector’s rules, the prevention strategies—is version-independent.

Measured go1.26.1, darwin/amd64. Every figure, exit code, goroutine dump and “it deadlocks every time” claim in this chapter was produced by running the listing as printed. Where a result is not deterministic the run count is given with it.

Before diving in, let’s distinguish deadlocks from similar-sounding issues:

Problem
Deadlock
Goroutine leak
Data race
Livelock
Starvation

Key insight: Go’s runtime only detects deadlock when all goroutines are permanently blocked on synchronization primitives (channels, mutexes, or select without default). If even one goroutine is still runnable—a time.Sleep loop, a ticker, an idle HTTP server—the runtime won’t report deadlock, and the blocked goroutines simply hang forever. (A goroutine parked in select {} does not rescue you: it is blocked forever too, so the detector still fires. §10.4 has the full table of what counts.) This is why goroutine leaks (Chapter 2) go undetected by the deadlock detector.

Livelock vs Starvation

Livelock: Goroutines are running (consuming CPU) but making no progress—like two people in a hallway repeatedly stepping aside for each other. Unlike deadlock, CPU is busy but nothing useful happens. Common cause: TryLock retry loops without backoff.

Starvation: A goroutine is repeatedly denied resources because others keep acquiring them first. The system makes progress, but unfairly.

Both are rarer than deadlocks in Go and are typically addressed through design (backoff strategies, fair scheduling) rather than detection tools. This chapter focuses on deadlocks.

Connection to the Four Questions

Chapter 2's Question 1 asks: “How does this goroutine exit?” A goroutine stuck in deadlock never exits—it violates the most fundamental property of well-designed concurrent code.


10.1 What Causes Deadlocks: The Four Conditions

In 1971, computer scientist Edward Coffman identified four conditions that must all be present for a deadlock to occur. These Coffman conditions provide both a theoretical framework for understanding deadlocks and a practical toolkit for preventing them.

THE FOUR COFFMAN CONDITIONS

The four conditions that must all hold for a deadlock: mutual exclusion, hold and wait, no preemption, and circular wait. Removing any one of them makes deadlock impossible, which is the structure the rest of the chapter is built on.

This is analogous to Chapter 8's three conditions for data races. Just as removing any data race condition eliminates the race, removing any Coffman condition eliminates deadlock.

Bug Type
Data Race (Ch 8)
Deadlock (Ch 10)

Let’s examine each condition in the context of Go’s concurrency primitives.


Condition 1: Mutual Exclusion

Definition: At least one resource must be held in a non-shareable mode—only one goroutine can use it at a time.

In Go, mutual exclusion is the purpose of synchronization primitives:

Resource
sync.Mutex
sync.RWMutex (write)
Unbuffered channel
Buffered channel (full)
Buffered channel (empty)
mu_101.go
// Illustrative snippet — not a complete program
var mu sync.Mutex

// Only one goroutine executes this critical section at a time
mu.Lock()
// ... exclusive access ...
mu.Unlock()

Why it’s necessary for deadlock: If resources could be shared freely, goroutines wouldn’t block waiting—they’d simply access what they need.

Can we eliminate it? Sometimes, but often not:

Approach
sync.RWMutex
Atomic operations
Immutable data
Confinement

For most shared mutable state, mutual exclusion is necessary for correctness—removing it reintroduces data races. This is why deadlock prevention typically focuses on the other three conditions.


Condition 2: Hold and Wait

Definition: A goroutine holding at least one resource is waiting to acquire additional resources held by other goroutines.

This is the “greedy acquisition” pattern—goroutines don’t release what they have while waiting for more.

First, let’s define a type we’ll use throughout this chapter:

account_101.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    id      int64   // Unique identifier for lock ordering
    balance int64
}

Now consider the transfer function:

transfer_101.go
// Illustrative snippet — not a complete program
func transfer(from, to *Account, amount int64) {
    from.mu.Lock()         // HOLD from.mu
    defer from.mu.Unlock()

    to.mu.Lock()           // WAIT for to.mu (while holding from.mu)
    defer to.mu.Unlock()

    from.balance -= amount
    to.balance += amount
}

The goroutine holds from.mu while waiting for to.mu. If another goroutine holds to.mu and waits for from.mu, we have the ingredients for deadlock.

HOLD AND WAIT

A goroutine holds resource A and then requests resource B without releasing A. Because it keeps what it has while waiting for what it needs, its held resource stays unavailable to everyone else for the entire wait.

Why it’s necessary for deadlock: If goroutines released all resources before requesting new ones, circular waiting couldn’t form.

Can we eliminate it? In theory, yes—by acquiring all locks atomically or by releasing all held locks before waiting. In practice, Go doesn’t provide atomic multi-lock acquisition, making true elimination difficult.

Practical approach: Instead of eliminating hold-and-wait, we typically eliminate Condition 4 (Circular Wait) through lock ordering—covered in detail in §10.5.


Condition 3: No Preemption

Definition: Resources cannot be forcibly taken from a goroutine—they’re only released voluntarily by the holder.

Go’s synchronization primitives are non-preemptive by design:

Primitive
sync.Mutex
sync.RWMutex
Unbuffered channel
select with timeout
context.Context
mu_101_2.go
// Illustrative snippet — not a complete program
mu.Lock()
// The runtime will NEVER force this goroutine to Unlock()
// Only this goroutine can call mu.Unlock()
// If it blocks here forever, waiters block forever

Why it’s necessary for deadlock: If the runtime could forcibly reclaim resources, it could break circular waits.

Can we eliminate it? Not directly in Go. The timeout mechanisms (select with timeout, context cancellation) don’t preempt the lock holder—they let the waiter give up, leaving the holder unaffected. This breaks the wait chain but doesn’t force resource release.

Go Does Not Support Lock Preemption

Unlike channels (where you can use select with a timeout), Go’s sync.Mutex cannot be acquired with a timeout. There is no Lock(ctx) and no LockWithDeadline, and every pattern that tries to build one out of a helper goroutine either leaks that goroutine or ends up holding the lock permanently.

TryLock (used in §10.3 and §10.5.5) is the closest thing Go offers, and it is worth being precise about what it is: a non-blocking poll, not a timeout. It returns immediately with true or false; it never waits. You can build a bounded retry loop on top of it, but you are then writing a spin with backoff, and you have traded deadlock for the possibility of livelock—two goroutines politely failing forever, which §10.3 covers.

The practical approaches, in order of preference:

  1. Prevent deadlock by design—use lock ordering (§10.5.3)
  2. Detect deadlocks after they occur—use stack traces (§10.4)
  3. Use channels instead of mutexes—where the design permits
  4. TryLock with backoff—last resort, and only where failing to acquire is a legitimate outcome your caller can handle

Condition 4: Circular Wait

Definition: A closed chain of goroutines exists, where each holds a resource that the next goroutine in the chain is waiting for.

This is the condition that “closes the loop” and creates deadlock:

CIRCULAR WAIT

A closed chain of goroutines: each one holds a resource that the next goroutine in the chain is waiting for, and the last waits on the first. The cycle closes, so no goroutine in it can ever proceed.

Why it’s necessary for deadlock: Without a cycle, there’s always some goroutine that isn’t waiting for another in the set—it can proceed, release its resources, and unblock others.

Can we eliminate it? Yes—this is often the easiest condition to eliminate:

transfer_unsafe_101_x_1.go
// Illustrative snippet — not a complete program
// ✗ DEADLOCK RISK: Inconsistent ordering
func transferUnsafe(from, to *Account, amount int64) {
    from.mu.Lock()
    defer from.mu.Unlock()

    to.mu.Lock()      // Order depends on arguments
    defer to.mu.Unlock()

    from.balance -= amount
    to.balance += amount
}

// ✓ SAFE: Consistent ordering by account ID
func transfer(from, to *Account, amount int64) error {
    if from == to {
        return errSameAccount // see below: this guard is load-bearing
    }

    first, second := from, to
    if from.id > to.id {
        first, second = to, from
    }

    first.mu.Lock()
    defer first.mu.Unlock()

    second.mu.Lock()
    defer second.mu.Unlock()

    // Note: first/second are for locking order only.
    // The actual transfer direction is still from → to.
    from.balance -= amount
    to.balance += amount
    return nil
}

By ordering locks by account ID, all goroutines acquire locks in the same order. If accountA.id < accountB.id, every transfer involving these accounts locks A first, then B—regardless of transfer direction. No cycle can form. §10.5.3 covers lock ordering in depth.

ORDERING IS ONLY SAFE IF IT IS A TOTAL ORDER

The from == to guard is not defensive padding. Drop it and call transfer(acct, acct, 100)—a self-transfer, which arrives in real systems from a retry, a bad import row, or a form that lets someone pick the same account twice—and the second Lock blocks on the mutex the first Lock is holding. Measured: it deadlocks immediately, every time.

The general rule behind the guard: comparing by ID gives you a total order only when you handle equality. Two cases produce it—the same object passed twice, and two distinct objects that share an ID. The first is the common one and the guard above rejects it. For the second, fall back to a tiebreaker that is guaranteed unique:

pa_101.go
// Illustrative snippet — not a complete program
pa := uintptr(unsafe.Pointer(from))
pb := uintptr(unsafe.Pointer(to))
if from.id > to.id || (from.id == to.id && pa > pb) {
first, second = to, from
}

Two caveats on that tiebreaker. It reaches for unsafe, which is a real cost in review. And it assumes the runtime does not move heap objects between the two comparisons—true of every Go implementation to date, because Go’s collector is non-moving, but it is not a language guarantee. Prefer a real unique key when you have one.

If your IDs are genuinely unique—a database primary key—you do not need the tiebreaker at all, and you should say so in a comment so the next reader does not wonder. What you always need is the from == to guard.


Self-Deadlock: A Cycle of Length One

Circular wait doesn’t require multiple goroutines. A single goroutine can wait for itself:

cache_101_x_1.go
// Illustrative snippet — not a complete program
// Self-deadlock: Single goroutine, single mutex
func (c *Cache) Update(key string, value Data) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.validate(value)  // Calls validate while holding lock
}

func (c *Cache) validate(value Data) {
    c.mu.Lock()  // Attempts to lock again → DEADLOCK
    defer c.mu.Unlock()
    // ...
}

Four conditions analysis:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

This is why Go mutexes are non-reentrant. See §9.4 for the *Locked helper pattern that prevents this issue.


The Conditions in Practice

Here’s a concrete deadlock demonstrating all four conditions:

wg_101.go
// Illustrative snippet — not a complete program
var muA, muB sync.Mutex

func main() {
    var wg sync.WaitGroup

    // Goroutine 1: locks A, then B
    wg.Go(func() {
        muA.Lock()
        defer muA.Unlock()

        time.Sleep(time.Millisecond) // Ensure interleaving

        muB.Lock()
        defer muB.Unlock()
    })

    // Goroutine 2: locks B, then A
    wg.Go(func() {
        muB.Lock()
        defer muB.Unlock()

        time.Sleep(time.Millisecond) // Ensure interleaving

        muA.Lock()
        defer muA.Unlock()
    })

    wg.Wait()
}

Execution trace showing deadlock:

AB-BA DEADLOCK TIMELINE

An execution timeline for two goroutines. At T0 goroutine 1 locks A successfully; at T1 goroutine 2 locks B successfully; at T2 both sleep; at T3 goroutine 1 asks for B and waits; at T4 goroutine 2 asks for A and waits; at T5 both are blocked forever, and the runtime prints: fatal error, all goroutines are asleep, deadlock.

Why the Runtime Detected This Deadlock

The fatal error appears because all goroutines are blocked: G1 and G2 on mutexes, and main on wg.Wait(). If main had left any runnable goroutine behind, the runtime would not report this deadlock—the blocked goroutines would simply hang forever.

Measured, with the same AB-BA deadlock underneath:

Also running
nothing
go func(){ for { time.Sleep(10*time.Millisecond) } }()
go func(){ select{} }()

The middle row is what production looks like: one health-check ticker is enough to hide the deadlock from the runtime completely. The third row is the common misconception—select {} blocks forever, so it is counted as asleep like everything else and does not suppress detection.

Timing Matters

This trace shows one possible interleaving that causes deadlock. Without the time.Sleep calls, Goroutine 1 might complete both locks before Goroutine 2 starts—no deadlock would occur. In real code, deadlocks often manifest only under specific timing conditions (like production load), making them hard to reproduce in testing.

Analyzing the four conditions. Same four boxes as the self-deadlock above—but notice what changed. There, the cycle had length one and the “circular wait” was a goroutine waiting on itself. Here it has length two. That is the only difference between the two bugs, and it is why one is deterministic and this one needs an unlucky interleaving:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

Breaking Deadlocks: Remove Any Condition

Since all four conditions must be present, removing any one prevents deadlock:

DEADLOCK PREVENTION STRATEGIES

Each Coffman condition paired with how you would eliminate it in Go. Mutual exclusion: use RWMutex, atomics or immutable data where possible, though it is often not eliminable because correctness needs it. Hold and wait: release all locks before acquiring new ones, or use lock ordering to prevent cycles, which is the practical approach. No preemption: use timeouts to abandon waiting via context or select, which works for channels but not mutexes. Circular wait: impose a global lock ordering, the most practical general solution.

Lock Ordering Is Usually the Best Strategy

Eliminating circular wait through consistent lock ordering is the most widely applicable prevention technique:

  • Doesn’t require changing lock semantics
  • No runtime overhead (unlike timeouts)
  • Works for any number of locks
  • Enforced by convention and code review

§10.5 covers lock ordering in depth.


Common Misconceptions

Misconception
“I only have one mutex, so no deadlock”
“Deadlock requires multiple locks”
“It works in testing, so no deadlock”
“Adding timeouts everywhere fixes it”
defer mu.Unlock() prevents deadlock”

Summary: The Four Conditions

Condition
Mutual Exclusion
Hold and Wait
No Preemption
Circular Wait

Key Takeaways

  1. All four conditions must be present for deadlock—remove any one to prevent it
  2. Mutual exclusion is often necessary—focus on eliminating other conditions
  3. Hold and wait creates the “grip”—goroutines won’t release what they have
  4. No preemption means no escape—blocked goroutines stay blocked forever
  5. Circular wait closes the loop—the most directly preventable condition
  6. Lock ordering is the primary prevention strategy—simple, effective, no runtime cost
  7. Channels can deadlock too—the four conditions apply to any blocking synchronization
  8. Self-deadlock is real—a single goroutine can deadlock with a single mutex
  9. Deadlocks are timing-dependent—they may not manifest in every run
  10. Testing isn’t sufficient—reason about possible interleavings

Next: §10.2 examines channel deadlock patterns—the specific ways channels can trap goroutines and how to recognize and prevent each pattern.

10.2 Channel Deadlocks

§10.1 established the four Coffman conditions using mutex examples. But channels—Go’s primary coordination mechanism—can deadlock just as easily. Channel deadlocks are often more subtle because the “resources” being contested are synchronization points, not explicit locks. For a refresher on channel semantics, review Chapter 3.

MUTEX VS CHANNEL DEADLOCKS

A comparison of how deadlocks present in the two primitives: mutex deadlocks come from lock ordering and re-entry, channel deadlocks from missing partners, unclosed channels and circular dependencies.

Channels and the Four Conditions:

Coffman Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

A channel operation blocks when it cannot complete:

Operation
Send on unbuffered channel
Receive from unbuffered channel
Send on full buffered channel
Receive from empty buffered channel
Any operation on nil channel

This section covers three fundamental channel deadlock patterns:

  1. All goroutines blocked—complete program deadlock (runtime detects)
  2. Unclosed channels with waiting receivers—sometimes detected, often a goroutine leak
  3. Circular channel dependencies—goroutines waiting for each other in cycles

Runtime Detection: When It Works

Before examining patterns, understand what the runtime can and cannot detect.

Go’s runtime detects deadlock when all goroutines are blocked waiting for synchronization that requires another goroutine to provide—channel operations, mutex locks, or select without ready cases. The key insight: timed operations like time.Sleep don’t trigger the detector because they’ll complete on their own without help from other goroutines.

Goroutine State
chan send
chan receive
select (no ready cases)
sync.Mutex.Lock
time.Sleep
IO wait
CRITICAL LIMITATION

A warning panel: production systems almost never trigger the runtime detector, because HTTP servers, metric collectors and background workers keep running even when the business logic deadlocks. A single running goroutine hides every blocked goroutine. You must instead use runtime.NumGoroutine, goleak and pprof, and never rely on the all-goroutines-are-asleep message in production.


Pattern 1: All Goroutines Blocked

The simplest channel deadlock occurs when every goroutine is blocked on channel operations—no one can make progress because everyone is waiting.

Send Without Receiver

main_102_x_1.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)  // Unbuffered
    ch <- 42              // Blocks forever—no receiver exists
    fmt.Println("done")   // Never reached
}

Output:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
    /tmp/main.go:5 +0x50

Why it deadlocks: An unbuffered channel requires a sender and receiver to rendezvous simultaneously. With only one goroutine, there’s no one to receive—the send blocks forever.

UNBUFFERED CHANNEL SEMANTICS

An unbuffered send blocks until a receiver is ready, and an unbuffered receive blocks until a sender is ready. The send and the receive complete together as a single rendezvous, which is why a missing partner blocks forever.

Applying the four conditions:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

The symmetric case—receiving with no sender—is identical:

main_102_x_2.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)
    val := <-ch           // Blocks forever—no sender exists
    fmt.Println(val)
}

Output:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
    /tmp/main.go:5 +0x4a

Nil Channel Operations

A receive (or send) on a nil channel blocks forever. This often occurs with uninitialized channel variables:

main_102_x_3.go
// Illustrative snippet — not a complete program
func main() {
    var ch chan int       // nil—not initialized with make()
    val := <-ch           // Blocks forever
    fmt.Println(val)
}

Output:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
main.main()
    /tmp/main.go:5 +0x...
Operation
Send ch <- v
Receive <-ch
Close close(ch)
The Stack Trace Reveals Nil Channels

Go’s stack trace explicitly says chan receive (nil chan). When debugging deadlocks, this phrase immediately identifies the problem. Nil channels are useful in select statements to disable cases, but catastrophic when unintentional.


Pattern 2: Unclosed Channels with Waiting Receivers

A for range over a channel exits only when the channel is closed. If no goroutine closes the channel, the loop blocks forever. See §5.3 for channel lifecycle patterns.

main_102_x_4.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)

    go func() {
        ch <- 1
        ch <- 2
        ch <- 3
        // ✗ BUG: forgot to close(ch)
    }()

    for val := range ch {  // Blocks forever after receiving 3
        fmt.Println(val)
    }
    fmt.Println("done")    // Never reached
}

Output:

Terminal
1
2
3
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
    /tmp/main.go:13 +0x...

Execution trace:

UNCLOSED CHANNEL TIMELINE

An execution timeline. The main goroutine ranges over a channel and waits. The sender delivers values 1, 2 and 3, which main receives and prints, and then the sender returns without closing the channel. Main waits for a next value that never comes; the sender goroutine has terminated, only the blocked main remains, and the runtime reports a deadlock.

Why it’s detected here: After the sender goroutine exits (completes all sends and returns), main is the only goroutine left—and it’s blocked waiting for more values or channel close. The runtime detects this because all goroutines are now blocked or completed.

The fix: Always close channels when sending is complete:

snippet_102.go
// Illustrative snippet — not a complete program
go func() {
    defer close(ch)  // ✓ Ensures close on all exit paths
    ch <- 1
    ch <- 2
    ch <- 3
}()
Sender-Closes Principle
  • The goroutine that sends should close the channel
  • Receivers should never close—they don’t know if other senders exist
  • Closing signals “no more values” to all receivers
  • for range exits cleanly when the channel closes

See §5.3 for detailed channel lifecycle patterns.

When Detection Fails: Goroutine Leaks

If other goroutines keep running, blocked receivers become invisible goroutine leaks:

main_102_5.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)

    go func() {
        <-ch  // Blocked forever (goroutine leak)
    }()

    // Main continues running—no deadlock reported
    http.ListenAndServe(":8080", nil)
}

The receiver goroutine is permanently blocked, but the runtime sees the HTTP server running and assumes progress is possible. This is a partial deadlock—some goroutines stuck, others running.

Partial Deadlock = Goroutine Leak

When some goroutines are permanently blocked but others continue running:

  • It’s a deadlock from the blocked goroutines' perspective
  • It’s a goroutine leak that the runtime can’t detect
  • Production code rarely sees “all goroutines asleep” because background services run

Detection requires tools: goleak, runtime.NumGoroutine(), goroutine profiling (pprof)

Timeout-Based Goroutine Leaks

A subtle variant—timeouts that abandon blocked senders:

search_102_x_1.go
// Illustrative snippet — not a complete program
func search(query string) string {
    results := make(chan string)

    go func() {
        result := performSearch(query)  // Takes 100ms
        results <- result  // ← Blocks forever if we timeout!
    }()

    select {
    case r := <-results:
        return r
    case <-time.After(50 * time.Millisecond):
        return "timeout"  // We return, but sender still blocked
    }
}

What happens:

  1. Timeout fires before search completes (50ms < 100ms)
  2. Function returns "timeout"
  3. Background goroutine finishes, tries to send
  4. Send blocks forever—no receiver exists (we already returned)
  5. Goroutine leaks (not detected—caller continues running)

Fix: Buffer matches sender count

search_102_2.go
// Illustrative snippet — not a complete program
func search(query string) string {
    results := make(chan string, 1)  // ✓ Buffer = 1 sender

    go func() {
        result := performSearch(query)
        results <- result  // Send completes even without receiver
    }()

    select {
    case r := <-results:
        return r
    case <-time.After(50 * time.Millisecond):
        return "timeout"  // Goroutine can still send and exit
    }
}
Buffer Sizing Rule for Timeout Patterns

When multiple goroutines send on a channel and the receiver might not consume all values (timeout, early return, first-wins), the buffer must be at least as large as the number of senders. This ensures all goroutines can complete their sends without blocking.

results_102.go
// Illustrative snippet — not a complete program
// 3 concurrent senders → buffer size 3
results := make(chan Result, 3)

This is NOT a general buffering rule—normal pipelines don’t require this.

Closing Doesn’t Unblock Senders

A common mistake:

snippet_102_x_2.go
// Illustrative snippet — not a complete program
select {
case r := <-results:
return r
case <-time.After(timeout):
close(results)  // ✗ Doesn't help!
return "timeout"
}

Closing a channel unblocks receivers (they get zero value), but senders panic if they send on a closed channel:

Terminal
panic: send on closed channel

Multiple Senders: Coordination Required

When multiple senders write to the same channel, closing requires coordination:

fetch_all_102.go
// Illustrative snippet — not a complete program
func fetchAll(urls []string) []string {
    results := make(chan string)
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Go(func() {
            results <- fetch(url)
        })
    }

    // Close channel when ALL senders complete
    go func() {
        wg.Wait()
        close(results)
    }()

    var responses []string
    for result := range results {  // Now terminates when closed
        responses = append(responses, result)
    }
    return responses
}

Alternative—know the count in advance:

fetch_all_102_2.go
// Illustrative snippet — not a complete program
func fetchAll(urls []string) []string {
    results := make(chan string, len(urls))

    for _, url := range urls {
        go func() {
            results <- fetch(url)
        }(url)
    }

    responses := make([]string, 0, len(urls))
    // Receive exactly the expected count
    for i := 0; i < len(urls); i++ {
        responses = append(responses, <-results)
    }
    return responses
}
Caveat

This pattern assumes all senders will send exactly once. If a sender might fail silently (panic, early return without send), use the WaitGroup + close pattern instead—it’s more defensive.


Pattern 3: Circular Channel Dependencies

The most subtle channel deadlock: goroutines arranged in a cycle where each waits for data from the next.

Two-Goroutine Cycle

main_102_6.go
// Illustrative snippet — not a complete program
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    // Goroutine A: sends to ch1, then receives from ch2
    go func() {
        ch1 <- 1      // Blocks waiting for receiver
        fmt.Println(<-ch2)
    }()

    // Goroutine B: sends to ch2, then receives from ch1
    go func() {
        ch2 <- 2      // Blocks waiting for receiver
        fmt.Println(<-ch1)
    }()

    select {}  // Block main forever—enables deadlock detection
}

Output:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
    /tmp/main.go:18 +0x...
goroutine 5 [chan send]:
main.main.func1()
    /tmp/main.go:9 +0x...
goroutine 6 [chan send]:
main.main.func2()
    /tmp/main.go:15 +0x...

Execution trace:

CIRCULAR CHANNEL DEPENDENCY TIMELINE

An execution timeline for two goroutines. Goroutine A sends on channel 1 and blocks; goroutine B sends on channel 2 and blocks; both are now waiting for a receiver that the other would have provided, so both are blocked forever and the program deadlocks.

CIRCULAR CHANNEL DEPENDENCY

Goroutine A waits to send on the channel B would read, while goroutine B waits to send on the channel A would read. The dependency closes into a cycle, so neither send can ever complete.

Coffman conditions:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

Fix 1: Reorder Operations

wg_102.go
// Illustrative snippet — not a complete program
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    var wg sync.WaitGroup

    // Goroutine A: receives first, then sends
    wg.Go(func() {
        val := <-ch2          // ✓ Receive first (waits for B)
        fmt.Println("A received:", val)
        ch1 <- 1              // Then send
    })

    // Goroutine B: sends first, then receives
    wg.Go(func() {
        ch2 <- 2              // ✓ Send first (unblocks A)
        val := <-ch1          // Then receive
        fmt.Println("B received:", val)
    })

    wg.Wait()
}

Output:

Terminal
A received: 2
B received: 1

Why this works: Operations are now ordered sequentially—no circular wait.

Fix 2: Use Buffered Channels

wg_102_2.go
// Illustrative snippet — not a complete program
func main() {
    ch1 := make(chan int, 1)  // Buffer eliminates hold-and-wait
    ch2 := make(chan int, 1)  // Both sends complete before receives
    var wg sync.WaitGroup

    wg.Go(func() {
        ch1 <- 1              // Completes immediately (buffer)
        fmt.Println(<-ch2)
    })

    wg.Go(func() {
        ch2 <- 2              // Completes immediately (buffer)
        fmt.Println(<-ch1)
    })

    wg.Wait()
}

Output — either ordering, and it changes run to run:

Terminal
1 2
2 or 1
Measured over 40 runs of the listing above, 1 then 2 came out 31 times and 2 then 1 nine times. Fix 1, by contrast, printed the same order on all 40.

Why this works: Buffering eliminates hold-and-wait—both goroutines complete their sends immediately, then both receives succeed.

BUFFERING CHANGES WHETHER YOU BLOCK, NEVER *WHO GOES FIRST

*

Compare the two fixes. Fix 1's output is genuinely deterministic—40 runs, 40 identical outputs—because the unbuffered channels order the two goroutines against each other: B’s send cannot complete until A receives, so A received: 2 always precedes B received: 1. Fix 2's is not, because once both sends land in buffers the two goroutines are free to race to their Println.

Both programs are correct. Only one has a predictable output, and the difference is a good instinct to build early: if your test asserts on output order, make sure something in the program actually establishes that order. A buffer does not.

Pipeline Deadlocks

Pipelines (Chapter 7) are susceptible to deadlocks when stages exit without closing their output:

pipeline_102_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Middle stage exits without closing output
func pipeline() {
    nums := make(chan int)
    squared := make(chan int)

    // Generator
    go func() {
        defer close(nums)
        for i := 1; i <= 5; i++ {
            nums <- i
        }
    }()

    // Squarer—but exits early without closing!
    go func() {
        for n := range nums {
            if n == 3 {
                return  // ✗ Early exit! Never closes squared
            }
            squared <- n * n
        }
        close(squared)
    }()

    // Consumer blocks forever
    for result := range squared {
        fmt.Println(result)
    }
}

Output:

Terminal
1
4
fatal error: all goroutines are asleep - deadlock!

The fix: Use defer close() at the start of every pipeline stage:

snippet_102_3.go
// Illustrative snippet — not a complete program
// ✓ FIXED: Guaranteed closure even on early exit
go func() {
    defer close(squared)  // Always closes, even on early return
    for n := range nums {
        if n == 3 {
            return
        }
        squared <- n * n
    }
}()
Complete Pipeline Cleanup Is Complex

The fix above ensures the consumer terminates, but the generator goroutine may still be blocked if the squarer stops consuming. Full pipeline cleanup requires either:

  • Draining all upstream channels
  • Using context cancellation to signal all stages (see Chapter 13)
  • Designing pipelines to handle partial consumption

For production pipelines, Chapter 13's context-based patterns provide complete cleanup.

Pipeline Principle

Every pipeline stage should:

  1. Use defer close(output) at the start of the goroutine
  2. Respect context cancellation for clean shutdown
  3. Handle upstream closure gracefully (for range does this automatically)

Prevention Checklist: Channel Deadlocks

CHANNEL DEADLOCK PREVENTION

The prevention rules for channel deadlocks: close in the sender and never in a receiver, initialize every channel before use, size buffers deliberately, and break send-receive cycles by reordering the operations.


Common Mistakes: Channel Deadlocks

Mistake
Unbuffered send in single goroutine
Forgot close(ch) with for range
Uninitialized channel (var ch chan T)
Buffer size < sender count in timeout
Circular channel dependencies
Closing to “unblock” senders
Nil channel in select without default

Summary: Channel Deadlocks

Pattern
Send without receiver
Receive without sender/close
Insufficient buffer
Nil channel
Circular dependency
Pipeline break

Key Takeaways

  1. Channel deadlocks follow the four Coffman conditions—the resources are synchronization points, not locks
  2. Unbuffered channels require simultaneous send and receive—one without the other blocks
  3. Runtime detection requires ALL goroutines blocked—timed operations and I/O don’t count
  4. Partial deadlocks are goroutine leaks—runtime silent, detect with goleak/pprof
  5. The sender-closes principle prevents receiver deadlocksdefer close(ch) in sender goroutines
  6. Buffer size must accommodate all potential senders—when receivers may not consume all values
  7. Nil channels block forever—useful in select to disable cases, catastrophic when accidental
  8. Circular dependencies are the channel equivalent of lock-ordering violations—break cycles by reordering or buffering
  9. Closing doesn’t unblock senders—they panic; use buffers instead
  10. Production code rarely triggers detection—background goroutines mask deadlocked workers
Connection to the Four Questions

Recall Chapter 2's framework:

  • Question 1: “How does this goroutine exit?” — A goroutine stuck in channel deadlock never exits.
  • Question 2: “How does it communicate results?” — A channel deadlock means results never arrive.

Before launching any channel-using goroutine, verify sends have receivers (or buffers) and receives have senders (or the channel will close).


Self-Check Questions: Channel Deadlocks

Test your understanding of channel deadlocks:

1. Why does time.Sleep(time.Second) at the end of main prevent deadlock detection, even if other goroutines are permanently blocked?

time.Sleep is timer-based—it will unblock on its own after the duration, without needing another goroutine. The deadlock detector only triggers when all goroutines are blocked waiting for synchronization that requires another goroutine to provide (channel ops, mutexes). After the sleep, main exits normally, killing the blocked goroutines via the “Iron Rule” (Chapter 2).

2. This code has a bug. What happens, and how do you fix it?

process_102.go
// Illustrative snippet — not a complete program
func process(items []int) []int {
    results := make(chan int)
    for _, item := range items {
        go func() {
            results <- item * 2
        }()
    }

    var output []int
    for result := range results {
        output = append(output, result)
    }
    return output
}
Show answer

Deadlock after receiving all items. The for range waits for close(results), but no one closes it. Fix: use a WaitGroup to track goroutines and close the channel when all complete:

wg_102_3.go
// Illustrative snippet — not a complete program
var wg sync.WaitGroup
for _, item := range items {
    wg.Go(func() {
        results <- item * 2
    })
}
go func() {
    wg.Wait()
    close(results)
}()
3. Two goroutines with unbuffered channels: G1 sends to chA then receives from chB; G2 sends to chB then receives from chA. Will this deadlock? What if the channels are buffered with capacity 1?

With unbuffered channels: Yes, deadlock. Both try to send first (blocking on rendezvous), neither reaches their receive—circular wait. With make(chan int, 1): No deadlock. Both sends complete immediately into the buffer, then both receives succeed. Buffering eliminates hold-and-wait.


Next: §10.3 examines mutex deadlock patterns—lock ordering violations, self-deadlock from non-reentrant mutexes, and the classic dining philosophers problem.

10.3 Mutex Deadlocks

§10.2 covered channel deadlocks—goroutines blocked waiting for synchronization partners. Mutex deadlocks follow the same four Coffman conditions but manifest differently: instead of waiting for send/receive operations, goroutines wait for lock holders to release.

CHANNEL VS MUTEX DEADLOCK COMPARISON

A side-by-side comparison of the two deadlock families: what each looks like in a stack trace, what causes it, and which prevention technique applies.

Mutex deadlocks are particularly insidious because:

This section covers four mutex deadlock patterns:

  1. Self-deadlock—a goroutine attempts to lock a mutex it already holds
  2. Lock ordering violations—multiple goroutines acquire the same locks in different orders
  3. Dining philosophers—the classic N-goroutine, N-resource deadlock
  4. Blocking channel operations under mutex—mixing synchronization primitives incorrectly

Pattern 1: Self-Deadlock

The simplest mutex deadlock involves a single goroutine attempting to lock a mutex it already holds. From Chapter 9, recall that Go mutexes are non-reentrant: a goroutine cannot lock a mutex it already holds.

Basic Self-Deadlock

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

func (c *Cache) Get(key string) []byte {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.data[key]
}

// ✗ SELF-DEADLOCK
func (c *Cache) GetWithFallback(key, fallbackKey string) []byte {
    c.mu.Lock()
    defer c.mu.Unlock()

    val := c.Get(key)         // Tries to lock c.mu again → DEADLOCK
    if val != nil {
        return val
    }
    return c.Get(fallbackKey)
}
SELF-DEADLOCK TIMELINE

An execution timeline for a single goroutine. It locks the mutex successfully, then calls a method that locks the same mutex again. Because Go mutexes are not reentrant the second acquisition blocks, and it is blocked on a lock that only it could release.

SELF-DEADLOCK: CYCLE OF LENGTH ONE

The circular wait condition drawn for one goroutine: it holds the mutex and waits for the same mutex. The dependency cycle has length one, which is why this deadlock needs no concurrency at all and fires on the very first call.

Applying the four conditions:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait
Why This Deadlock Is Permanent

Unlike some blocking operations that can timeout or be cancelled, mutex deadlock is absolute:

  • No other goroutine can unlock the mutex (Chapter 9: “The same goroutine that calls Lock() must call Unlock()”)
  • The goroutine can’t proceed to its defer mu.Unlock() because it’s blocked
  • No timeout exists—Lock() blocks indefinitely until available

Detection: Stack Traces

When the runtime detects self-deadlock (all goroutines blocked), the stack trace reveals the issue:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(0x...)
    /usr/local/go/src/runtime/sema.go:77 +0x...
internal/sync.(*Mutex).lockSlow(0x...)
    /usr/local/go/src/internal/sync/mutex.go:171 +0x...
internal/sync.(*Mutex).Lock(...)
    .../internal/sync/mutex.go:81
main.(*Cache).Get(...)
    /tmp/main.go:15
main.(*Cache).GetWithFallback(...)
    /tmp/main.go:28
main.main()
    /tmp/main.go:40

Reading the stack:

  1. Current line: (*Cache).Get line 15—the second Lock() call
  2. Called from: (*Cache).GetWithFallback line 28—which already holds the lock
  3. Same goroutine, same mutex—self-deadlock
Stack Trace Pattern Recognition

Self-deadlock signatures:

  • sync.(*Mutex).Lock appears multiple times in same goroutine’s stack
  • Call chain shows methods on same type (e.g., Cache → Cache)
  • All calls are synchronous (no goroutine boundaries)

The Fix: Internal Helpers with Locked Suffix

Chapter 9.4 introduced the *Locked suffix pattern:

cache_103_2.go
// Illustrative snippet — not a complete program
// ✓ PUBLIC METHOD: Acquires lock
func (c *Cache) Get(key string) []byte {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.getLocked(key)
}

// ✓ PRIVATE HELPER: Caller MUST hold c.mu
func (c *Cache) getLocked(key string) []byte {
    return c.data[key]
}

// ✓ SAFE: Uses helpers—no self-deadlock
func (c *Cache) GetWithFallback(key, fallbackKey string) []byte {
    c.mu.Lock()
    defer c.mu.Unlock()

    // []byte zero value is nil
    if val := c.getLocked(key); val != nil {
        return val
    }
    return c.getLocked(fallbackKey)
}

The pattern:

Method Type
Public API
Private helper
Chapter 9 Connection

The *Locked helper pattern (§9.4) exists precisely to prevent this deadlock pattern. Every multi-method type with a mutex should use it.

Why *Locked Suffix Works

The suffix creates a visual and semantic contract:

  • Public methods acquire locks → call *Locked helpers
  • *Locked helpers NEVER acquire locks → safe to call under lock
  • Code reviewers immediately recognize the pattern

Callback Re-Entry Deadlock

Self-deadlock often occurs through callbacks—code you control calls code you don’t, which calls back:

registry_103.go
// Illustrative snippet — not a complete program
type Registry struct {
    mu       sync.Mutex
    handlers map[string]func()
}

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?
    }
}

func (r *Registry) Register(event string, handler func()) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.handlers[event] = handler
}

Deadlock scenario:

snippet_103_x_1.go
// Illustrative snippet — not a complete program
registry.Register("startup", func() {
    registry.Register("cleanup", cleanupHandler)  // DEADLOCK!
})

registry.Notify("startup")
CALLBACK RE-ENTRY DEADLOCK

A type calls a user-supplied callback while holding its own lock. The callback calls back into a public method of the same type, which tries to take the lock again, and the goroutine deadlocks against itself.

Prevention: Release lock before callback (Chapter 9.4):

registry_103_2.go
// Illustrative snippet — not a complete program
// ✓ SAFE: Release before callback
func (r *Registry) Notify(event string) {
    r.mu.Lock()
    handler, ok := r.handlers[event]
    r.mu.Unlock()  // Release lock BEFORE callback—cannot use defer here

    if ok {
        handler()  // Now safe—no lock held
    }
}
Rule: Never Call Unknown Code Under Lock

“Unknown code” includes:

  • User-provided callbacks and handlers
  • Interface method implementations
  • Functions from other packages
  • Any code you don’t fully control

Copy the data you need, release the lock, then make the call.

RLock to Lock Upgrade

A related self-deadlock occurs with sync.RWMutex when attempting to “upgrade” a read lock to a write lock:

cache_103_x_3.go
// Illustrative snippet — not a complete program
func (c *Cache) GetOrCompute(key string) int {
    c.mu.RLock()
    if val, ok := c.data[key]; ok {
        c.mu.RUnlock()
        return val
    }

    // Key not found—need to write
    // DEADLOCK: waits for all readers -- including this goroutine
    c.mu.Lock()
    defer c.mu.Unlock()
    // ...
}

Lock() waits for all readers to release, but this goroutine is a reader. It waits for itself—instant deadlock.

The fix: Release the read lock before acquiring the write lock, then double-check:

cache_103_4.go
// Illustrative snippet — not a complete program
func (c *Cache) GetOrCompute(key string) int {
    c.mu.RLock()
    if val, ok := c.data[key]; ok {
        c.mu.RUnlock()
        return val
    }
    c.mu.RUnlock()  // Release read lock BEFORE acquiring write lock

    c.mu.Lock()
    defer c.mu.Unlock()

    // CRITICAL: Double-check! Another goroutine may have written.
    if val, ok := c.data[key]; ok {
        return val
    }

    val := compute(key)
    c.data[key] = val
    return val
}

Pattern 2: Lock Ordering Violations

The most common mutex deadlock in production: two goroutines acquire the same locks in opposite orders.

The Classic AB-BA Deadlock

routine1_103.go
// Illustrative snippet — not a complete program
var muA, muB sync.Mutex

// Goroutine 1: locks A, then B
func routine1() {
    muA.Lock()
    defer muA.Unlock()

    muB.Lock()
    defer muB.Unlock()
    // work...
}

// Goroutine 2: locks B, then A
func routine2() {
    muB.Lock()         // ← Opposite order!
    defer muB.Unlock()

    muA.Lock()
    defer muA.Unlock()
    // work...
}

Execution trace showing deadlock:

LOCK ORDERING VIOLATION TIMELINE

An execution timeline. Goroutine 1 locks mutex A; goroutine 2 locks mutex B; goroutine 1 then asks for B and waits; goroutine 2 asks for A and waits. Goroutine 1 holds A and wants B while goroutine 2 holds B and wants A, so both are blocked and the cycle is closed.

LOCK ORDERING VIOLATION

Two code paths acquire the same pair of mutexes in opposite orders. Either path alone is correct; run concurrently they form a cycle, which is why the bug survives review and single-threaded tests.

Coffman conditions analysis:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait
The “Works in Testing” Trap

Lock ordering deadlocks are timing-dependent—they don’t happen on every execution. The code may run correctly thousands of times before deadlocking. The bug exists from day one, but it waits for production load to expose the specific interleaving.

Terminal
99 runs: A locks first, completes → No deadlock
1 run: Both lock simultaneously → DEADLOCK

This is why lock ordering violations are often discovered in production, not testing.

Real-World Example: Account Transfers

Recall the Account type from §10.1:

account_103.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    id      int64
    balance int64
}

A naive transfer function has a lock ordering vulnerability:

transfer_unsafe_103_x_1.go
// Illustrative snippet — not a complete program
// ✗ DEADLOCK RISK: Lock order depends on arguments
func transferUnsafe(from, to *Account, amount int64) {
    from.mu.Lock()
    defer from.mu.Unlock()

    to.mu.Lock()
    defer to.mu.Unlock()

    if from.balance < amount {
        return
    }
    from.balance -= amount
    to.balance += amount
}

The problem: If two goroutines transfer in opposite directions simultaneously:

snippet_103_2.go
// Illustrative snippet — not a complete program
// Goroutine 1
transferUnsafe(accountA, accountB, 100)  // Locks A, then B

// Goroutine 2
transferUnsafe(accountB, accountA, 50)   // Locks B, then A

The fix: Acquire locks in a consistent order regardless of transfer direction:

transfer_103_x_1.go
// Illustrative snippet — not a complete program
// ✓ DEADLOCK-FREE: Always lock lower ID first
func transfer(from, to *Account, amount int64) error {
    // Check for self-transfer (prevents self-deadlock)
    if from == to {
        // Prevent self-deadlock: would try to lock same mutex twice
        // Also a no-op from business logic perspective
        return nil
    }

    // Determine lock order by account ID
    first, second := from, to
    if from.id > to.id {
        first, second = to, from
    }

    first.mu.Lock()
    defer first.mu.Unlock()

    second.mu.Lock()
    defer second.mu.Unlock()

    // Lock order uses first/second. Transfer logic uses from/to.
    // This decouples synchronization order from business
    // logic direction.
    if from.balance < amount {
        return errors.New("insufficient funds")
    }
    from.balance -= amount
    to.balance += amount
    return nil
}
WHY SELF-TRANSFER CHECK IS NECESSARY

When both arguments name the same account, ordering by id compares equal and the code locks one mutex twice. The second acquisition blocks on the lock the first is holding, so a self-transfer self-deadlocks unless an equality guard rejects it first.

Now both transfer(A, B, 100) and transfer(B, A, 50) acquire locks in the same order (lower ID first). No cycle can form.

Lock Order ≠ Business Logic Order

first/second determine lock acquisition order, but the actual balance modification still uses from/to. The ordering logic is purely for synchronization—it doesn’t change what the transfer does.

Chapter 9 Warning Revisited

Chapter 9's bank transfer example included this warning:

“Production code requires self-transfer checks and consistent lock ordering. Chapter 10 covers deadlock prevention strategies in depth

Now you know why: without self-transfer checks (self-deadlock) and lock ordering (circular wait), the transfer function deadlocks.

Hidden Lock Ordering in Call Chains

The most insidious production deadlocks occur when lock ordering violations hide across function calls:

service_a_103.go
// Illustrative snippet — not a complete program
// module_a.go
func (a *ServiceA) ProcessRequest(b *ServiceB) {
    a.mu.Lock()
    defer a.mu.Unlock()

    // ... some work ...
    b.UpdateState()  // Acquires b.mu while holding a.mu
}

// module_b.go
func (b *ServiceB) RefreshFromA(a *ServiceA) {
    b.mu.Lock()
    defer b.mu.Unlock()

    // ... some work ...
    a.GetData()  // Acquires a.mu while holding b.mu
}

Neither function looks problematic in isolation. The deadlock only becomes apparent when you trace the full call graph:

Terminal
ProcessRequest: a.mu → b.mu
RefreshFromA: b.mu → a.mu
Prevention: Document Lock Dependencies

For types with mutexes that may call other locked types:

  1. Document which external locks each method may acquire
  2. Establish a global ordering (e.g., “always acquire ServiceA before ServiceB”)
  3. Review cross-module calls for ordering violations

Ordering Multiple Locks

Lock ordering extends to three or more locks. The principle remains: define a global total order and always acquire in that order.

lock_accounts_103.go
// Illustrative snippet — not a complete program
// ✓ GENERAL PATTERN: Order N locks before acquiring
func lockAccounts(accounts []*Account) []*Account {
    // 1. Remove duplicates and nils (prevents self-deadlock)
    unique := make(map[*Account]bool)
    var toLock []*Account
    for _, acc := range accounts {
        if acc != nil && !unique[acc] {
            unique[acc] = true
            toLock = append(toLock, acc)
        }
    }

    // 2. Sort by ID
    sort.Slice(toLock, func(i, j int) bool {
        return toLock[i].id < toLock[j].id
    })

    // 3. Lock in sorted order
    for _, acc := range toLock {
        acc.mu.Lock()
    }

    return toLock  // Return for caller to unlock
}

func unlockAccounts(locked []*Account) {
    // Unlock in reverse order (conventional; not required
    // for correctness)
    for i := len(locked) - 1; i >= 0; i-- {
        locked[i].mu.Unlock()
    }
}

// Usage
func complexTransfer(accounts []*Account, amounts []int64) {
    locked := lockAccounts(accounts)
    defer unlockAccounts(locked)

    // Perform operations on accounts
    // ...
}

Lock Ordering Strategies

Strategy
Natural ID ordering
Memory address ordering
Hierarchical ordering
Alphabetical

What each one looks like in code:

mu_103.go
// Illustrative snippet — not a complete program
// Natural ID: order by a field the objects already have.
if a.id < b.id {
    first, second = a, b
} else {
    first, second = b, a
}

// Memory address: a last resort. See the caveat below.
if uintptr(unsafe.Pointer(a)) < uintptr(unsafe.Pointer(b)) {
    first, second = a, b
}

// Hierarchical: a rule written down once, applied everywhere.
//   database.mu -> account.mu -> auditLog.mu
db.mu.Lock()
acct.mu.Lock()

// Alphabetical: order by the lock's own name.
cacheMu.Lock()    // "cacheMu" sorts before "metricsMu"
metricsMu.Lock()
Memory Address Ordering Caveat

Using uintptr(unsafe.Pointer(x)) for ordering works in practice because Go’s current garbage collector doesn’t move objects. However, this isn’t guaranteed by the language specification:

  • Go’s GC may move objects (though sync.Mutex embedded in structs typically stays put)
  • Address comparison must be atomic—don’t store addresses separately
  • Prefer stable numeric IDs when possible

This technique works for embedded mutexes in heap-allocated structs, but it’s fragile. Use only when IDs aren’t available.

Document Your Lock Ordering Convention

Write the rule down where the next reader will actually meet it. The package comment is the right place, because it is what go doc prints.

snippet_103_3.go
// Package bank implements thread-safe banking operations.
//
// Lock Ordering Convention:
//   All Account mutexes must be acquired in ascending ID order.
//   Methods requiring multiple account locks must call
//   lockAccounts() to ensure consistent ordering.
package bank

Pattern 3: The Dining Philosophers Problem

The dining philosophers problem is a classic concurrency illustration formulated by Edsger Dijkstra. It demonstrates N-way circular wait with multiple participants competing for shared resources.

Why Study Dining Philosophers?

This isn’t just a theoretical puzzle—it models real systems:

Database transactions:

  • Philosophers = transactions
  • Forks = row locks
  • Deadlock = two transactions locking rows in opposite order

Resource pools:

  • Philosophers = worker threads
  • Forks = limited resources (connections, file handles)
  • Deadlock = circular resource dependencies

The lesson: When N entities compete for N resources in a circular arrangement, local greedy acquisition (grab left, then right) guarantees deadlock. Prevention requires global coordination (ordering, limits, or timeouts).

The Problem

Five philosophers sit at a round table. Between each pair is one fork (five forks total). To eat, a philosopher needs both adjacent forks simultaneously.

THE DINING PHILOSOPHERS TABLE

Five philosophers seated in a circle with one fork between each neighbouring pair. Each philosopher needs both adjacent forks to eat, so the five compete for five shared resources arranged in a ring.

Naive Implementation (Can Deadlock)

fork_103.go
// Illustrative snippet — not a complete program
type Fork struct {
    id int      // For identification in solutions
    mu sync.Mutex
}

func philosopher(id int, leftFork, rightFork *Fork) {
    leftFork.mu.Lock()    // Pick up left fork

    // Give every philosopher time to pick up a left fork before
    // anyone reaches for a right one. Without this the deadlock is
    // real but rare -- see the note below.
    time.Sleep(10 * time.Millisecond)

    rightFork.mu.Lock()   // Pick up right fork

    rightFork.mu.Unlock()
    leftFork.mu.Unlock()
}

Run it and the runtime reports the cycle immediately:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
main.main()
goroutine 18 [sync.Mutex.Lock]:
main.philosopher(...)
...four more goroutines in the same state
WHY THE SLEEP IS IN THE EXAMPLE

That time.Sleep between the two Lock calls is not padding, and it is not how you would write this in production. It is there to make the bug reproducible, and the reason is the most important thing this section has to teach.

The textbook version of this example puts a “think” sleep at the top of a loop and lets the philosophers run. That version is genuinely deadlock-prone —and it almost never deadlocks. Measured: 8 runs of 6 seconds each, 0 deadlocks and roughly 62,000 meals eaten. The think phase desynchronizes the philosophers, so whoever grabs a left fork first usually gets the right one before a neighbour is ready to compete for it.

That gap—between a bug that is certain in theory and one that fires once a month in production—is exactly why deadlocks survive code review and test suites. Widening the window with a sleep is a debugging technique worth keeping: if you suspect a lock-ordering bug, add a delay between the two acquisitions and see whether it becomes reliable.

Measured the textbook version (a “think” sleep at the top of the loop, no delay between the two acquisitions) ran 8 times for 6 seconds each: 0 deadlocks, roughly 62,000 meals eaten. The version printed above, with the delay between the two Lock calls, deadlocked on 10 runs out of 10.

The deadly scenario: All philosophers pick up their left fork simultaneously:

DINING PHILOSOPHERS DEADLOCK TIMELINE

An execution timeline across five philosophers. At T0 each of P0 through P4 successfully picks up its left fork. At T1 each reaches for its right fork, which its neighbour is already holding, and all five wait. Every philosopher holds one fork and waits for another, closing the cycle.

CIRCULAR DEADLOCK WITH 5 GOROUTINES

The dependency cycle drawn out: P0 holds fork 0 and wants fork 1, P1 holds fork 1 and wants fork 2, and so on around to P4, which holds fork 4 and wants fork 0. The arrows close into a ring.

All four Coffman conditions are present:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

Solution 1: Resource Hierarchy (Numbered Forks)

Always acquire lower-numbered fork first:

philosopher_103.go
// Illustrative snippet — not a complete program
// ✓ SOLUTION: Always lock lower ID first
func philosopher(id int, leftFork, rightFork *Fork) {
    for i := 0; i < 3; i++ {  // Eat 3 times
        time.Sleep(time.Millisecond)  // Think

        // Order by fork ID, not by left/right
        first, second := leftFork, rightFork
        if first.id > second.id {
            first, second = second, first
        }

        first.mu.Lock()
        second.mu.Lock()

        fmt.Printf("Philosopher %d is eating\n", id)
        time.Sleep(time.Millisecond)

        second.mu.Unlock()
        first.mu.Unlock()
    }
}

func main() {
    const numPhilosophers = 5

    // Create forks with IDs
    forks := make([]*Fork, numPhilosophers)
    for i := range forks {
        forks[i] = &Fork{id: i}
    }

    var wg sync.WaitGroup
    for i := 0; i < numPhilosophers; i++ {
        leftFork := forks[i]
        rightFork := forks[(i+1)%numPhilosophers]
        wg.Go(func() { philosopher(i, leftFork, rightFork) })
    }

    wg.Wait()
    fmt.Println("All philosophers finished dining")
}

Why this prevents deadlock:

Terminal
Fork assignment with ordering:
  P0: left=F0, right=F1 → Lock order: F0, F1
  P1: left=F1, right=F2 → Lock order: F1, F2
  P2: left=F2, right=F3 → Lock order: F2, F3
  P3: left=F3, right=F4 → Lock order: F3, F4
  P4: left=F4, right=F0 → Lock order: F0, F4 ← Breaks cycle!
P4 now acquires F0 before F4 (not F4 before F0).
No circular wait possible.
WHY CONSISTENT ORDERING PREVENTS DEADLOCK

A proof by contradiction. If every goroutine acquires locks in ascending order, a cycle would require some goroutine to hold a higher-numbered lock while waiting for a lower-numbered one, which contradicts the rule. No cycle can form, and one violator is enough to break the guarantee.

Eliminates Condition 4 (Circular Wait): Global lock ordering prevents circular wait.

Solution 2: Timeout and Retry

Use TryLock with backoff:

philosopher_103_2.go
// Illustrative snippet — not a complete program
func philosopher(id int, leftFork, rightFork *Fork) {
    for i := 0; i < 3; i++ {
        time.Sleep(time.Millisecond)  // Think

        attempt := 0
        for {
            if !leftFork.mu.TryLock() {
                // Randomized exponential backoff
                backoff := time.Millisecond *
                    time.Duration(1<<min(attempt, 4))
                jitter := time.Duration(rand.Intn(100)) *
                    time.Microsecond
                time.Sleep(backoff + jitter)
                attempt++
                continue
            }

            if !rightFork.mu.TryLock() {
                leftFork.mu.Unlock()  // Release left, try again
                backoff := time.Millisecond *
                    time.Duration(1<<min(attempt, 4))
                jitter := time.Duration(rand.Intn(100)) *
                    time.Microsecond
                time.Sleep(backoff + jitter)
                attempt++
                continue
            }

            break  // Got both forks
        }

        fmt.Printf("Philosopher %d is eating\n", id)
        time.Sleep(time.Millisecond)

        rightFork.mu.Unlock()
        leftFork.mu.Unlock()
    }
}

Breaks Condition 2 (Hold and Wait): Philosophers release held forks when they fail to acquire all needed resources, preventing indefinite hold-and-wait.

Critical Drawback: Livelock

The timeout solution has a serious livelock risk—all philosophers might:

  1. Grab left fork simultaneously
  2. Fail to get right fork simultaneously
  3. Release and retry simultaneously
  4. Repeat forever (high CPU usage, no progress)

Mitigation: Use randomized exponential backoff (shown above):

backoff_103.go
// Illustrative snippet — not a complete program
// Exponential backoff with jitter
backoff := time.Millisecond * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(100)) * time.Microsecond
time.Sleep(backoff + jitter)

Additional problems at scale:

Use timeouts only for:

For internal locks: Resource hierarchy (Solution 1) is almost always better—deterministic, fair, and efficient.

Alternative Solutions

Other solutions exist for the dining philosophers problem:

These alternative approaches are explored in the exercises at the end of this chapter.

Comparing Solutions

Solution
Numbered forks
Waiter
Timeout
Production Recommendation

Numbered forks (resource hierarchy) is usually best: deterministic, fair, efficient, and generalizes to arbitrary resources.


Pattern 4: Blocking Channel Operations Under Mutex

Real systems often combine mutexes and channels. Deadlocks can span both primitives when blocking channel operations occur inside critical sections.

service_103.go
// Illustrative snippet — not a complete program
type Service struct {
    mu      sync.Mutex
    data    int
    results chan int
}

func (s *Service) Process(val int) {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.data = val * 2
    s.results <- s.data  // Blocks if no receiver! (unbuffered channel)
}

func (s *Service) Collect() int {
    s.mu.Lock()
    defer s.mu.Unlock()

    return <-s.results  // Blocks waiting for sender
}

Deadlock scenarios:

BLOCKING UNDER A MUTEX: BOTH ORDERINGS

Two scenarios for the same bug. In scenario 1 the producer takes the lock and then blocks sending on a channel with no receiver, while the consumer blocks trying to take the lock the producer holds. In scenario 2 the consumer takes the lock first and blocks receiving with no sender. Either interleaving deadlocks; the bug is doing a blocking channel operation while holding a lock.

MIXED MUTEX-CHANNEL DEADLOCK

A cycle formed across two different primitives: one goroutine holds a mutex and waits on a channel, the other holds the channel’s other end and waits on the mutex. Mixing primitives does not remove the cycle, it just makes it harder to see.

Coffman conditions analysis:

Condition
Mutual exclusion
Hold and wait
No preemption
Circular wait

The fix: Don’t perform blocking channel operations under mutex:

service_103_2.go
// Illustrative snippet — not a complete program
func (s *Service) Process(val int) {
    s.mu.Lock()
    result := val * 2
    s.mu.Unlock()

    s.results <- result  // Send without holding lock
}
Critical Principle

Never perform blocking operations while holding a mutex:

  • Channel sends (if channel might be full or unbuffered)
  • Channel receives (if channel might be empty or unbuffered)
  • Network I/O
  • File I/O
  • Calls to unknown/external code

Any of these can block indefinitely. If another goroutine needs the mutex to unblock the operation, you have a deadlock.

Production Considerations

The simplified fix above prevents the immediate deadlock but doesn’t handle complete lifecycle:

  • If Collect() exits, Process() may block forever on send (goroutine leak)
  • Production code needs buffered channels or context-based cancellation

Complete pattern:

service_103_3.go
// Illustrative snippet — not a complete program
func (s *Service) Process(ctx context.Context, val int) error {
s.mu.Lock()
result := val * 2
s.mu.Unlock()

select {
case s.results <- result:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

See Chapter 13 for complete patterns with context-based cleanup.


Prevention Checklist: Mutex Deadlocks

MUTEX DEADLOCK PREVENTION

The prevention rules for mutex deadlocks: use Locked-suffixed helpers for internal calls, order locks consistently by id or hierarchy, never call unknown code while holding a lock, and never perform a blocking operation inside a critical section.


Common Mistakes: Mutex Deadlocks

Mistake
Public method calls another public method
Callbacks under lock
Inconsistent lock order
Forgot self-transfer check
Channel send under mutex
Using mutable data for ordering

Summary: Mutex Deadlocks

Pattern
Self-deadlock
Lock ordering violation
Dining philosophers
Blocking channel ops under mutex

Key Takeaways

  1. Self-deadlock is a cycle of length 1—goroutine waits for itself
  2. Go mutexes are non-reentrant by design—second Lock() on held mutex blocks indefinitely
  3. *Locked suffix prevents self-deadlock—helpers assume lock is held
  4. Release before callbacks—external code may re-enter your type
  5. Lock ordering prevents cross-goroutine cycles—all code must follow same order
  6. Global ordering must be total and consistent—numeric ID or memory address
  7. Handle self-transfer and nil cases—ordering code must validate inputs
  8. Dining philosophers demonstrates all four conditions—classic deadlock example
  9. Resource hierarchy is the best general solution—deterministic, fair, efficient
  10. Never perform blocking operations under mutex—channel ops, I/O, external calls
Connection to Chapter 9

Chapter 9's design patterns directly prevent deadlocks:

  • Monitor pattern (9.4): Encapsulation limits lock visibility
  • *Locked helpers (9.4): Prevents self-deadlock
  • Release before callbacks (9.4): Prevents re-entry deadlock
  • Keep critical sections short (9.2): Reduces hold time, lowering deadlock probability
Chapter 2 Connection

This violates Question 1 - “How does this goroutine exit?” - Goroutines in deadlock never exit.


Self-Check Questions: Mutex Deadlocks

Test your understanding of mutex deadlocks.

1. Why does this code deadlock?

cache_103_5.go
// Illustrative snippet — not a complete program
func (c *Cache) ProcessAll() {
    c.mu.Lock()
    defer c.mu.Unlock()

    for key := range c.data {
        c.Process(key)  // Process also locks c.mu
    }
}
Show answer

Self-deadlock. ProcessAll holds c.mu via Lock(), then calls c.Process(key). If Process also calls Lock(), it tries to lock the mutex the goroutine already holds—instant deadlock. Fix: Create processLocked(key) helper that assumes lock is held.

2. Two accounts, A (ID=100) and B (ID=200). Goroutine 1 calls transfer(A, B, 50) and Goroutine 2 calls transfer(B, A, 30). With ID-based lock ordering, what order does each goroutine acquire locks?

Both goroutines lock in the same order: A (ID=100) first, then B (ID=200).

  • Goroutine 1: transfer(A, B, 50)from.id=100, to.id=200100 < 200 → no swap needed → locks A, then B
  • Goroutine 2: transfer(B, A, 30)from.id=200, to.id=100200 > 100 → swap: first=A, second=B → locks A, then B

Both follow A→B order. No circular wait possible.

3. In the dining philosophers problem, why must the waiter solution limit to 4 philosophers (not 5)?

With 5 forks and 5 philosophers, if all 5 try to eat simultaneously, each could grab their left fork—deadlock (everyone holds one fork, waits for another). With maximum 4 diners, at least one fork pair is always available for someone to get both forks and make progress (pigeonhole principle).


Next: §10.4 examines deadlock detection techniques—runtime detection limitations, stack trace analysis, pprof goroutine profiling, and third-party tools like goleak.

10.4 Deadlock Detection

§§10.2 and 10.3 covered how deadlocks happen. This section covers how to find them—both when the runtime helps and when it doesn’t.

Go’s runtime detects deadlock only when all goroutines are blocked. In production, background goroutines (HTTP servers, metrics collectors, health checks) keep running while business logic deadlocks silently. You need additional techniques to find these partial deadlocks.

DEADLOCK DETECTION LANDSCAPE

What each tool can and cannot see: the runtime detector needs every goroutine blocked, stack traces show current state, goroutine counts reveal partial deadlocks, goleak catches leaks in tests, and block profiles show where time is spent waiting.


Runtime Deadlock Detection

Go’s runtime automatically detects deadlock when every goroutine is blocked waiting for synchronization that requires another goroutine to provide.

What the Runtime Detects

main_104.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)
    <-ch  // Only goroutine, blocked forever
}

Output:

Terminal
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
    /tmp/main.go:4 +0x28
exit status 2

The runtime checks whether each goroutine can make progress:

Goroutine State
Channel send/receive
sync.Mutex.Lock
sync.WaitGroup.Wait
select (no ready cases)
select {} (empty select)
time.Sleep
<-time.After()
Network/file I/O
WHICH COMMAND EXITS WITH WHAT

A deadlocking program exits with code 2. But you will usually run it through go run or go test, and those are wrappers: they report their child’s death with their own status, not the child’s. Measured:

Terminal
$ go run main.go ; echo $? # 1 ← the wrapper's status
$ go build -o prog . && ./prog ; echo $? # 2 ← the program's status

So a CI gate written as go run main.go; [ $? -eq 2 ] never fires—and worse, exit 1 is the code that normally means “ordinary program error” (os.Exit(1), log.Fatal), so a deadlock gets filed as a routine failure. Build the binary first if you want to branch on the exit code. Exit code 2 specifically indicates a runtime panic, which includes deadlock.

This is the same wrapper effect as the race detector’s exit codes in §8.3: a -race binary exits 66, but go test -race exits 1.


Critical Limitation: Partial Deadlocks

THE HIDDEN DEADLOCK PROBLEM

A warning panel: production systems almost never trigger runtime detection, because background goroutines keep running. An HTTP server goroutine, a metrics collector, a health check, or anything sleeping or waiting on I/O is enough to keep the runtime from ever declaring that all goroutines are asleep.

main_104_2.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)

    go func() {
        ch <- 1  // Blocked forever—no receiver (partial deadlock)
    }()

    // But this keeps running...
    // Runtime sees "progress possible"
    http.ListenAndServe(":8080", nil)
}

The sender goroutine is permanently blocked, but the runtime sees ListenAndServe running and assumes progress is possible. This partial deadlock goes undetected.

FULL VS PARTIAL DEADLOCK

A full deadlock blocks every goroutine and the runtime reports it. A partial deadlock blocks only some, while others keep running, so the runtime stays silent and the symptom is a goroutine leak with requests timing out.

Partial Deadlock = Goroutine Leak = Memory Leak

When some goroutines are permanently blocked but others continue:

  • From blocked goroutines' perspective: deadlock
  • From runtime’s perspective: normal operation
  • From your perspective: goroutine leak (Chapter 2)

Each goroutine has a stack (typically 2KB minimum, can grow to MBs). When goroutines leak, memory usage grows, GC pressure increases, and OOM becomes likely in long-running services. Monitor both goroutine count AND memory usage—a correlation suggests goroutine leak.

Detection requires active monitoring, not passive runtime detection.


Reading Stack Traces

When the runtime detects deadlock or when you capture a dump manually, stack traces are your primary diagnostic tool.

Obtaining Stack Traces

Method 1: Runtime Detection Output

When the runtime detects deadlock, it prints stack traces automatically.

Method 2: SIGQUIT (Unix/Linux/macOS)

SIGQUIT dumps every goroutine’s stack — and then kills the process:

Terminal
# In terminal running the program
$ Ctrl+\
# Or send signal to process (Unix/Linux/macOS)
$ kill -SIGQUIT $(pgrep myserver)
# On Windows: Use Ctrl+Break or the pprof HTTP endpoint instead
SIGQUIT TERMINATES THE PROCESS

This is the most commonly misunderstood tool in this chapter. Go’s default disposition for SIGQUIT is dump all stacks, then crash — the process exits with status 2 and does not come back. Measured: a service sent SIGQUIT mid-request printed its dump and died; it never reached the next line of main.

That is fine for a process you have already written off, and it is the right tool for a hung batch job or a container you are about to restart anyway. It is the wrong tool for a production service that is only partially stuck, because you will convert a degraded service into a down one while you are trying to diagnose it.

For a dump that leaves the process running, use Method 3 (the pprof endpoint), or install your own handler on a signal the runtime does not claim:

sigs_104.go
// Illustrative snippet — not a complete program
// SIGUSR1 dumps stacks and keeps serving.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR1)
go func() {
for range sigs {
pprof.Lookup("goroutine").WriteTo(os.Stderr, 2)
}
}()
SIGQUIT Output Details
  • Output is written to stderr (not stdout)
  • Can be large (hundreds of KB for thousands of goroutines)
  • Includes all goroutines, even runtime system goroutines
  • The process exits with status 2 immediately after the dump
  • Consider log rotation for long-running services

Use GOTRACEBACK=all to include runtime goroutines in dumps, or GOTRACEBACK=crash to also generate a core dump.

Method 3: pprof HTTP Endpoint

main_104_3.go
// Illustrative snippet — not a complete program
import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // ... your application ...
}
SECURITY: Protect Debug Endpoints

The pprof HTTP endpoint exposes sensitive runtime information including:

  • Full source code paths
  • Memory contents and heap dumps
  • Goroutine stacks (may contain sensitive data in local variables)

Production deployment:

  • Bind to localhost only (as shown above)
  • Or add authentication middleware
  • Never expose on 0.0.0.0 or public interfaces
  • Consider environment-based enabling (dev/staging only)
Terminal
# Full dump (debug=2 = human-readable, with full stacks)
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=2'
# Summary with goroutine counts (debug=1)
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -1
# goroutine profile: total 847
PPROF DEBUG LEVELS

The pprof goroutine endpoint’s debug parameter: debug=1 gives a summary with goroutine counts, debug=2 gives the full human-readable dump with complete stacks.

Method 4: Programmatic Dump

dump_goroutines_104.go
// Illustrative snippet — not a complete program
import "runtime/pprof"

func dumpGoroutines(w io.Writer) {
    pprof.Lookup("goroutine").WriteTo(w, 2)  // debug=2 for full stacks
}
For Comprehensive Profiling

This section covers pprof usage specific to deadlock detection. For general profiling techniques including CPU profiling, memory profiling, flame graphs, and advanced pprof analysis, see Chapter 19.


Anatomy of a Stack Trace

Terminal
goroutine 18 [chan send, 5 minutes]:
main.(*Service).Process(0xc0000b4000, 0x2a)
    /app/service.go:42 +0x85
main.handleRequest(0xc0000b6000)
    /app/handler.go:28 +0x123
created by main.startWorker
    /app/main.go:67 +0x89
STACK TRACE ANATOMY

The parts of a goroutine dump entry: the goroutine number, the wait state in brackets, how long it has been in that state, then the stack frames from the blocking runtime call down through your own code. Runtime and sync internals sit at the top and are skipped; your package path is what you read.

Reading Noisy Stack Traces

Real stack traces include runtime internals. Focus on your code:

Terminal
goroutine 14 [sync.Mutex.Lock, 5 minutes]:
internal/sync.runtime_SemacquireMutex(0xc00012e084, 0x0, 0x1)
.../runtime/sema.go:71 +0x47 ← Skip runtime
internal/sync.(*Mutex).lockSlow(0xc00012e080)
.../internal/sync/mutex.go:138 +0x105 ← Skip sync internals
internal/sync.(*Mutex).Lock(...)
.../internal/sync/mutex.go:81
main.(*Service).UpdateData(0xc00012e080, 0xc000102000)
/app/service.go:45 +0x39 ← Your code!
main.processRequest(0xc000102000)
/app/handler.go:23 +0x56 ← Your code!

When analyzing, skip lines with /usr/local/go/src/runtime or /usr/local/go/src/sync—look for your package path (/app, main, etc.).

Common Blocking States

State
[running]
[runnable]
[sync.Mutex.Lock]
[sync.RWMutex.RLock]
[sync.RWMutex.Lock]
[chan send]
[chan receive]
[chan receive (nil chan)]
[select]
[select (no cases)]
[sync.Cond.Wait]
[sync.WaitGroup.Wait]
[IO wait]
The Duration Field

The duration (e.g., 5 minutes) shows how long the goroutine has been in this state. Note: Not all states show duration—it appears for blocking states like chan send and sync.Mutex.Lock, but not for running or runnable. Long durations on blocking states are red flags for deadlock.


Identifying Deadlock Patterns in Stack Traces

Pattern 1: Self-Deadlock

Look for the same type appearing twice in one goroutine’s stack:

Terminal
goroutine 1 [sync.Mutex.Lock]:
    ...
    main.(*Cache).Get(...) ← Inner call trying to lock
        /app/cache.go:15
    main.(*Cache).GetWithFallback(...) ← Outer call already holds lock
        /app/cache.go:28

Same type appears twice → method calling method on same receiver → self-deadlock.

Pattern 2: Lock Ordering Violation

Look for two goroutines with swapped arguments:

Terminal
goroutine 5 [sync.Mutex.Lock, 3 minutes]:
    ...
    main.(*Account).Transfer(0xc000010000, 0xc000010020, ...)
        /tmp/main.go:25 ← G5: from=0x...00, to=0x...20
goroutine 6 [sync.Mutex.Lock, 3 minutes]:
    ...
    main.(*Account).Transfer(0xc000010020, 0xc000010000, ...)
        /tmp/main.go:25 ← G6: from=0x...20, to=0x...00

Two goroutines in same function with swapped pointer arguments → opposite lock order.

Pattern 3: Channel Circular Dependency

Terminal
goroutine 5 [chan send]:
    main.worker1()
        /app/workers.go:12 ← Sending on ch1
goroutine 6 [chan send]:
    main.worker2()
        /app/workers.go:18 ← Sending on ch2

Multiple goroutines blocked on sends → check if they’re waiting for each other to receive.

Pointer Values Identify Objects

Stack traces include pointer values like 0xc0000a0000. Same pointer = same object. Different pointers in same function = different instances. This helps distinguish “same mutex locked twice” (self-deadlock) from “two different mutexes” (ordering violation).


Manual Deadlock Analysis

For complex deadlocks, extract the dependency graph from stack traces:

MANUAL DEADLOCK ANALYSIS

The steps for reading a dump by hand: find the long-blocked goroutines, identify the primitive each is waiting on, locate your own frames, then look for the same type twice in one stack (self-deadlock) or swapped arguments across two stacks (ordering violation).


Using pprof for Blocking Profiles

Stack traces show current state. Blocking profiles show where goroutines spend time waiting—accumulated over the program’s lifetime.

Enabling Block Profiling

main_104_4.go
// Illustrative snippet — not a complete program
import "runtime"

func main() {
    // Enable block profiling
    // Rate is in nanoseconds—events blocking ≥ rate are sampled
    runtime.SetBlockProfileRate(1)

    // ... your application ...
}
BLOCK PROFILE RATE VALUES

A two-column table of SetBlockProfileRate values. Rate 0 disables the profile, the default. Rate 1 captures every blocking event of at least one nanosecond. Rate N greater than 1 samples events that block for at least N nanoseconds.

Performance Impact

SetBlockProfileRate(1) captures every blocking event—significant overhead in production. Use SetBlockProfileRate(1000000) (events blocking ≥1ms) for lower overhead, or enable only during investigation.

BLOCK PROFILE VS MUTEX PROFILE

The block profile records time spent blocked on channels, selects and mutexes; the mutex profile records lock contention specifically. They answer different questions and have different overheads.

Analyzing Block Profiles

Terminal
# Interactive analysis
$ go tool pprof http://localhost:6060/debug/pprof/block
# Common commands:
$ (pprof) top10 # Top 10 blocking locations
$ (pprof) list FuncName # Source code with blocking time
$ (pprof) web # Visualization in browser (requires graphviz)

High sync.(*Mutex).Lock time indicates lock contention or potential deadlock. High runtime.chanrecv1 time indicates slow producers or missing senders.

Identifying Deadlock vs Contention

Blocking profiles don’t prove deadlock—they show where goroutines block. Distinguish:

Symptom
Blocking time grows linearly
Goroutines blocked indefinitely
(no progress over time)
One function dominates
Even distribution

Detecting Goroutine Leaks

Partial deadlocks manifest as goroutine leaks—goroutine count grows over time as blocked goroutines accumulate.

Monitoring Goroutine Count

monitor_goroutines_104.go
// Illustrative snippet — not a complete program
// Requires: import "runtime/pprof" and "os"
func monitorGoroutines(ctx context.Context) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    var previous int

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            count := runtime.NumGoroutine()

            // Alert on sustained growth (simple heuristic)
            if previous > 0 && count > previous+100 {
                log.Printf("WARNING: goroutines %d -> %d",
                    previous, count)
                pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
            }

            previous = count
        }
    }
}
GOROUTINE COUNT PATTERNS

Three shapes a goroutine count takes over time: flat and healthy, a sawtooth under normal load, and unbounded growth, which is the signature of a leak or a partial deadlock.

Using goleak in Tests

The go.uber.org/goleak package detects goroutine leaks in tests. See Chapter 16 for comprehensive testing strategies.

main_test_104_5.go
// Illustrative snippet — not a complete program
import (
    "testing"
    "go.uber.org/goleak"
)

func TestMain(m *testing.M) {
    // Calls os.Exit() internally; never returns
    goleak.VerifyTestMain(m)
}

// Or per-test (use this if you need cleanup after tests):
func TestNoLeak(t *testing.T) {
    defer goleak.VerifyNone(t)

    // Test code that shouldn't leak goroutines
}

Output when leak detected:

Terminal
found unexpected goroutines:
[Goroutine 19 in state chan send, with
main.leakyFunc.func1 on top of the stack:
goroutine 19 [chan send]:
main.leakyFunc.func1()
    /tmp/main_test.go:15 +0x45
]
goleak in CI

Add goleak.VerifyTestMain to catch goroutine leaks before they reach production. Use goleak.IgnoreTopFunction() to allow known-safe background goroutines.

Production Metrics

Export goroutine count for alerting:

init_104.go
// Illustrative snippet — not a complete program
// Prometheus example
var goroutineGauge = prometheus.NewGaugeFunc(
    prometheus.GaugeOpts{
        Name: "app_goroutines_total",
        Help: "Current number of goroutines",
    },
    func() float64 { return float64(runtime.NumGoroutine()) },
)

func init() {
    prometheus.MustRegister(goroutineGauge)
}
prometheus-rules.yaml
# Prometheus alert rule. The 1000 is a placeholder -- set it from your
# own baseline, not from this book. A service that fans out per request
# will breach it under normal load; a small worker pool may never reach
# it even while leaking steadily. What is portable is the *shape*:
# alert on sustained growth, not on an absolute count.
- alert: GoroutineLeak
  expr: increase(app_goroutines_total[1h]) > 1000
  for: 5m
  annotations:
    summary: 'Possible goroutine leak detected'

Third-Party Tools

go-deadlock: Drop-in Mutex Replacement

The github.com/sasha-s/go-deadlock package replaces sync.Mutex with a version that detects potential deadlocks:

cache_104.go
// Illustrative snippet — not a complete program
import (
    deadlock "github.com/sasha-s/go-deadlock"
)

type Cache struct {
    mu   deadlock.Mutex  // Drop-in replacement for sync.Mutex
    data map[string][]byte
}

Features:

GO-DEADLOCK: BENEFIT VS COST

A two-column table weighing the go-deadlock library. Benefits: catches ordering issues early, gives clear error messages, and needs no code changes beyond the import. Costs: significant runtime overhead, false positives are possible, and it is not suitable for production.

Use case: Development and testing only.


Debugging Workflow

When you suspect a deadlock in a running system:

DEADLOCK DEBUGGING WORKFLOW

The end-to-end debugging sequence: observe the symptom, capture a goroutine dump, filter to long-blocked goroutines, identify the blocking primitive, classify the pattern, then apply the matching prevention strategy.

Example Investigation

Symptom: HTTP requests to /api/transfer hang after 30 seconds (timeout).

Step 1: Check goroutine count

Terminal
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -1
# goroutine profile: total 847

847 goroutines—suspicious for a service that should have ~50.

Step 2: Get goroutine dump

Terminal
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=2' \
  > goroutines.txt

Step 3: Find blocked goroutines

Terminal
$ grep -nE '^goroutine [0-9]+ \[(sync\.|chan |select)' \
  goroutines.txt | head -30
Terminal
goroutine 234 [sync.Mutex.Lock, 2 minutes]:
    ...
    main.transfer(0xc0001a4000, 0xc0001a4080, 0x64)
        /app/bank.go:45
goroutine 235 [sync.Mutex.Lock, 2 minutes]:
    ...
    main.transfer(0xc0001a4080, 0xc0001a4000, 0x32)
        /app/bank.go:45

Step 4: Identify pattern

Both blocked in sync.Mutex.Lock. Swapped arguments → lock ordering violation.

Step 5: Fix

Apply ID-based lock ordering (§10.5).


Prevention Checklist: Detection

DEADLOCK DETECTION SETUP

A setup checklist split by environment. In development: import net/http/pprof for debug endpoints, enable block profiling in dev and staging, use goleak in the test suite, and consider go-deadlock for mutex-heavy code. In production: export the goroutine count to metrics and alert on sustained growth.


Common Mistakes: Detection

Mistake
Relying only on runtime detector
No debug endpoints in production
Block profiling always on
Ignoring gradual goroutine growth
Not testing with goleak
Dismissing “just a test flake”

Summary: Detection

Detection Method
Runtime detector
Stack traces
Block profiles
Goroutine monitoring
go-deadlock
goleak

Key Takeaways

  1. Runtime detection requires ALL goroutines blocked—background services hide deadlocks
  2. Partial deadlock = goroutine leak = memory leak—same bug, multiple perspectives
  3. Stack traces name the primitive—[sync.Mutex.Lock], [chan send], etc.
  4. Duration in stack trace is critical—5 minutes blocked = almost certainly deadlock
  5. Pointer values identify objects—same pointer = same mutex
  6. SIGQUIT dumps stacks and then kills the process (exit 2)—use the pprof endpoint or a SIGUSR1 handler to dump a service you need to keep alive
  7. pprof endpoints enable remote diagnosis—expose in dev, protect in prod
  8. Block profiles show contention patterns—find hotspots before they deadlock
  9. goleak catches leaks in tests—add to TestMain for comprehensive coverage
  10. Monitor goroutine count in production—alert on sustained growth
The Best Detection Is Prevention

Detection tools help diagnose deadlocks after they occur. But the techniques from §§10.1–10.3 (lock ordering, *Locked helpers, avoiding blocking under lock) prevent deadlocks entirely. Use detection as a safety net, not a primary strategy.


Self-Check Questions: Detection

Test your understanding of deadlock detection.

1. Your server’s health endpoint responds, but request handlers are hanging. Will the runtime deadlock detector report this? Why or why not?

No, the runtime won’t report it. The health endpoint goroutine is running (handling HTTP requests), so not all goroutines are blocked. The runtime only detects deadlock when ALL goroutines are blocked on synchronization. Partial deadlocks appear as hanging requests, not fatal errors. Use pprof or goroutine count monitoring to find them.

2. A stack trace shows goroutine 42 [chan receive (nil chan), 10 minutes]. What does this indicate, and what’s the likely bug?

The goroutine is receiving from a nil channel, which blocks forever. The bug is an uninitialized channel variable—var ch chan T without ch = make(chan T). The stack trace explicitly says nil chan, making this easy to identify. The 10-minute duration confirms it’s not a transient wait.

3. You see 500 goroutines in state [sync.Mutex.Lock] all blocked in (*Cache).Get. Is this definitely a deadlock? What else could it be?

Not necessarily a deadlock—could be severe contention. If one goroutine holds the lock doing slow work (I/O, computation), others queue up waiting. Check if any goroutine is in [running] or [IO wait] state while holding the lock. True deadlock means holders are also blocked; contention means the holder is working but slow.


Next: §10.5 consolidates prevention strategies—lock ordering conventions, timeout patterns, and design principles for deadlock-free systems.

10.5 Prevention Strategies

§§10.1–10.4 taught you to understand, recognize, and detect deadlocks. This section teaches you to prevent them—eliminating the possibility of deadlock through careful design, not just catching it after it happens.

Prevention is always better than detection—a prevented deadlock never pages you at 3 AM.

Recall the four Coffman conditions from §10.1. Deadlock requires all four simultaneously. Remove any one, and deadlock becomes impossible:

DEADLOCK PREVENTION HIERARCHY

Prevention strategies ordered from most to least effective: eliminate shared state through confinement or immutability; reduce lock scope with a single lock or atomics; enforce lock ordering by id, address or hierarchy; avoid blocking under a lock using copy-release-call; and only then use timeouts, which are a last resort because they mask the bug.

Prevention vs Detection

Prevention eliminates deadlocks structurally—correct code cannot deadlock regardless of timing or load. Detection finds deadlocks after they occur, requiring diagnosis and fixes under pressure.

Always prefer prevention. Use detection as a safety net, not a primary strategy.

This section covers seven complementary prevention strategies:

  1. Eliminate shared state (10.5.1)—confinement and immutability
  2. Reduce lock scope (10.5.2)—single locks and lock-free alternatives
  3. Lock ordering (10.5.3)—the primary strategy for unavoidable multiple locks
  4. Avoid blocking under locks (10.5.4)—prevent mixed mutex-channel deadlocks
  5. Timeouts and non-blocking acquisition (10.5.5)—last resort escape hatch
  6. The *Locked helper pattern (10.5.6)—prevent self-deadlock through internal calls
  7. Real-world example (10.5.7)—applying prevention to a production deadlock

10.5.1 Eliminate Shared State

The most effective prevention: if goroutines don’t share state, they can’t deadlock competing for it.

Confinement

Confine mutable state to a single goroutine. Other goroutines communicate via channels. See §8.5.1 for confinement patterns in depth.

cache_105_x_1.go
// Illustrative snippet — not a complete program
// ✗ SHARED STATE: Multiple goroutines access cache directly
type Cache struct {
    mu   sync.Mutex
    data map[string][]byte
}

func (c *Cache) Get(key string) []byte {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.data[key]
}

// ✓ CONFINED STATE: Single goroutine owns the cache
type Cache struct {
    requests chan cacheRequest
}

type cacheRequest struct {
    op     string  // "get" or "set"
    key    string
    value  []byte
    result chan []byte
}

func NewCache() *Cache {
    c := &Cache{requests: make(chan cacheRequest)}
    go c.run()  // Single owner goroutine
    return c
}

func (c *Cache) run() {
    data := make(map[string][]byte)  // Confined to this goroutine
    for req := range c.requests {
        switch req.op {
        case "get":
            req.result <- data[req.key]
        case "set":
            data[req.key] = req.value
            req.result <- nil
        }
    }
}

// Get retrieves a value from the cache.
// Blocks until the cache manager responds.
func (c *Cache) Get(key string) []byte {
    result := make(chan []byte, 1)
    c.requests <- cacheRequest{op: "get", key: key, result: result}
    return <-result
}

Why it prevents deadlock: No mutex exists. The data map is confined to a single goroutine. Channel operations may block, but there’s no hold-and-wait—goroutines don’t hold resources while waiting.

CONFINEMENT VS SHARED STATE

Shared state has several goroutines reaching into the same structure behind a lock. Confinement gives the state to one goroutine and has the others communicate with it by channel, so there is no lock to order and no deadlock to prevent.

When to Use Confinement
  • State has clear ownership semantics
  • Operations are naturally serializable
  • Deadlock risk outweighs performance cost
  • You’re building actor-like systems

When NOT to use:

  • High-frequency read-heavy workloads (use RWMutex)
  • Simple counters (use atomics)
  • Performance-critical paths

Immutability

Immutable data requires no synchronization—any goroutine can read safely. See §8.5.3 for immutability patterns.

config_105_x_1.go
// Illustrative snippet — not a complete program
// ✗ MUTABLE: Requires locking
type Config struct {
    mu       sync.RWMutex
    settings map[string]string
}

func (c *Config) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.settings[key]
}

// ✓ IMMUTABLE: No locking needed
type Config struct {
    settings atomic.Pointer[map[string]string]
}

func NewConfig() *Config {
    c := &Config{}
    initial := make(map[string]string)
    c.settings.Store(&initial)
    return c
}

// Get returns the value for key, or empty string if key doesn't exist.
func (c *Config) Get(key string) string {
    m := c.settings.Load()
    if m == nil {
        return ""
    }
    return (*m)[key]
}

func (c *Config) Set(key, value string) {
    for {
        old := c.settings.Load()

        var newMap map[string]string
        if old == nil {
            newMap = map[string]string{key: value}
        } else {
            newMap = make(map[string]string, len(*old)+1)
            for k, v := range *old {
                newMap[k] = v
            }
            newMap[key] = value
        }

        if c.settings.CompareAndSwap(old, &newMap) {
            return
        }
        // CAS failed—another goroutine updated; retry
    }
}

Why it prevents deadlock: Readers never block—they get a consistent snapshot. Writers create new versions atomically. No mutex means no deadlock.

Aspect
Read performance
Write performance
Memory
Deadlock risk

10.5.2 Reduce Lock Scope

Fewer locks means fewer ordering constraints.

Single Lock (Coarse-Grained)

The simplest approach: one lock protects all shared state.

service_105.go
// Illustrative snippet — not a complete program
// Multiple related fields protected by single lock
type Service struct {
    mu       sync.Mutex
    cache    map[string][]byte
    metrics  Metrics
    config   Config
}

func (s *Service) HandleRequest(key string) []byte {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.metrics.RequestCount++
    if val, ok := s.cache[key]; ok {
        s.metrics.CacheHits++
        return val
    }
    // ... fetch and cache ...
}

Why it prevents deadlock: With only one lock, there’s no ordering to violate. Self-deadlock is still possible (§10.3), but cross-goroutine circular wait is eliminated.

Aspect
Simplicity
Contention
Deadlock risk
Scalability
Start Coarse, Refine If Needed

Begin with a single lock. Split into multiple locks only when profiling proves contention is a bottleneck. Premature fine-grained locking adds complexity and deadlock risk without measurable benefit.

Lock-Free Data Structures

For specific patterns, avoid locks entirely using atomic operations.

counter_105.go
// Illustrative snippet — not a complete program
// Lock-free counter
type Counter struct {
    value atomic.Int64
}

func (c *Counter) Increment() int64 {
    return c.value.Add(1)
}

func (c *Counter) Get() int64 {
    return c.value.Load()
}

// Lock-free presence set (add-only)
type PresenceSet struct {
    members sync.Map  // Lock-free for concurrent access
}

func (p *PresenceSet) Add(id string) {
    p.members.Store(id, struct{}{})
}

func (p *PresenceSet) Contains(id string) bool {
    _, ok := p.members.Load(id)
    return ok
}
Structure
Counter
Flag
Pointer swap
Key-value

\* sync.Map is optimized for: (1) write-once, read-many keys, or (2) disjoint key sets per goroutine. For general-purpose maps with mixed read/write, RWMutex + map often performs better.


10.5.3 Lock Ordering: Breaking Circular Wait

When multiple locks are unavoidable, consistent ordering prevents circular wait. This is the primary strategy for mutex deadlocks.

Why It Works

If every goroutine acquires locks in the same order, no cycle can form:

WHY LOCK ORDERING WORKS

With a global order, every goroutine climbs the same ladder of locks. A cycle would need one goroutine to be climbing down while another climbs up, which the rule forbids, so the wait graph stays acyclic.

Ordering Strategies

Choose an ordering mechanism appropriate to your data structures:

Strategy
Numeric ID
Memory address
Type hierarchy
Single-lock design

Implementation: Numeric ID Ordering

account_105_x_1.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    id      int64
    balance int64
}

// ✓ DEADLOCK-FREE: Always locks lower ID first
func Transfer(from, to *Account, amount int64) error {
    // 1. Prevent self-transfer (causes self-deadlock)
    if from == to {
        return errors.New("cannot transfer to self")
    }

    // 2. Establish consistent ordering
    first, second := from, to
    if from.id > to.id {
        first, second = to, from
    }

    // 3. Acquire locks in order
    first.mu.Lock()
    defer first.mu.Unlock()

    second.mu.Lock()
    defer second.mu.Unlock()

    // 4. Perform operation using original from/to (not first/second)
    if from.balance < amount {
        return errors.New("insufficient funds")
    }

    from.balance -= amount
    to.balance += amount

    return nil
}
Critical Distinction: Lock Order ≠ Business Logic Order

first/second determine lock acquisition order to prevent deadlock. from/to determine business logic direction for the transfer.

The transfer still goes from→to even when lock order is to→from.

Why Self-Transfer Check Is Essential

Without if from == to, calling Transfer(account, account, 100) results in:

first_105_x_1.go
// Illustrative snippet — not a complete program
first = account
second = account  // Same mutex!

first.mu.Lock()   // Acquires account.mu
second.mu.Lock()  // Tries to lock account.mu again → DEADLOCK

The pointer comparison catches this before locking begins.

Hierarchical Lock Ordering

For systems with natural layers, lock from top to bottom:

snippet_105.go
// Package bank provides thread-safe banking operations.
//
// LOCK ORDERING CONVENTION:
//
//   Level 1: Database.mu (database operations)
//   Level 2: Account.mu (by ascending Account.id)
//   Level 3: AuditLog.mu (audit logging)
//
// Global order: Database → Account (by ID) → AuditLog
//
// INVARIANT: Never acquire a lock at level N while holding
// a lock at level N+1 or higher.
//
// Any code path acquiring multiple locks MUST respect this order.
// Violations will cause deadlock.
package bank
LOCK HIERARCHY EXAMPLE

A worked hierarchy: the database lock at level 1, account locks at level 2 ordered by ascending id, and the audit log lock at level 3. The invariant is that no code acquires a lower-level lock while holding a higher-level one.

Multiple Lock Acquisition with Duplicates

For N locks, sort before locking and handle duplicates:

lock_accounts_105.go
// Illustrative snippet — not a complete program
import "sort"

// ✓ GENERAL PATTERN: Lock multiple accounts safely
func lockAccounts(accounts []*Account) func() {
    // 1. Deduplicate (prevents self-deadlock from duplicates)
    seen := make(map[*Account]bool)
    unique := make([]*Account, 0, len(accounts))

    for _, acc := range accounts {
        if acc != nil && !seen[acc] {
            seen[acc] = true
            unique = append(unique, acc)
        }
    }

    // 2. Sort by ID (establishes order)
    sort.Slice(unique, func(i, j int) bool {
        return unique[i].id < unique[j].id
    })

    // 3. Lock in sorted order
    for _, acc := range unique {
        acc.mu.Lock()
    }

    // 4. Return unlock function
    return func() {
        // Unlock in reverse order (LIFO is conventional for symmetry
        // with Lock order, but not required for correctness)
        for i := len(unique) - 1; i >= 0; i-- {
            unique[i].mu.Unlock()
        }
    }
}
Code Review Checklist for Lock Ordering

When reviewing code that acquires multiple locks:

  • Is the ordering documented?
  • Does this code path follow the documented order?
  • Are self-transfer and duplicate cases handled?
  • Does the code handle nil pointers before locking?
  • Can this call other code that acquires locks?

10.5.4 Avoiding Blocking Operations Under Lock

Principle: Never perform blocking operations while holding a mutex. Blocking operations include:

Why it causes deadlock: If a blocking operation waits for another goroutine, and that goroutine needs the mutex, you have circular wait:

Terminal
G1: holds mutex A, blocks on channel send, waits for G2 to receive
G2: blocks on mutex A (held by G1), can't receive from channel
Cycle: G1 → channel → G2 → mutex A → G1

Anti-Pattern: Channel Send Under Mutex

server_105_x_1.go
// Illustrative snippet — not a complete program
// ✗ DEADLOCK RISK: Channel send under mutex
type Server struct {
    mu     sync.Mutex
    events chan Event
}

func (s *Server) HandleRequest(req Request) {
    s.mu.Lock()
    defer s.mu.Unlock()

    // ... update internal state ...

    s.events <- Event{Type: "request", Data: req}  // May block!
}

Problem: If events channel is full, this goroutine blocks while holding s.mu. Other goroutines trying to lock s.mu are blocked. If one of those goroutines is the consumer of events, deadlock occurs.

Solution: Release Before Blocking

server_105_2.go
// Illustrative snippet — not a complete program
// ✓ SAFE: Release lock before channel send
func (s *Server) HandleRequest(req Request) {
    s.mu.Lock()
    // ... update internal state ...
    event := Event{Type: "request", Data: req}
    s.mu.Unlock()  // Release before blocking

    s.events <- event  // Safe to block now
}

The Copy-Release-Call Pattern

registry_105_x_1.go
// Illustrative snippet — not a complete program
type EventHandler func(string)

type Registry struct {
    mu       sync.Mutex
    handlers map[string][]EventHandler
}

// ✗ WRONG: Callback under lock
func (r *Registry) Notify(event string) {
    r.mu.Lock()
    defer r.mu.Unlock()

    for _, handler := range r.handlers[event] {
        handler(event)  // What if handler needs r.mu? DEADLOCK!
    }
}

// ✓ CORRECT: Copy, release, then call
func (r *Registry) Notify(event string) {
    r.mu.Lock()
    handlers := make([]EventHandler, len(r.handlers[event]))
    copy(handlers, r.handlers[event])
    r.mu.Unlock()

    // Now safe: lock released before calling external code
    for _, handler := range handlers {
        handler(event)
    }
}
COPY-RELEASE-CALL PATTERN

The three steps for calling unknown code safely: take the lock and copy what you need, release the lock, then make the call with the copy. The callback never runs while the lock is held, so it cannot re-enter and deadlock.

What Counts as “Blocking”?

Operation
Map read/write
Slice append
Simple computation
Channel send/receive
Another Lock()
Network I/O
File I/O
Database query
Calling unknown code (callbacks, interface methods)
time.Sleep
select without default
The Callback Rule

Never call callbacks, interface methods, or function parameters while holding a lock. You don’t control what that code does—it might try to acquire the same lock (self-deadlock) or a different lock (ordering violation).


10.5.5 Timeouts and Non-Blocking Acquisition

When deadlock can’t be prevented structurally, timeouts provide an escape hatch—but they’re problematic.

Critical limitation: This works for channels but not for mutexes. Go’s sync.Mutex has no timeout mechanism—TryLock below is a non-blocking poll, not a timeout, and §10.1 explains why the distinction matters.

TIMEOUT APPLICABILITY

Which primitives support a timeout. Channel sends, channel receives, selects and contexts all do, using select with time.After or a context’s Done channel. sync.Mutex, sync.RWMutex and sync.WaitGroup do not. For mutexes use TryLock instead, remembering that TryLock is a non-blocking poll rather than a timeout.

Channel Timeouts

Timeouts prevent indefinite blocking on channel operations:

send_with_105.go
// Illustrative snippet — not a complete program
// ✓ SAFE: Timeout prevents indefinite block
func sendWithTimeout(
    ch chan<- int, value int, timeout time.Duration,
) error {
    select {
    case ch <- value:
        return nil
    case <-time.After(timeout):
        return fmt.Errorf("send timed out after %v", timeout)
    }
}

func receiveWithTimeout(
    ch <-chan int, timeout time.Duration,
) (int, error) {
    select {
    case value := <-ch:
        return value, nil
    case <-time.After(timeout):
        return 0, fmt.Errorf("receive timed out after %v", timeout)
    }
}
Timeouts Mask Bugs, Don’t Fix Them

A timeout that fires regularly indicates a bug:

  • Missing channel close
  • Goroutine leak (sender/receiver died)
  • Logic error in coordination

Fix the root cause instead of accepting timeouts as normal.

Context-Based Cancellation

context.Context provides cooperative cancellation across goroutine boundaries. See Chapter 13 for context patterns comprehensively.

process_request_105.go
// Illustrative snippet — not a complete program
func processRequest(ctx context.Context, data Data) error {
    results := make(chan Result, 1)

    go func() {
        result := expensiveComputation(data)
        select {
        case results <- result:
        case <-ctx.Done():
            // Context cancelled. Prevents a goroutine leak when
            // ctx is cancelled after the computation completes
            // but before the receiver reads the channel.
        }
    }()

    select {
    case result := <-results:
        return handleResult(result)
    case <-ctx.Done():
        // context.Canceled or context.DeadlineExceeded
        return ctx.Err()
    }
}

TryLock: Non-Blocking Acquisition

sync.Mutex and sync.RWMutex support TryLock() for non-blocking attempts:

cache_105_2.go
// Illustrative snippet — not a complete program
// ✓ APPROPRIATE: Opportunistic optimization
func (c *Cache) GetIfAvailable(key string) (Value, bool) {
    if c.mu.TryLock() {
        defer c.mu.Unlock()
        return c.data[key], true
    }
    // Lock held; return cached miss or stale data
    return Value{}, false
}

// ✓ APPROPRIATE: Metrics without blocking
func (m *Metrics) TryUpdateCounter(name string, delta int) {
    if m.mu.TryLock() {
        defer m.mu.Unlock()
        m.counters[name] += delta
        return
    }
    // Skip update if lock held (acceptable for metrics)
    m.missedUpdates.Add(1)
}

When TryLock is appropriate:

When TryLock is wrong:

mu_105_x_1.go
// Illustrative snippet — not a complete program
// ✗ BAD: Busy-wait polling
for !mu.TryLock() {
    time.Sleep(10 * time.Millisecond)  // Wastes CPU
}
defer mu.Unlock()

// ✓ GOOD: Just block
mu.Lock()
defer mu.Unlock()

If you’re going to keep trying until you get the lock, just use Lock()—it’s more efficient.

TryLock Is Not a Deadlock Solution

TryLock doesn’t prevent deadlock—it just makes your code give up immediately instead of blocking. If the fundamental issue is lock ordering, TryLock won’t help.

Better: Fix the lock ordering (Strategy 10.5.3).


10.5.6 The *Locked Helper Pattern

The *Locked suffix pattern prevents self-deadlock through internal method calls. This pattern is covered in detail in §9.4.

The Problem: Self-Deadlock Through Internal Calls

cache_105_x_3.go
// Illustrative snippet — not a complete program
func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if data, ok := c.data[key]; ok {
        return data, true
    }
    // Try to compute derived value
    return c.computeDerived(key) // BUG: calls method that locks
}

func (c *Cache) computeDerived(key string) ([]byte, bool) {
    c.mu.Lock() // DEADLOCK: already locked by Get
    defer c.mu.Unlock()
    // ...
}

The Solution: *Locked Helpers

cache_105_4.go
// Illustrative snippet — not a complete program
func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.getLocked(key)
}

// getLocked requires c.mu to be held.
func (c *Cache) getLocked(key string) ([]byte, bool) {
    if data, ok := c.data[key]; ok {
        return data, true
    }
    return c.computeDerivedLocked(key)
}

// computeDerivedLocked requires c.mu to be held.
func (c *Cache) computeDerivedLocked(key string) ([]byte, bool) {
    // No locking here—caller holds the lock
    // ...
}
*LOCKED PATTERN RULES

Four rules for the Locked-suffix pattern. Public methods acquire the lock and then call the Locked helper. Locked methods assume the lock is already held and never take it themselves. Locked methods call other Locked methods. A public method must never be called from inside a Locked method, because it would try to take the lock again.

See §9.4 for complete implementation patterns, naming conventions, and when to use this pattern vs. alternatives.


10.5.7 Real-World Example: Fixing a Production Deadlock

Scenario: E-commerce platform deadlocks during high-traffic order processing.

Symptom: Periodic request timeouts; goroutine count grows; pprof shows goroutines blocked in ProcessOrder and UpdateInventory.

Original Code (Deadlock-Prone)

order_105.go
// Illustrative snippet — not a complete program
type Order struct {
    mu      sync.Mutex
    id      string
    items   []Item
    status  Status
}

type Inventory struct {
    mu    sync.Mutex
    stock map[string]int
}

func (o *Order) Process(inv *Inventory) error {
    o.mu.Lock()
    defer o.mu.Unlock()

    // Reserve inventory for order items
    inv.mu.Lock()  // ← PROBLEM: Acquires inv.mu while holding o.mu
    defer inv.mu.Unlock()

    for _, item := range o.items {
        if inv.stock[item.SKU] < item.Quantity {
            return errors.New("insufficient stock")
        }
        inv.stock[item.SKU] -= item.Quantity
    }

    o.status = Confirmed
    return nil
}

func (inv *Inventory) Restock(orderID string, ord *Order) {
    inv.mu.Lock()
    defer inv.mu.Unlock()

    ord.mu.Lock()  // ← PROBLEM: Acquires ord.mu while holding inv.mu
    defer ord.mu.Unlock()

    for _, item := range ord.items {
        inv.stock[item.SKU] += item.Quantity
    }

    ord.status = Cancelled
}

Deadlock scenario:

Terminal
T0: G1: Order.Process() → o.mu.Lock()
T1: G2: Inventory.Restock() → inv.mu.Lock()
T2: G1: tries inv.mu.Lock() → blocks (G2 holds it)
T3: G2: tries ord.mu.Lock() → blocks (G1 holds it)
DEADLOCK: G1→inv.mu→G2→ord.mu→G1

Fix 1: Establish Lock Ordering (Preferred)

order_105_2.go
// Illustrative snippet — not a complete program
// Global ordering: ALWAYS lock Inventory before Order

func (o *Order) Process(inv *Inventory) error {
    // ✓ CORRECT ORDER: Inventory first, then Order
    inv.mu.Lock()
    defer inv.mu.Unlock()

    o.mu.Lock()
    defer o.mu.Unlock()

    for _, item := range o.items {
        if inv.stock[item.SKU] < item.Quantity {
            return errors.New("insufficient stock")
        }
        inv.stock[item.SKU] -= item.Quantity
    }

    o.status = Confirmed
    return nil
}

func (inv *Inventory) Restock(orderID string, ord *Order) {
    // ✓ CORRECT ORDER: Inventory first, then Order
    inv.mu.Lock()
    defer inv.mu.Unlock()

    ord.mu.Lock()
    defer ord.mu.Unlock()

    for _, item := range ord.items {
        inv.stock[item.SKU] += item.Quantity
    }

    ord.status = Cancelled
}

Result: Both functions acquire locks in same order (Inventory → Order). No cycle possible.

Fix 2: Redesign with Channels

inventory_manager_105.go
// Illustrative snippet — not a complete program
type InventoryManager struct {
    requests chan InventoryRequest
}

type InventoryRequest struct {
    Order    *Order
    Response chan error
}

func (im *InventoryManager) Run() {
    inventory := make(map[string]int)  // Confined to this goroutine

    for req := range im.requests {
        // No locks: inventory confined to this goroutine
        err := processRequest(req.Order, inventory)
        req.Response <- err
    }
}

func (im *InventoryManager) ProcessOrder(ord *Order) error {
    respCh := make(chan error, 1)
    im.requests <- InventoryRequest{Order: ord, Response: respCh}
    return <-respCh
}

Advantage: No locks → no deadlock. Inventory state confined to single goroutine.


Design Principles

Beyond specific techniques, these principles reduce deadlock risk.

Principle 1: Keep Critical Sections Short

cache_105_x_5.go
// Illustrative snippet — not a complete program
// ✗ BAD: I/O under lock
func (c *Cache) RefreshFromDB() error {
    c.mu.Lock()
    defer c.mu.Unlock()

    data, err := c.db.Query("SELECT * FROM items")  // Network I/O!
    if err != nil {
        return err
    }
    c.data = data
    return nil
}

// ✓ GOOD: I/O outside lock
func (c *Cache) RefreshFromDB() error {
    // Fetch without lock
    data, err := c.db.Query("SELECT * FROM items")
    if err != nil {
        return err
    }

    // Brief lock just for assignment
    c.mu.Lock()
    c.data = data
    c.mu.Unlock()
    return nil
}

Principle 2: Never Call Unknown Code Under Lock

Callbacks, interface implementations, and functions from other packages may acquire locks you don’t expect.

Principle 3: Avoid Nested Lock Acquisition

When nesting is unavoidable, document the order and ensure all paths follow it.

Principle 4: Prefer Channels for Coordination

MUTEX VS CHANNEL DECISION GUIDE

Which primitive to reach for: a mutex to protect internal state that stays in place, a channel to move data or ownership between goroutines. Choosing the wrong one is where most avoidable deadlocks start.

Principle 5: Design for Testability

ch10/bank_test.go
// Illustrative snippet — not a complete program
// ✓ TESTABLE: goleak catches leaks
func TestTransfer(t *testing.T) {
    defer goleak.VerifyNone(t)

    // Test completes = no goroutines stuck
}

// ✓ TESTABLE: Stress test exposes races
func TestTransferStress(t *testing.T) {
    defer goleak.VerifyNone(t)

    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Go(func() {
            Transfer(accountA, accountB, 1)
        })
        wg.Go(func() {
            Transfer(accountB, accountA, 1)
        })
    }
    wg.Wait()
}

Run stress tests with:

Terminal
$ go test -race -count=100 ./...

Decision Framework

DEADLOCK PREVENTION DECISION TREE

A decision tree that walks from the situation to the strategy: can the shared state be eliminated, can the locks be combined, is a consistent order definable, must unknown code be called under the lock, and only at the leaves does it reach timeouts and TryLock.


Prevention Checklist: Prevention

DEADLOCK PREVENTION CHECKLIST

A checklist split by phase. Design: can shared state be eliminated through confinement or immutability, can multiple locks be combined into one, is lock ordering defined and documented, would channels be more appropriate than mutexes. Implementation: critical sections are short with no I/O or network calls, no unknown code is called under a lock, and self-reference is checked before locking.


Common Mistakes: Prevention

Mistake
Ad-hoc lock ordering
HTTP/DB calls under lock
Callbacks under lock
Public method calls from *Locked
Missing self-transfer check
Missing duplicate check
Mutex timeout with goroutines
TryLock in a busy-wait loop

Summary: Prevention

Prevention strategies by effectiveness:

Strategy
Confinement
Immutability
Single lock
Lock ordering
Avoid blocking under lock
Timeouts
Message passing

Key Takeaways

  1. Lock ordering is the most effective general-purpose prevention—works for any number of locks
  2. Document ordering prominently—package comments, type comments, function comments
  3. Self-transfer checks prevent self-deadlock—always validate from != to and deduplicate
  4. Never block under mutex—I/O, channels, callbacks must be outside critical sections
  5. Copy-release-call pattern—safe way to call external code from synchronized contexts
  6. Timeouts work for channels, not mutexesselect with time.After or context
  7. TryLock is not a deadlock solution—it’s opportunistic acquisition, not prevention
  8. Confinement eliminates deadlock—goroutine-owned state needs no locks
  9. Immutability eliminates mutual exclusion—copy-on-write for safe sharing
  10. Test with high contention—stress tests expose timing-dependent deadlocks
Prevention Hierarchy
  1. Best: Design to avoid locks (confinement, immutability, channels)
  2. Good: Single lock per operation (no ordering needed)
  3. Acceptable: Multiple locks with documented ordering
  4. Fragile: Timeouts masking coordination bugs
  5. Wrong: Hoping it won’t deadlock

Choose the highest level your design permits.

Chapter 9 Connection

Chapter 9's mutex design patterns directly support prevention:

  • Monitor pattern: Encapsulation limits lock visibility → simpler ordering
  • *Locked helpers: Prevents self-deadlock
  • Short critical sections: Reduces hold time → lower deadlock probability
  • No I/O under lock: Prevents blocking while holding resources

Self-Check Questions: Prevention

Test your understanding of prevention strategies.

1. Two types ServiceA and ServiceB each have their own mutex. ServiceA.Process() acquires a.mu then calls b.Update() (which acquires b.mu). ServiceB.Refresh() acquires b.mu then calls a.GetState() (which acquires a.mu). Is there a deadlock risk? How would you fix it?

Yes, deadlock risk exists. Lock acquisition order is opposite:

  • ServiceA.Process(): a.mub.mu
  • ServiceB.Refresh(): b.mua.mu

Fixes:

  1. Establish hierarchy: Always acquire a.mu before b.mu. Change ServiceB.Refresh() to acquire a.mu first, or restructure to avoid cross-service calls under lock.
  2. Copy-release-call pattern: Have ServiceB.Refresh() copy whatever state it needs while holding b.mu, release b.mu, then call a.GetState() without holding any locks.

2. Why is this code still at risk of deadlock despite using lock ordering?

process_105.go
// Illustrative snippet — not a complete program
func process(resources []*Resource) {
    sort.Slice(resources, func(i, j int) bool {
        return resources[i].id < resources[j].id
    })

    for _, r := range resources {
        r.mu.Lock()
        defer r.mu.Unlock()
    }
    // ... work ...
}
Show answer

The code doesn’t handle the case where resources contains duplicates. If the same resource appears twice in the slice, the code tries to lock it twice → self-deadlock:

resources_105_x_1.go
// Illustrative snippet — not a complete program
resources := []*Resource{r1, r2, r1}  // r1 appears twice
// After sorting: [r1, r1, r2]
// First iteration: r1.mu.Lock() ✓
// Second iteration: r1.mu.Lock() → DEADLOCK (already held)

Fix: Deduplicate before sorting and locking.

3. A cache needs to serialize to disk periodically. The serialization reads all entries (needs lock). Writing to disk is slow (100ms). How would you structure this to avoid blocking cache operations?

Snapshot under lock with deep copy, serialize and write outside lock:

cache_105_6.go
// Illustrative snippet — not a complete program
func (c *Cache) SaveToDisk() error {
    // Hold lock only during deep copy
    c.mu.RLock()
    snapshot := make(map[string][]byte, len(c.data))
    for k, v := range c.data {
        copied := append([]byte(nil), v...)  // Deep copy
        snapshot[k] = copied
    }
    c.mu.RUnlock()  // Release before slow I/O

    // Serialize and write outside critical section
    serialized := serializeToJSON(snapshot)  // No lock held
    return os.WriteFile("cache.json", serialized, 0644)  // Slow I/O
}

Cache operations can proceed while serialization and I/O happen.


Quick Reference

This section consolidates Chapter 10's deadlock patterns, detection techniques, and prevention strategies into a practical reference. Use it during code reviews, debugging sessions, and system design.


Symptom → Cause

When you suspect a deadlock, start here:

Symptom
Program exits with all goroutines are asleep
Requests time out, but the service still looks healthy
Goroutine count grows without bound
One stack shows the same mutex type twice
Two stacks show the same call with swapped pointer arguments
Several goroutines blocked on channel send or receive

Deadlock Type Summary

Type
Unbuffered send without receiver
Nil channel
Unclosed channel
Circular channel wait
Mutex self-deadlock
Lock ordering violation
RLock→Lock upgrade
Channel+Mutex mixed
Callback under lock
Critical Limitation

In production services, the runtime detector almost NEVER triggers because HTTP servers, health checks, and background workers keep at least one goroutine running. Treat “Runtime Detects” as “only in simple programs or full system deadlock.” Production requires active monitoring (goroutine count, timeouts, pprof).


Emergency Commands

Terminal
# Get goroutine count (fast check). Quote the URL: zsh globs a bare '?'.
$ curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' | head -1
# Get full goroutine dump
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=2' \
  > goroutines.txt
# SIGQUIT also dumps stacks -- but it KILLS the process (exit 2).
# Use it only on a process you are willing to lose.
$ kill -SIGQUIT $(pgrep myserver) # output goes to stderr, then it dies
# Find every blocked goroutine, whatever it is blocked on
$ grep -nE '^goroutine [0-9]+ \[(sync\.|chan |select)' goroutines.txt
# Or one primitive at a time
$ grep -A 10 '\[sync.Mutex.Lock' goroutines.txt
$ grep -A 10 '\[chan send' goroutines.txt
# Find long-blocked goroutines
$ grep 'minutes\|hours' goroutines.txt
# Compare profiles to find leaks
$ curl http://localhost:6060/debug/pprof/goroutine > baseline.pprof
$ sleep 300
$ curl http://localhost:6060/debug/pprof/goroutine > current.pprof
$ go tool pprof -base baseline.pprof current.pprof

Tool Quick Reference

Tool
Runtime detection
Stack dump
pprof goroutines
Block profile
goleak
go-deadlock

For comprehensive pprof usage, see Chapter 19.


Goroutine State Reference

When reading stack traces, these states indicate blocking:

State
[running]
[runnable]
[sync.Mutex.Lock]
[sync.RWMutex.RLock]
[sync.RWMutex.Lock]
[chan send]
[chan receive]
[chan receive (nil chan)]
[select]
[select (no cases)]

Duration interpretation. These are rules of thumb, not measurements— calibrate them against your own service’s normal lock-hold times before you page anyone on them:

The caveat that matters more than the exact numbers: a bounded stall looks identical to a deadlock in a single dump. A lock convoy, an exhausted connection pool, or a slow downstream call with a 10-minute timeout will all show goroutines blocked for minutes and then clear on their own. One dump cannot tell them apart. Take two or three, 30 seconds apart, and compare: if the same goroutine IDs are blocked at the same lines with growing durations, it is a deadlock; if the set of blocked goroutines churns, you are looking at contention or a slow dependency instead.


Prevention Strategy Matrix

Strategy
Confinement
Immutability
Single lock
Lock ordering (by ID)
Lock ordering (hierarchical)
Copy-release-call
*Locked helpers
Context timeouts
TryLock

Prevention hierarchy (most to least effective):

  1. Eliminate shared state — Confinement, immutability, message passing
  2. Reduce lock scope — Single lock, atomics
  3. Enforce lock ordering — By ID, address, or hierarchy
  4. Avoid blocking under lock — Copy-release-call pattern
  5. Use timeouts — Channels only; last resort (masks bugs)

The 10 Commandments of Deadlock Prevention

  1. Thou shalt eliminate shared state when possible Confinement, immutability, and message passing beat synchronization.
  2. Thou shalt use the simplest synchronization for the job For deadlock risk: atomics < single lock < multiple locks < channel cycles.
  3. Thou shalt document lock ordering prominently Package docs, type docs, function docs—make it visible.
  4. Thou shalt always lock in the same order No exceptions, no “just this once,” no “it’s fine in this case.”
  5. Thou shalt check for self-reference before locking if from == to { return } prevents self-deadlock.
  6. Thou shalt never perform blocking operations under lock I/O, channels, callbacks—release first or use non-blocking.
  7. Thou shalt use *Locked helpers for internal calls Public methods lock, *Locked helpers assume held.
  8. Thou shalt release locks before calling external code Copy-release-call: never trust callbacks with locks held.
  9. Thou shalt close channels in senders, not receivers defer close(ch) prevents for-range deadlock.
  10. Thou shalt test with high concurrency and goleak Stress tests + goleak catch deadlocks before production.

Production Incident Response Workflow

DEADLOCK INCIDENT RESPONSE WORKFLOW

A six-phase incident procedure. Detect: check alerts, the health endpoint, and whether CPU is low (blocking) or high (livelock). Capture: take a goroutine dump from the pprof endpoint, use SIGQUIT only if you can lose the process because it exits after dumping, and take two or three dumps thirty seconds apart. Analyze: find long-blocked goroutines, identify the primitive, find your own frames, and look for the same type twice or swapped arguments. Identify root cause, then mitigate by restarting or routing traffic away, and finally apply a permanent fix with a regression test.


Code Review Checklist

DEADLOCK CODE REVIEW CHECKLIST

What to look for when reviewing concurrent code: multiple locks acquired in a documented order, no blocking operations inside critical sections, no callbacks invoked under a lock, Locked-suffix helpers used for internal calls, self-reference guarded before locking, and channels closed by their senders.


Chapter Connections

Chapter
Chapter 2
Chapter 3
Chapter 4
Chapter 8
Chapter 9
Chapter 11
Chapter 13
Chapter 19

Chapter Summary

CHAPTER 10: DEADLOCKS - KEY TAKEAWAYS

A recap of the chapter. A deadlock is a set of goroutines waiting on each other so none can proceed, and it needs all four Coffman conditions. Channel deadlocks come from unbuffered sends or receives without a partner, nil channels, unclosed channels with a for-range, and circular dependencies. Mutex deadlocks come from self-deadlock, lock ordering violations, blocking operations under a lock, and callback re-entry. Detection is limited: the runtime only reports when every goroutine is blocked, which is rare in production, so partial deadlocks need active monitoring. Prevention is ordered from eliminating shared state down to timeouts as a last resort.

Final Thought

When you encounter a deadlock, resist the urge to add timeouts or TryLock as quick fixes. Instead, ask: “What design change would make this deadlock impossible?” The answer usually involves one of the prevention strategies from §10.5.

Prevention is always better than detection. A prevented deadlock never happens. A detected deadlock still ruined someone’s day.


Further Resources:


Chapter Review

Before moving to Chapter 11, verify your understanding with these synthesis questions that span multiple sections.


Integration Questions

These questions require combining concepts from multiple sections.

1. A production service shows high CPU but blocked goroutines. Is this deadlock or livelock behavior? How would you diagnose each?

Diagnosis approach:

  • High CPU + blocked goroutines = Likely livelock (goroutines are running but making no progress, e.g., busy-waiting TryLock loops)
  • Low CPU + blocked goroutines = Likely deadlock (goroutines blocked on mutexes or channels)

To diagnose:

  1. Check CPU metrics—livelock shows sustained high CPU; deadlock shows low CPU
  2. Capture goroutine dump—livelock shows [running] or [runnable] states; deadlock shows [sync.Mutex.Lock] or [chan send/receive]
  3. Look at stack traces—livelock often shows retry loops; deadlock shows blocked at Lock() or channel operations

Key distinction: Deadlocked goroutines are blocked (scheduler removes them from run queue). Livelocked goroutines are running (consuming CPU) but accomplishing nothing.

2. You discover both channel and mutex deadlocks in the same codebase. What systematic approach would you use to fix both?

Systematic approach:

  1. Capture comprehensive state: - Goroutine dump with pprof/goroutine?debug=2 - Note blocking states: [sync.Mutex.Lock] = mutex, [chan send/receive] = channel
  2. Categorize by type: - Channel deadlocks: Look for unclosed channels, circular dependencies, nil channels - Mutex deadlocks: Look for self-deadlock (same type twice), lock ordering violations, blocking under lock
  3. Apply appropriate fixes: - Channel: defer close(ch) in senders, break circular dependencies, initialize channels - Mutex: *Locked helpers for self-deadlock, consistent ordering by ID, copy-release-call for callbacks
  4. Consider design-level changes: - Can shared state be eliminated? (confinement) - Can channels replace mutexes? (message passing) - Can multiple locks be combined? (coarse-grained locking)
  5. Add regression tests: - Use goleak.VerifyNone(t) in all tests - Add stress tests with high concurrency - Run with -race flag
3. Given a goroutine dump showing multiple goroutines in [sync.Mutex.Lock], how do you determine if it’s lock ordering violation vs self-deadlock?

Distinguishing patterns:

Self-deadlock:

  • Single goroutine blocked
  • Same mutex type appears twice in the stack trace
  • Example: Cache.Get() calls Cache.Lookup(), both try to lock c.mu
Terminal
goroutine 42 [sync.Mutex.Lock, 5 minutes]:
    internal/sync.(*Mutex).Lock(...)
    main.(*Cache).Lookup(...) <- trying to lock c.mu
    main.(*Cache).Get(...) <- already holds c.mu

Lock ordering violation:

  • Two or more goroutines blocked
  • Different goroutines have same function with swapped pointer arguments
  • Example: G1 has Transfer(0xA, 0xB), G2 has Transfer(0xB, 0xA)
Terminal
goroutine 18 [sync.Mutex.Lock, 5 minutes]:
    main.Transfer(0xc000010000, 0xc000010020, ...) <- A->B
goroutine 19 [sync.Mutex.Lock, 5 minutes]:
    main.Transfer(0xc000010020, 0xc000010000, ...) <- B->A (swapped!)

Key indicator: Self-deadlock = one goroutine, same type twice. Ordering = multiple goroutines, swapped arguments.

4. When is timeout prevention appropriate vs. when should you fix the underlying design?

Use timeouts when:

  • External systems are involved (network calls, database queries)
  • You need graceful degradation under load
  • The blocking operation is inherently unpredictable (user input, third-party APIs)
  • You’re implementing circuit breakers or retry logic

Fix the design when:

  • Deadlock is between components you control
  • The timeout would mask a bug (deadlock still exists, just hidden)
  • Timeouts cause cascading failures or resource leaks
  • You see repeated timeout-triggered failures in logs

Rule of thumb: Timeouts are for external uncertainty. Internal deadlocks should be prevented through design (lock ordering, confinement, etc.).

Warning signs that timeout is wrong fix:

  • Adding timeout just to “make tests pass”
  • Timeout fires frequently in production
  • Timeout creates goroutine leaks (sender blocks after receiver gives up)
  • Code complexity increases significantly to handle timeout cases

Practical Exercises

Exercise 1: Diagnose from Goroutine Dump

Given this goroutine dump, identify the deadlock type and root cause:

Terminal
goroutine 15 [sync.Mutex.Lock, 3 minutes]:
internal/sync.(*Mutex).Lock(0xc0000a2000)
    /usr/local/go/src/internal/sync/mutex.go:81 +0xa7
main.(*OrderService).ProcessOrder(0xc0000b4000, 0xc0000c6000)
    /app/order.go:45 +0x85
main.(*OrderService).ProcessBatch(0xc0000b4000, 0xc0000d0000)
    /app/order.go:32 +0x12f
goroutine 18 [sync.Mutex.Lock, 3 minutes]:
internal/sync.(*Mutex).Lock(0xc0000a2000)
    /usr/local/go/src/internal/sync/mutex.go:81 +0xa7
main.(*OrderService).ValidateOrder(0xc0000b4000, 0xc0000c6040)
    /app/order.go:67 +0x4a
main.(*OrderService).ProcessOrder(0xc0000b4000, 0xc0000c6040)
    /app/order.go:48 +0xc5
Show answer

Deadlock type: Self-deadlock — with a queued bystander.

Two goroutines are blocked, but only one of them is the bug. Separating the culprit from the collateral damage is the whole skill here, because in a real dump the culprit is outnumbered: one self-deadlocked goroutine can have dozens piled up behind it.

Read the stacks bottom-up:

  • Goroutine 18 is the culprit. Its stack contains ProcessOrder and ValidateOrder. ProcessOrder took s.mu at order.go:48, then called ValidateOrder, which tries to take the same s.mu at order.go:67. Go’s mutexes are not reentrant, so it blocks holding the very lock it is waiting for. Nothing will ever release it.
  • Goroutine 15 is a victim. Its stack shows only ProcessBatchProcessOrder, blocked at the first acquisition. It did nothing wrong; it simply arrived after goroutine 18 had already wedged the lock.

The tell: both goroutines are blocked on the same mutex — the identical address 0xc0000a2000 on the Lock frame proves it — but only goroutine 18 has the same receiver appearing twice in its own stack. One goroutine holding and waiting for the same lock is a self-deadlock; every other goroutine on that address is a consequence, not a cause. Fix the culprit and the queue drains on its own.

Root cause: ProcessOrder holds the lock and calls the exported ValidateOrder, which locks again.

Fix: Use *Locked helper pattern:

order_service_105.go
// Illustrative snippet — not a complete program
func (s *OrderService) ProcessOrder(order *Order) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.processOrderLocked(order)
}

func (s *OrderService) processOrderLocked(order *Order) error {
    // Call validateOrderLocked instead of ValidateOrder
    if err := s.validateOrderLocked(order); err != nil {
        return err
    }
    // ... rest of processing
}

// validateOrderLocked requires s.mu to be held.
func (s *OrderService) validateOrderLocked(order *Order) error {
    // Validation logic without locking
}

Exercise 2: Fix Lock Ordering

Refactor this code to prevent deadlock:

account_105_2.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    balance int64
}

func Transfer(from, to *Account, amount int64) error {
    from.mu.Lock()
    defer from.mu.Unlock()
    to.mu.Lock()
    defer to.mu.Unlock()

    if from.balance < amount {
        return errors.New("insufficient funds")
    }
    from.balance -= amount
    to.balance += amount
    return nil
}
Show answer

Problem: Two concurrent transfers Transfer(A, B) and Transfer(B, A) can deadlock—G1 holds A, waits for B; G2 holds B, waits for A.

Fix: Add unique ID field and lock in consistent order:

account_105_3.go
// Illustrative snippet — not a complete program
type Account struct {
    mu      sync.Mutex
    id      int64  // Unique identifier for ordering
    balance int64
}

func Transfer(from, to *Account, amount int64) error {
    // Prevent self-deadlock
    if from == to {
        return errors.New("cannot transfer to same account")
    }

    // Establish consistent ordering: always lock lower ID first
    first, second := from, to
    if from.id > to.id {
        first, second = to, from
    }

    first.mu.Lock()
    defer first.mu.Unlock()
    second.mu.Lock()
    defer second.mu.Unlock()

    // IMPORTANT: Use from/to for business logic, NOT first/second
    if from.balance < amount {
        return errors.New("insufficient funds")
    }
    from.balance -= amount
    to.balance += amount
    return nil
}

Key points:

  • first/second determine lock order
  • from/to determine money flow
  • Both Transfer(A,B) and Transfer(B,A) now lock in same order (lower ID first)

Exercise 3: Design for Confinement

Convert this shared-state design to use confinement (actor pattern):

counter_105_2.go
// Illustrative snippet — not a complete program
type Counter struct {
    mu    sync.Mutex
    value int64
}

func (c *Counter) Increment() {
    c.mu.Lock()
    c.value++
    c.mu.Unlock()
}

func (c *Counter) Get() int64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}
Show answer

Confinement-based design:

counter_105_3.go
// Illustrative snippet — not a complete program
type Counter struct {
    ops chan func(*int64)
}

func NewCounter(ctx context.Context) *Counter {
    c := &Counter{ops: make(chan func(*int64))}
    go c.run(ctx)
    return c
}

func (c *Counter) run(ctx context.Context) {
    var value int64  // Confined to this goroutine—no mutex needed
    for {
        select {
        case <-ctx.Done():
            return
        case op := <-c.ops:
            op(&value)
        }
    }
}

func (c *Counter) Increment(ctx context.Context) error {
    done := make(chan struct{})
    select {
    case c.ops <- func(v *int64) { *v++; close(done) }:
        <-done
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (c *Counter) Get(ctx context.Context) (int64, error) {
    result := make(chan int64, 1)
    select {
    case c.ops <- func(v *int64) { result <- *v }:
        return <-result, nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}

Key benefits:

  • No mutex—value is confined to single goroutine
  • Deadlock impossible—no locks to order or re-enter
  • Naturally supports context cancellation

Trade-off: Higher latency (channel communication vs direct memory access). Use for coordination-heavy scenarios; keep mutex for simple, high-throughput counters (or use atomic.Int64).


Exercise 10.1 — Fix the AB-BA Deadlock

Your move

Fix the AB-BA Deadlock

Everything up to here has been reading. This one runs.

code/ch10/ holds a two-account bank whose Transfer is correct by every measure the earlier chapters gave you—it compiles, go vet is clean, and go test -race finds no data race, because every balance write really is under the right lock. It still takes the service down.

ch10/bank.go
package ch10

import (
	"errors"
	"sync"
)

var ErrSameAccount = errors.New(
	"bank: cannot transfer to the same account")

// TODO(reader): This code is correct in every way the previous
// chapters taught you to check. It compiles. `go vet` is happy. Run
// `go test -race -run TestTransferMovesMoney ./...` and the race
// detector finds nothing, because every balance write really is under
// the right lock.
//
// It still takes the whole service down.
//
// Transfer locks `from` and then `to`. Two goroutines running
// opposing transfers — A→B and B→A — each grab their first lock and
// then wait forever for the other's. That is §10.3's AB-BA deadlock,
// and it is the reason this chapter exists: the race detector cannot
// see it, and a single-threaded test cannot reach it.
//
// Fix it by making the lock order independent of the argument order,
// so that every goroutine touching this pair acquires them in the
// same sequence. §10.5.3 walks through the technique.
//
// Two tests gate the fix, and the second one is the interesting half:
//
//  1. TestConcurrentTransfersDoNotDeadlock runs opposing transfers
//     until the deadlock fires. It fails today.
//  2. TestSelfTransferDoesNotDeadlock calls Transfer(acct, acct, n).
//     An ordering fix that forgets this case will pass test 1 and
//     hang on test 2 — ordering by ID is only a *total* order if you
//     handle the two accounts being equal. §10.1 has the callout.
//
// Return ErrSameAccount for a self-transfer rather than pretending to
// move the money.
type Account struct {
	mu      sync.Mutex
	id      int
	balance int64
}

// NewAccount returns an account with the given id and opening balance.
// Ids are unique within a test.
func NewAccount(id int, balance int64) *Account {
	return &Account{id: id, balance: balance}
}

// Balance returns the current balance.
func (a *Account) Balance() int64 {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance
}

// Transfer moves amount from one account to the other.
func Transfer(from, to *Account, amount int64) error {
	from.mu.Lock()
	defer from.mu.Unlock()

	to.mu.Lock()
	defer to.mu.Unlock()

	from.balance -= amount
	to.balance += amount
	return nil
}

The suite turns a hang into a failure, which is the only way to test for a deadlock: a deadlocked goroutine never returns, so waiting on it directly would just hang the test binary until the package timeout fires ten minutes later.

with_deadline_105.go
// Illustrative snippet — not a complete program
// withDeadline runs work in its own goroutine and fails the test if it
// has not finished in time. A deadlocked goroutine never returns, so
// this is the only way to turn a hang into a test failure — waiting on
// it directly would just hang the whole test binary until the package
// timeout fires ten minutes later.
//
// The leaked goroutines keep their locks forever, which is why every
// test below builds its own accounts instead of sharing a fixture.
func withDeadline(t *testing.T, d time.Duration, work func()) {
	t.Helper()
	done := make(chan struct{})
	go func() {
		defer close(done)
		work()
	}()
	select {
	case <-done:
	case <-time.After(d):
		t.Fatalf("timed out after %v: the goroutines are deadlocked.\n"+
			"Run the suite again with -timeout 30s and read the dump: "+
			"the goroutines blocked in [sync.Mutex.Lock] with swapped "+
			"arguments are the AB-BA pair.", d)
	}
}
ch10/bank_test.go
// Illustrative snippet — not a complete program
// The gate. Opposing transfers on the same pair of accounts: one
// goroutine going A->B while another goes B->A. With the shipped
// implementation the two lock orders invert and both goroutines block
// forever. Measured: this deadlocks on every run.
func TestConcurrentTransfersDoNotDeadlock(t *testing.T) {
	a, b := NewAccount(1, 1_000_000), NewAccount(2, 1_000_000)

	withDeadline(t, 5*time.Second, func() {
		var wg sync.WaitGroup
		for range 2 {
			wg.Go(func() {
				for range 2000 {
					Transfer(a, b, 1)
				}
			})
			wg.Go(func() {
				for range 2000 {
					Transfer(b, a, 1)
				}
			})
		}
		wg.Wait()
	})

	// Money is neither created nor destroyed.
	total := a.Balance() + b.Balance()
	if want := int64(2_000_000); total != want {
		t.Errorf("total = %d, want %d", total, want)
	}
}

And the half that is easy to miss:

ch10/bank_test.go
// Illustrative snippet — not a complete program
// The second half of the lesson. Ordering the two locks by account id
// is only a total order if you also handle the two accounts being the
// same object -- otherwise this call locks one mutex twice and
// self-deadlocks. A fix that orders by id but skips the equality guard
// passes the test above and hangs here.
func TestSelfTransferDoesNotDeadlock(t *testing.T) {
	a := NewAccount(1, 100)

	withDeadline(t, 5*time.Second, func() {
		if err := Transfer(a, a, 50); !errors.Is(err, ErrSameAccount) {
			t.Errorf("Transfer(a, a, 50) = %v, want %v",
				err, ErrSameAccount)
		}
	})

	if got, want := a.Balance(), int64(100); got != want {
		t.Errorf("a.Balance() = %d, want %d; "+
			"a rejected transfer must not move money", got, want)
	}
}
Done when: go test -race ./... in code/ch10/ reports ok for all three tests.
Two traps, both from this chapter. The first is the one the exercise is named for: locking from and then to makes the lock order depend on which way the money moves, so two goroutines going opposite directions invert it (§10.3, Pattern 2). Order the locks by something that does not depend on the arguments—§10.5.3 has the technique.

The second only bites after you have fixed the first. Ordering by id is a total order only once you have said what happens when the two ids compare equal, and the usual way that happens is the same account passed twice. A fix that orders correctly but skips that guard passes TestConcurrentTransfersDoNotDeadlock and then hangs on TestSelfTransferDoesNotDeadlock—verified. §10.1's callout on total orders covers why.

Where the files are: labs/go-concurrency/code/ch10/. A worked answer sits in solution/bank.go.txt.

Final Checklist

Before moving to Chapter 11, ensure you can:


Further Reading

Primary sources

The original problems

Tooling


Next: Chapter 11 explores atomic operations—lock-free primitives that eliminate entire categories of synchronization bugs, including some deadlock scenarios.

Next

You can now name the four conditions a deadlock needs, recognize the channel and mutex patterns that produce them, read a goroutine dump well enough to tell the culprit from the goroutines queued behind it, and—most of the time—prevent the whole class by ordering locks consistently. Every technique here still assumed a lock. Chapter 11 removes it: atomic operations, what the hardware actually guarantees, and the narrow set of problems where lock-free code is both correct and worth the difficulty.