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.
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:
// 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:
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.
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:
- They don’t always happen—they require specific timing to manifest
- They produce no error messages in most cases
- They’re invisible to the race detector (no data race occurred)
- They can lurk in code for months before the right conditions align
By the end of this chapter, you’ll understand:
- The four conditions required for deadlock and how removing any one prevents it
- Channel deadlock patterns: blocked sends/receives, unclosed channels, circular dependencies
- Mutex deadlock patterns: lock ordering violations, self-deadlock, the dining philosophers problem
- How Go’s runtime detects deadlocks (and its critical limitations)
- Prevention strategies: lock ordering, timeouts, and avoiding locks across blocking calls
What we’re NOT covering in Chapter 10:
- Atomic operations as mutex alternatives—Chapter 11
sync.Condand other sync primitives—Chapter 12- Context-based cancellation patterns (full treatment)—Chapter 13
- Comprehensive testing strategies—Chapter 16
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.
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 thewg.Add(1)/defer wg.Done()pair used throughout this book’s earlier drafts. Both are correct;wg.Gois 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 readinternal/sync.(*Mutex).lockSlowrather thansync.(*Mutex).lockSlow, and the mutex address you use to correlate goroutines now sits on thelockSlowframe.
Everything else here—the Coffman conditions, the detector’s rules, the prevention strategies—is version-independent.
Deadlocks vs Related Problems
Before diving in, let’s distinguish deadlocks from similar-sounding issues:
runtime.NumGoroutine(), pprof, goleak-race)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: 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.
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 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.
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:
sync.Mutexsync.RWMutex (write)// 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:
sync.RWMutexFor 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:
// 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:
// 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.
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:
sync.Mutexsync.RWMutexselect with timeoutcontext.Context// 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.
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:
- Prevent deadlock by design—use lock ordering (§10.5.3)
- Detect deadlocks after they occur—use stack traces (§10.4)
- Use channels instead of mutexes—where the design permits
TryLockwith 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:
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:
// 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.
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:
// 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:
// 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:
sync.Mutex is exclusiveUpdate, waits in validateThis 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:
// 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:
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.
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:
fatal error: all goroutines are asleep (exit 2)go func(){ for { time.Sleep(10*time.Millisecond) } }()go func(){ select{} }()fatal error still firesThe 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.
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:
sync.Mutex provides exclusive accessBreaking Deadlocks: Remove Any Condition
Since all four conditions must be present, removing any one prevents deadlock:
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.
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
defer mu.Unlock() prevents deadlock”defer only prevents forgetting to unlock; doesn’t prevent circular waitsSummary: The Four Conditions
sync.Mutex, channel opsLock() blocks indefinitelyKey Takeaways
- All four conditions must be present for deadlock—remove any one to prevent it
- Mutual exclusion is often necessary—focus on eliminating other conditions
- Hold and wait creates the “grip”—goroutines won’t release what they have
- No preemption means no escape—blocked goroutines stay blocked forever
- Circular wait closes the loop—the most directly preventable condition
- Lock ordering is the primary prevention strategy—simple, effective, no runtime cost
- Channels can deadlock too—the four conditions apply to any blocking synchronization
- Self-deadlock is real—a single goroutine can deadlock with a single mutex
- Deadlocks are timing-dependent—they may not manifest in every run
- 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.
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:
A channel operation blocks when it cannot complete:
This section covers three fundamental channel deadlock patterns:
- All goroutines blocked—complete program deadlock (runtime detects)
- Unclosed channels with waiting receivers—sometimes detected, often a goroutine leak
- 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.
chan sendchan receiveselect (no ready cases)sync.Mutex.Locktime.SleepIO waitA 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
// 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:
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.
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:
The symmetric case—receiving with no sender—is identical:
// Illustrative snippet — not a complete program
func main() {
ch := make(chan int)
val := <-ch // Blocks forever—no sender exists
fmt.Println(val)
}
Output:
Nil Channel Operations
A receive (or send) on a nil channel blocks forever. This often occurs with uninitialized channel variables:
// 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:
ch <- v<-chclose(ch)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.
// 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:
Execution trace:
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:
// Illustrative snippet — not a complete program
go func() {
defer close(ch) // ✓ Ensures close on all exit paths
ch <- 1
ch <- 2
ch <- 3
}()
- 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 rangeexits 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:
// 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.
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:
// 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:
- Timeout fires before search completes (50ms < 100ms)
- Function returns
"timeout" - Background goroutine finishes, tries to send
- Send blocks forever—no receiver exists (we already returned)
- Goroutine leaks (not detected—caller continues running)
Fix: Buffer matches sender count
// 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
}
}
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.
// 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.
A common mistake:
// 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:
Multiple Senders: Coordination Required
When multiple senders write to the same channel, closing requires coordination:
// 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:
// 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
}
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
// 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:
Execution trace:
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.
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:
Fix 1: Reorder Operations
// 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:
Why this works: Operations are now ordered sequentially—no circular wait.
Fix 2: Use Buffered Channels
// 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:
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.
*
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:
// 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:
The fix: Use defer close() at the start of every pipeline stage:
// 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
}
}()
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.
Every pipeline stage should:
- Use
defer close(output)at the start of the goroutine - Respect context cancellation for clean shutdown
- Handle upstream closure gracefully (
for rangedoes this automatically)
Prevention Checklist: Channel Deadlocks
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
close(ch) with for rangedefer close(ch) in sendervar ch chan T)make(chan T)default or ensure initializedSummary: Channel Deadlocks
defer close(ch) in sendermake(chan T)defer close(output)Key Takeaways
- Channel deadlocks follow the four Coffman conditions—the resources are synchronization points, not locks
- Unbuffered channels require simultaneous send and receive—one without the other blocks
- Runtime detection requires ALL goroutines blocked—timed operations and I/O don’t count
- Partial deadlocks are goroutine leaks—runtime silent, detect with goleak/pprof
- The sender-closes principle prevents receiver deadlocks—
defer close(ch)in sender goroutines - Buffer size must accommodate all potential senders—when receivers may not consume all values
- Nil channels block forever—useful in
selectto disable cases, catastrophic when accidental - Circular dependencies are the channel equivalent of lock-ordering violations—break cycles by reordering or buffering
- Closing doesn’t unblock senders—they panic; use buffers instead
- Production code rarely triggers detection—background goroutines mask deadlocked workers
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:
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?
// 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
}
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:
// 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)
}()
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.
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:
- Lock acquisition order isn’t always visible in stack traces
- They often span multiple types with independent mutexes
- Call chains can cross module boundaries, hiding the cycle
- They’re timing-dependent—may only manifest under specific load patterns
This section covers four mutex deadlock patterns:
- Self-deadlock—a goroutine attempts to lock a mutex it already holds
- Lock ordering violations—multiple goroutines acquire the same locks in different orders
- Dining philosophers—the classic N-goroutine, N-resource deadlock
- 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
// 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)
}
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.
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:
Lock(), waits for second Lock()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:
Reading the stack:
- Current line:
(*Cache).Getline 15—the secondLock()call - Called from:
(*Cache).GetWithFallbackline 28—which already holds the lock - Same goroutine, same mutex—self-deadlock
Self-deadlock signatures:
sync.(*Mutex).Lockappears 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:
// 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:
Get, Set)Locked (getLocked, setLocked)The *Locked helper pattern (§9.4) exists precisely to prevent this deadlock pattern. Every multi-method type with a mutex should use it.
*Locked Suffix WorksThe suffix creates a visual and semantic contract:
- Public methods acquire locks → call
*Lockedhelpers *Lockedhelpers 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:
// 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:
// Illustrative snippet — not a complete program
registry.Register("startup", func() {
registry.Register("cleanup", cleanupHandler) // DEADLOCK!
})
registry.Notify("startup")
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):
// 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
}
}
“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:
// 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:
// 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
// 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:
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.
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:
sync.Mutex provides exclusive accessLock 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.
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:
// 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:
// 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:
// 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:
// 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
}
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.
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'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:
// 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:
For types with mutexes that may call other locked types:
- Document which external locks each method may acquire
- Establish a global ordering (e.g., “always acquire ServiceA before ServiceB”)
- 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.
// 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
✗ Requires the IDs to be genuinely unique
✗ Addresses differ from run to run, so it is not reproducible
✗ Requires a global design everyone follows
✗ Only works for static locks
What each one looks like in code:
// 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()
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.Mutexembedded 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.
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.
// 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.
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.
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)
// 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:
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.
Lock calls, deadlocked on 10 runs out of 10.
The deadly scenario: All philosophers pick up their left fork simultaneously:
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.
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:
Solution 1: Resource Hierarchy (Numbered Forks)
Always acquire lower-numbered fork first:
// 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:
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:
// 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.
The timeout solution has a serious livelock risk—all philosophers might:
- Grab left fork simultaneously
- Fail to get right fork simultaneously
- Release and retry simultaneously
- Repeat forever (high CPU usage, no progress)
Mitigation: Use randomized exponential backoff (shown above):
// 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:
- Starvation: One philosopher might never succeed while others eat regularly
- CPU waste: Constant lock polling burns CPU even when no work is possible
Use timeouts only for:
- Low-contention scenarios where deadlock is rare
- Graceful degradation where “giving up” is acceptable
- External resources where deterministic ordering isn’t possible
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:
- Waiter pattern (limit concurrency): Allow at most N-1 philosophers to attempt eating simultaneously. With 5 forks and at most 4 diners, at least one philosopher can always acquire both forks (pigeonhole principle). This breaks circular wait indirectly.
These alternative approaches are explored in the exercises at the end of this chapter.
Comparing Solutions
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.
// 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:
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.
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:
The fix: Don’t perform blocking channel operations under mutex:
// 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
}
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.
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:
// 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
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
*Locked helpersif from == to { return }Summary: Mutex Deadlocks
*Locked helpers; no callbacks under lockKey Takeaways
- Self-deadlock is a cycle of length 1—goroutine waits for itself
- Go mutexes are non-reentrant by design—second Lock() on held mutex blocks indefinitely
*Lockedsuffix prevents self-deadlock—helpers assume lock is held- Release before callbacks—external code may re-enter your type
- Lock ordering prevents cross-goroutine cycles—all code must follow same order
- Global ordering must be total and consistent—numeric ID or memory address
- Handle self-transfer and nil cases—ordering code must validate inputs
- Dining philosophers demonstrates all four conditions—classic deadlock example
- Resource hierarchy is the best general solution—deterministic, fair, efficient
- Never perform blocking operations under mutex—channel ops, I/O, external calls
Chapter 9's design patterns directly prevent deadlocks:
- Monitor pattern (9.4): Encapsulation limits lock visibility
*Lockedhelpers (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
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?
// 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
}
}
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.
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=200→100 < 200→ no swap needed → locks A, then B - Goroutine 2:
transfer(B, A, 30)→from.id=200, to.id=100→200 > 100→ swap:first=A, second=B→ locks A, then B
Both follow A→B order. No circular wait possible.
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.
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
// Illustrative snippet — not a complete program
func main() {
ch := make(chan int)
<-ch // Only goroutine, blocked forever
}
Output:
The runtime checks whether each goroutine can make progress:
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:
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
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.
// 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.
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.
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:
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:
// 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)
}
}()
- 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
// Illustrative snippet — not a complete program
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... your application ...
}
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
localhostonly (as shown above) - Or add authentication middleware
- Never expose on
0.0.0.0or public interfaces - Consider environment-based enabling (dev/staging only)
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
// 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
}
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
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.
Real stack traces include runtime internals. Focus on 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
[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 (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:
Same type appears twice → method calling method on same receiver → self-deadlock.
Pattern 2: Lock Ordering Violation
Look for two goroutines with swapped arguments:
Two goroutines in same function with swapped pointer arguments → opposite lock order.
Pattern 3: Channel Circular Dependency
Multiple goroutines blocked on sends → check if they’re waiting for each other to receive.
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:
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
// 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 ...
}
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.
SetBlockProfileRate(1) captures every blocking event—significant overhead in production. Use SetBlockProfileRate(1000000) (events blocking ≥1ms) for lower overhead, or enable only during investigation.
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
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:
Detecting Goroutine Leaks
Partial deadlocks manifest as goroutine leaks—goroutine count grows over time as blocked goroutines accumulate.
Monitoring Goroutine Count
// 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
}
}
}
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.
// 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:
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:
// 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 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:
// 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:
- Detects lock ordering violations
- Reports potential deadlocks before they occur
- Configurable timeout for lock acquisition (default: 30s)—reports if any lock is held longer, useful for catching long critical sections even before true deadlock
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:
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
847 goroutines—suspicious for a service that should have ~50.
Step 2: Get goroutine dump
Step 3: Find blocked goroutines
Step 4: Identify pattern
- G234:
transfer(0x...4000, 0x...4080, ...)— Account A → Account B - G235:
transfer(0x...4080, 0x...4000, ...)— Account B → Account A
Both blocked in sync.Mutex.Lock. Swapped arguments → lock ordering violation.
Step 5: Fix
Apply ID-based lock ordering (§10.5).
Prevention Checklist: Detection
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
Summary: Detection
Key Takeaways
- Runtime detection requires ALL goroutines blocked—background services hide deadlocks
- Partial deadlock = goroutine leak = memory leak—same bug, multiple perspectives
- Stack traces name the primitive—
[sync.Mutex.Lock],[chan send], etc. - Duration in stack trace is critical—5 minutes blocked = almost certainly deadlock
- Pointer values identify objects—same pointer = same mutex
- 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
- pprof endpoints enable remote diagnosis—expose in dev, protect in prod
- Block profiles show contention patterns—find hotspots before they deadlock
- goleak catches leaks in tests—add to TestMain for comprehensive coverage
- Monitor goroutine count in production—alert on sustained growth
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.
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.
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.
[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:
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 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:
- Eliminate shared state (10.5.1)—confinement and immutability
- Reduce lock scope (10.5.2)—single locks and lock-free alternatives
- Lock ordering (10.5.3)—the primary strategy for unavoidable multiple locks
- Avoid blocking under locks (10.5.4)—prevent mixed mutex-channel deadlocks
- Timeouts and non-blocking acquisition (10.5.5)—last resort escape hatch
- The
*Lockedhelper pattern (10.5.6)—prevent self-deadlock through internal calls - 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.
// 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.
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.
- 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.
// 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.
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.
// 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.
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.
// 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
}
atomic.Int64atomic.Boolatomic.Pointer[T]sync.Map\* 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:
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:
a.idunsafe.PointerImplementation: Numeric ID Ordering
// 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
}
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.
Without if from == to, calling Transfer(account, account, 100) results in:
// 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:
// 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
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:
// 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()
}
}
}
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:
- Channel sends/receives (unless buffered and guaranteed not to block)
- Network I/O
- File I/O
- Calls to external code (callbacks, interface methods, other packages)
- Sleep or timer operations
Why it causes deadlock: If a blocking operation waits for another goroutine, and that goroutine needs the mutex, you have circular wait:
Anti-Pattern: Channel Send Under Mutex
// 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
// 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
// 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)
}
}
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”?
Lock()time.Sleepselect without defaultNever 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.
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:
// 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)
}
}
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.
// 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:
// 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:
- Best-effort operations: “Process if unlocked, skip if locked”
- Metrics/monitoring: “Update stats if lock is free, skip if contended”
- Graceful degradation: “Take fast path if unlocked, slow path otherwise”
When TryLock is wrong:
// 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 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
// 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
// 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
// ...
}
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)
// 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:
Fix 1: Establish Lock Ordering (Preferred)
// 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
// 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
// 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
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
// 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:
Decision Framework
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
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
*Lockedif from == to before lockingSummary: Prevention
Prevention strategies by effectiveness:
Key Takeaways
- Lock ordering is the most effective general-purpose prevention—works for any number of locks
- Document ordering prominently—package comments, type comments, function comments
- Self-transfer checks prevent self-deadlock—always validate
from != toand deduplicate - Never block under mutex—I/O, channels, callbacks must be outside critical sections
- Copy-release-call pattern—safe way to call external code from synchronized contexts
- Timeouts work for channels, not mutexes—
selectwithtime.Afteror context - TryLock is not a deadlock solution—it’s opportunistic acquisition, not prevention
- Confinement eliminates deadlock—goroutine-owned state needs no locks
- Immutability eliminates mutual exclusion—copy-on-write for safe sharing
- Test with high contention—stress tests expose timing-dependent deadlocks
- Best: Design to avoid locks (confinement, immutability, channels)
- Good: Single lock per operation (no ordering needed)
- Acceptable: Multiple locks with documented ordering
- Fragile: Timeouts masking coordination bugs
- Wrong: Hoping it won’t deadlock
Choose the highest level your design permits.
Chapter 9's mutex design patterns directly support prevention:
- Monitor pattern: Encapsulation limits lock visibility → simpler ordering
*Lockedhelpers: 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.
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.mu→b.muServiceB.Refresh():b.mu→a.mu
Fixes:
- Establish hierarchy: Always acquire
a.mubeforeb.mu. ChangeServiceB.Refresh()to acquirea.mufirst, or restructure to avoid cross-service calls under lock. - Copy-release-call pattern: Have
ServiceB.Refresh()copy whatever state it needs while holdingb.mu, releaseb.mu, then calla.GetState()without holding any locks.
2. Why is this code still at risk of deadlock despite using lock ordering?
// 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 ...
}
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:
// 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.
Snapshot under lock with deep copy, serialize and write outside lock:
// 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:
all goroutines are asleepclose()Deadlock Type Summary
make(chan T)for-range never exitsdefer close(ch) in sender*Locked helpers (§9.4)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
Tool Quick Reference
SIGQUIT (kills it)go tool pprof .../pprof/goroutinego tool pprof .../pprof/blockgoleak.VerifyNone(t)sync.MutexFor comprehensive pprof usage, see Chapter 19.
Goroutine State Reference
When reading stack traces, these states indicate blocking:
[running][runnable][sync.Mutex.Lock][sync.RWMutex.RLock][sync.RWMutex.Lock][chan send][chan receive][chan receive (nil chan)][select][select (no cases)]select{}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:
- < 1 second — likely normal contention
- 1–30 seconds — investigate; could be a slow operation under lock
- 30 seconds – 5 minutes — very likely a deadlock
- > 5 minutes — treat as a deadlock
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
*Locked helpersPrevention hierarchy (most to least effective):
- Eliminate shared state — Confinement, immutability, message passing
- Reduce lock scope — Single lock, atomics
- Enforce lock ordering — By ID, address, or hierarchy
- Avoid blocking under lock — Copy-release-call pattern
- Use timeouts — Channels only; last resort (masks bugs)
The 10 Commandments of Deadlock Prevention
- Thou shalt eliminate shared state when possible Confinement, immutability, and message passing beat synchronization.
- Thou shalt use the simplest synchronization for the job For deadlock risk: atomics < single lock < multiple locks < channel cycles.
- Thou shalt document lock ordering prominently Package docs, type docs, function docs—make it visible.
- Thou shalt always lock in the same order No exceptions, no “just this once,” no “it’s fine in this case.”
- Thou shalt check for self-reference before locking
if from == to { return }prevents self-deadlock. - Thou shalt never perform blocking operations under lock I/O, channels, callbacks—release first or use non-blocking.
- Thou shalt use
*Lockedhelpers for internal calls Public methods lock,*Lockedhelpers assume held. - Thou shalt release locks before calling external code Copy-release-call: never trust callbacks with locks held.
- Thou shalt close channels in senders, not receivers
defer close(ch)prevents for-range deadlock. - Thou shalt test with high concurrency and goleak Stress tests + goleak catch deadlocks before production.
Production 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
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
*Locked helpers — Directly prevent self-deadlocksChapter Summary
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.
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:
- goleak: https://github.com/uber-go/goleak
- go-deadlock: https://github.com/sasha-s/go-deadlock
syncpackage: https://pkg.go.dev/syncruntime/pprof: https://pkg.go.dev/runtime/pprof
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.
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:
- Check CPU metrics—livelock shows sustained high CPU; deadlock shows low CPU
- Capture goroutine dump—livelock shows
[running]or[runnable]states; deadlock shows[sync.Mutex.Lock]or[chan send/receive] - 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.
Systematic approach:
- Capture comprehensive state: - Goroutine dump with
pprof/goroutine?debug=2- Note blocking states:[sync.Mutex.Lock]= mutex,[chan send/receive]= channel - 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
- Apply appropriate fixes: - Channel:
defer close(ch)in senders, break circular dependencies, initialize channels - Mutex:*Lockedhelpers for self-deadlock, consistent ordering by ID, copy-release-call for callbacks - Consider design-level changes: - Can shared state be eliminated? (confinement) - Can channels replace mutexes? (message passing) - Can multiple locks be combined? (coarse-grained locking)
- Add regression tests: - Use
goleak.VerifyNone(t)in all tests - Add stress tests with high concurrency - Run with-raceflag
[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()callsCache.Lookup(), both try to lockc.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 hasTransfer(0xB, 0xA)
Key indicator: Self-deadlock = one goroutine, same type twice. Ordering = multiple goroutines, swapped arguments.
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:
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
ProcessOrderandValidateOrder.ProcessOrdertooks.muatorder.go:48, then calledValidateOrder, which tries to take the sames.muatorder.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
ProcessBatch→ProcessOrder, 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:
// 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:
// 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
}
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:
// 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/seconddetermine lock orderfrom/todetermine money flow- Both
Transfer(A,B)andTransfer(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):
// 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
}
Confinement-based design:
// 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
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.
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.
// 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)
}
}
// 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:
// 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)
}
}
go test -race ./... in code/ch10/ reports ok for all three tests.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.
labs/go-concurrency/code/ch10/. A worked answer sits in solution/bank.go.txt.Final Checklist
Before moving to Chapter 11, ensure you can:
- Explain the four Coffman conditions and which are easiest to break in Go
- Identify channel vs mutex deadlocks from goroutine dumps
- Apply the
*Lockedhelper pattern to prevent self-deadlock - Implement consistent lock ordering by ID
- Use copy-release-call pattern for callbacks under lock
- Set up goleak in tests to catch goroutine leaks
- Choose between prevention strategies based on the situation
- Diagnose a production deadlock from a goroutine dump, and explain why you would reach for the pprof endpoint rather than SIGQUIT
- Tell a self-deadlocked goroutine from the ones merely queued behind it
- Get
go test -race ./...to pass incode/ch10/(the exercise below)
Further Reading
Primary sources
- The Go Memory Model — the rules that make “happens before” precise. §10.2's channel deadlocks are the flip side of the guarantees defined here.
syncpackage documentation — read theMutexdocs in full, particularly the sentence that a lockedMutexis not associated with a particular goroutine. That single design decision is why Go mutexes are not reentrant, and why §10.3's Pattern 1 exists.runtime/proc.go,checkdead()in the Go source — the actual deadlock detector, and the shortest way to convince yourself why a single sleeping ticker disables it.
The original problems
- E. G. Coffman, M. J. Elphick, A. Shoshani, “System Deadlocks,” ACM Computing Surveys 3(2), 1971 — the four conditions §10.1 is built on, in their original formulation.
- E. W. Dijkstra, “Hierarchical Ordering of Sequential Processes” (EWD310, 1971) — where the dining philosophers first appear, alongside the resource-hierarchy solution used in §10.3.
Tooling
go.uber.org/goleak— goroutine leak detection in tests; the single highest-value item in §10.4.github.com/sasha-s/go-deadlock— a drop-insync.Mutexreplacement that reports lock-order inversions at runtime. Development only; it is far too slow for production.runtime/pprof— goroutine and block profiles, and the non-destructive alternative to SIGQUIT.
Next: Chapter 11 explores atomic operations—lock-free primitives that eliminate entire categories of synchronization bugs, including some deadlock scenarios.
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.