open atlas
↑ Back to track
Go, zero to senior GO · 10 · 04

Deadlock postmortems: lock-order inversion, channel-under-mutex, and reading the goroutine dump

Three deadlock shapes: lock-order inversion, channel send under a mutex, WaitGroup.Add racing Wait. The runtime panic fires only when every goroutine blocks; partial deadlocks show up as stack clusters in the goroutine profile. go test -race finds the racy cousins.

GO Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The payments service did not crash. That was the problem. At 11:34 the /charge and /refund endpoints started timing out while /health returned 200 — the health check touched neither of the two mutexes that had just locked each other. Kubernetes saw a healthy pod and kept routing traffic into it. By 11:50 the goroutine dump told the whole story in two numbers: goroutine 412 held the account lock and waited 16 minutes for the ledger lock; goroutine 9817 held the ledger lock and waited 16 minutes for the account lock. Behind those two, a cluster of 9,412 goroutines parked in sync.Mutex.Lock, one per stuck request. No panic, no log line, no restart — the famous “all goroutines are asleep” error never fired because thousands of other goroutines were happily serving health checks. Forty minutes of failed payments, ended by a manual rollout. The dump had been one curl away the entire time.

Shape A: lock-order inversion

The Hook’s shape, and the classic. Two locks, two code paths, opposite orders:

func Transfer(from, to *Account) { // path 1: from → to
	from.mu.Lock()
	defer from.mu.Unlock()
	to.mu.Lock() // goroutine 412 waits here, holding from.mu
	defer to.mu.Unlock()
	// ...
}

func Audit(a, b *Account) { // path 2 locks the same pair in reverse
	b.mu.Lock()
	a.mu.Lock() // goroutine 9817 waits here, holding b.mu
	// ...
}

Each goroutine holds what the other needs: a cycle of length two, frozen forever — sync.Mutex has no timeout and no owner tracking. The window is tiny (both must be mid-acquisition in the same microseconds), which is why this ships: it survives code review, tests, and months of production before the interleaving lands. The fix is not cleverness, it is convention: define one global lock order and acquire in that order on every path. For dynamic pairs like accounts, order by an intrinsic key — if from.ID > to.ID { from, to = to, from } before locking — and write the convention down where the next engineer will trip over it.

Shape B: a channel operation under a mutex

func (s *Store) Flush() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.events <- snapshot(s.data) // send while holding s.mu — the receiver...
}

func (s *Store) consume() {
	for ev := range s.events {
		s.mu.Lock() // ...needs s.mu before it can finish receiving the NEXT one
		apply(ev)
		s.mu.Unlock()
	}
}

Flush sends while holding the lock; the consumer needs that same lock to process. One buffered slot of slack and it works in every demo — until the buffer is full at the same moment the consumer is between receive and lock. Then Flush waits for the consumer, the consumer waits for the mutex, cycle closed. The rule generalizes beyond channels: never perform a blocking operation — channel send or receive, I/O, RPC, Wait — while holding a mutex. A mutex is for nanosecond-scale memory edits; anything that can park the goroutine turns the critical section into a wait-graph node other goroutines may already depend on. Copy the data under the lock, release, then send.

Quiz

Two goroutines deadlock on a pair of mutexes inside a busy HTTP service. What does the process actually do?

Shape C: WaitGroup misuse

The third postmortem is subtler because it is a race that sometimes wears a deadlock costume:

var wg sync.WaitGroup
for _, job := range jobs {
	go func() {
		wg.Add(1) // WRONG: races with wg.Wait below
		defer wg.Done()
		process(job)
	}()
}
wg.Wait() // may observe counter == 0 before any Add ran

Add must happen before Wait can observe the counter — which means before the go statement, in the spawning goroutine. Written as above, the scheduler decides the outcome: if Wait runs before the children get scheduled, it sees zero and returns — work silently lost, or worse, main exits and the children are reaped mid-write. The mirror image deadlocks instead: a child calls wg.Wait() (or sends to an unbuffered channel read after Wait) while the parent holds something the child needs. Related classics in the same family: passing a WaitGroup by value (the copy’s Done never reaches the original — Wait blocks forever) and reusing a WaitGroup for a second wave before the first Wait returned. go vet catches the copy; -race reliably flags Add-racing-Wait.

The detection toolbox

The runtime’s famous fatal error: all goroutines are asleep - deadlock! is nearly useless in services: it fires only when every goroutine is blocked on runtime-visible operations — one live accept loop or ticker suppresses it. Production deadlocks are partial, and the instrument is the goroutine profile:

import _ "net/http/pprof"

// live dump with stacks and wait durations:
//   curl localhost:6060/debug/pprof/goroutine?debug=2
//
// goroutine 412 [sync.Mutex.Lock, 16 minutes]:   ← held-for duration = smoking gun
//   main.Transfer ... transfer.go:42
// goroutine 9817 [sync.Mutex.Lock, 16 minutes]:
//   main.Audit ... audit.go:31
// 9412 @ ... sync.Mutex.Lock main.(*Handler).Charge   ← the victim cluster

Read it like a detective, in three passes. First, cluster sizes (debug=1 groups identical stacks): 9,412 goroutines on one Lock line is the victim pile and names the contested lock. Second, wait durations (debug=2): minutes-long semacquire or chan send waits separate a deadlock from mere contention, which churns. Third, find the holders: the one or two goroutines blocked on a different lock than the cluster — they hold what the cluster wants and want what each other holds; their two stack traces are your cycle, file and line. Complete the kit with go test -race for the racy cousins (Shape C), go vet for WaitGroup copies, and the mutex profile (runtime.SetMutexProfileFraction + /debug/pprof/mutex) to watch contention trend toward the cliff before you fall off it. Together, these three passes turn a mysterious timeout into a file and line number in under ten minutes — without restarting the process or losing the evidence.

Quiz

In a goroutine dump you see 9,412 goroutines parked on Charge at mu.Lock, plus goroutine A holding mu and blocked 14 minutes on ledger.Lock, and goroutine B holding ledger and blocked 14 minutes on mu. What is the diagnosis?

Why this works

Why does the runtime not detect partial deadlocks, when it clearly has all the information? Because in general it does not have it: a mutex in Go records no owner, so the runtime sees “goroutine parked on semaphore” without knowing who would release it — building the wait-for graph would mean tracking ownership on every Lock/Unlock, a cost imposed on all programs to diagnose the broken few. The all-asleep check is free precisely because it needs no graph: if literally nothing can run, no future Unlock can exist. Everything subtler is delegated to the dump — which contains the graph implicitly, in the stacks, for a human (or a script) willing to read it.

Order the steps

Order the three passes for reading a goroutine dump to diagnose a partial deadlock:

  1. 1 Pass 1 — cluster sizes (debug=1): find the huge goroutine cluster parked on one Lock line — it names the contested mutex but is not the bug itself
  2. 2 Pass 2 — wait durations (debug=2): look for minute-long semacquire or chan send waits — these separate a true deadlock from heavy contention, which churns
  3. 3 Pass 3 — find the holders: locate the one or two goroutines blocked on a different lock than the crowd — they hold what the cluster wants and are waiting on each other, giving you the cycle with file and line

Prevention: boring rules that survive 3 a.m.

Every rule below is one of the three postmortems, inverted. Keep critical sections tiny — lock, touch memory, unlock; compute and I/O happen outside. Never block under a mutex: no channel ops, no RPC, no Wait — copy under the lock, act after release. One documented lock order per package (or an intrinsic-key ordering for dynamic pairs), enforced in review because no tool enforces it for you. Add before go, never inside the child; pass WaitGroups by pointer; run -race in CI always — it catches Shape C and a hundred relatives. And keep net/http/pprof wired in production before the incident: a deadlock you can dump in one curl is a 5-minute diagnosis, and the same dump taken after the restart shows nothing at all.

Recall before you leave
  1. 01
    Walk through reading a goroutine dump for a suspected partial deadlock: the three passes and what each one proves.
  2. 02
    Name the three deadlock shapes from the postmortems and the prevention rule each one implies.
Recap

The payments outage is the template: a partial deadlock does not crash, does not log, and keeps passing health checks while a victim cluster grows behind the frozen locks. Shape A is lock-order inversion — two paths acquiring the same mutexes in opposite orders, a two-node wait cycle that ships because the race window is microseconds wide; the fix is a documented global acquisition order, with intrinsic-key ordering for dynamic pairs like account pairs. Shape B is blocking under a mutex — a channel send held open while the receiver needs that same lock, working in every demo until the buffer fills at the wrong instant; the rule is that a mutex guards nanosecond memory edits and nothing that can park: copy under the lock, release, then send. Shape C is WaitGroup misuse — Add inside the child races Wait into returning early and losing work, by-value copies block Wait forever; Add before go, pass by pointer, and -race plus vet catch what review misses. Detection starts from an honest fact: the all-goroutines-asleep panic requires the entire process blocked, so it never fires in a server with a live accept loop. The instrument is the goroutine profile via net/http/pprof, read in three passes — cluster sizes name the contested lock, minutes-long wait durations separate deadlock from contention, and the holders blocked on each other are the cycle, file and line included. The mutex profile shows contention trending toward the cliff. Prevention is boring on purpose: tiny critical sections, no blocking ops under locks, one lock order, -race in CI, and pprof wired before the incident — because the dump taken after the restart shows nothing. Now when you see a service that is healthy but not responding, your first move is one curl to the goroutine profile — the cycle will be in the two stacks with minute-long wait durations, not in the crowd.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.