skip to content

Why is holding a sync.Mutex while sending on a channel a deadlock risk in Go?

level: seniorimportance: should knowfreq 52%

answer

  1. parking does not release anything
  2. who drains that channel?
  3. the consumer wants the same lock
  4. defer holds it to the end of the function
  5. unlock before you send

basics

~20 s

A blocked channel send does not release the mutex; Go never drops a lock when a goroutine parks. If the consumer that would drain the channel must first take that same mutex, each side waits for the other and neither moves again.

solid answer

~50 s

Parking is not releasing. When `out <- e` blocks because the channel is unbuffered or full, the goroutine is taken off the run queue with `mu` still held — Go has no notion of dropping a lock while you wait. Now look at the consumer: if the goroutine that would receive from that channel has to take the same mutex first, to read state or record a sequence number, it parks on the lock, so the send never completes, so the lock is never released. `defer mu.Unlock()` makes this easy to write by accident, because it silently extends the critical section to the end of the function including any blocking call added later. The fix is to shrink the critical section: lock, mutate or copy what you need, unlock, and only then send — and make the send a `select` with a `<-ctx.Done()` case so a stalled consumer produces an error rather than a permanent block. Buffering only raises the load at which it happens.

code

go · 19 lines
go
type store struct {
	mu  sync.Mutex
	seq int
	out chan Event
}

func (s *store) Publish(e Event) {
	s.mu.Lock()
	defer s.mu.Unlock() // held across the send below
	s.seq++
	e.Seq = s.seq
	s.out <- e // parks here with s.mu still locked
}

func (s *store) Seq() int {
	s.mu.Lock() // the consumer calls this and never gets in
	defer s.mu.Unlock()
	return s.seq
}

go deeper

for a junior

Remember the one fact this rests on: a goroutine that blocks on a channel keeps any mutex it already holds. Lock, change the shared state, unlock, then send.

for a middle

Explain the cycle in both directions — the producer waits for a receiver, the receiver waits for the lock — and why defer mu.Unlock() quietly stretches the critical section over any blocking call added later in the function.

for a senior

Show that you expect it to appear only under load, and that nothing in the runtime or the toolchain will flag it. Give the review heuristic and the bounded-send rewrite, and say what policy you chose for a full channel.

for a principal

Decide the standard your codebase enforces: whether shared state is guarded by mutexes at all or owned by a single goroutine, and what a package's exported methods promise about blocking, since a library method that blocks under its own lock exports a deadlock to every caller.

## The mechanism in one sentence A goroutine that blocks on a channel operation keeps everything it holds. `sync.Mutex` has no notion of releasing on wait, no lock-with-timeout, and no reentrancy; the lock stays owned by a goroutine that is parked and running nothing. Combine that with a channel operation whose counterpart needs the same lock and you have a two-party cycle where one edge is a lock and the other is a channel. ## The shape ``` func (s *store) Publish(e Event) { s.mu.Lock() defer s.mu.Unlock() // held to the end of the function s.seq++ e.Seq = s.seq s.out <- e // parks here, still holding s.mu } func (s *store) Seq() int { s.mu.Lock() // the consumer calls this and never gets it defer s.mu.Unlock() return s.seq } ``` The consumer goroutine receives from `s.out` in a loop and calls `s.Seq()` for each event. The moment the channel is full (or is unbuffered and the consumer happens to be inside `Seq`), the producer parks on the send holding `s.mu`, and the consumer parks on `s.mu` inside `Seq`. Neither will ever move. ## Why it is so easy to write Three things conspire: 1. **`defer mu.Unlock()` is the recommended idiom** — and it should be, because it survives early returns and panics. But it also means the critical section is the whole rest of the function. When someone later adds a send, a `select`, a network call or a `Wait` at the bottom of that function, they have extended the lock across a potentially unbounded wait without touching the locking code at all. 2. **The consumer's dependency on the lock is usually indirect** — it is not `mu.Lock()` in the receive loop, it is a call to an accessor three frames down that happens to take the same mutex. 3. **It is load-dependent.** With a buffered channel and a fast consumer, the send never blocks and the code is correct for months. It wedges the first time the consumer is slow, which is precisely when the system is under stress. ## Why nothing rescues you There is no lock-wait timeout in `sync.Mutex` and no cycle detection anywhere in the Go runtime or toolchain. The process is not idle either: the HTTP listener keeps accepting, health checks keep passing, other goroutines keep running. So the failure presents as "one feature stopped working" plus a slowly growing goroutine count, not as a crash. `go vet` will not see it, and the race detector answers a different question entirely — it finds unsynchronised accesses that actually executed, not blocking cycles. ## The fixes, in order of preference **Shrink the critical section.** Take the lock, do the small piece of shared-state work, release it, then perform the blocking operation. Written out, this means giving up `defer` for that function or splitting it in two: a small locked helper that returns what you need, and an unlocked body that sends. The rule that generalises: *never perform an operation with an unbounded wait while holding a lock* — a channel send or receive, a `select` without `default`, a `Wait`, a network round trip, or a call into code you do not control. **Bound the wait.** Replace a naked send with a `select` over the send and `<-ctx.Done()`, or a `default` branch that drops or applies backpressure to the caller. This turns a permanent block into a policy decision you have to name: drop the event, block the caller, or fail the request. **Change the ownership model.** If the state and the channel always travel together, consider giving the state to a single owner goroutine and letting other goroutines talk to it over a channel, so there is no mutex to hold. This is the "share by communicating" shape, and it removes the lock edge of the cycle rather than shortening it. **What is not a fix:** enlarging the buffer. It converts a deterministic deadlock into one that needs a slow consumer to appear — a strictly worse bug, because it will now happen in production and not in your test. It is fine to buffer for throughput; it is not a correctness argument. ## Reviewing for it The review heuristic is mechanical enough to apply quickly: find every `Lock()`, then read forward to the matching `Unlock()` (with `defer`, that is the end of the function) and look for anything that can block. If you find one, ask which goroutine completes it and whether that goroutine can reach the same lock. Most of the time the answer takes ten seconds, and when it does not, that is exactly the code worth arguing about.

  • Does giving the channel a large buffer remove the deadlock?
    No, it moves it. The cycle needs the send to block, and a buffered send blocks once the buffer is full — so a big buffer means the code is correct until the consumer falls behind, which is exactly when the system is loaded. Buffer for throughput if you have measured it; never cite it as a correctness argument.
  • How would you catch this in a code review?
    Find every `Lock()` and read to its `Unlock()` — with `defer`, that is the whole rest of the function — then look for anything with an unbounded wait inside that span: a channel send or receive, a `select` without `default`, a `Wait`, an HTTP call, or a callback into code you do not own. For each one, name the goroutine that completes it and check whether it needs the same lock.
  • Does adding a default case to the send fix it?
    It removes the block, but it forces you to define a policy: with `default` the event is dropped when the consumer is behind, which may be fine for metrics and unacceptable for a ledger. A `select` over the send and `<-ctx.Done()` is usually better — the caller learns it failed and can retry or shed load, and no lock is held either way.

It is like holding the only key to the storeroom while queuing at a counter that is staffed by the one person who needs that key to serve you.

saying these in an interview costs you the question

  • Thinks a parked goroutine releases its mutex
  • Says buffering the channel removes the deadlock
  • Writes defer mu.Unlock() and then adds a blocking call later in the function
  • Expects the runtime to detect and break the cycle
  • Blames the slow consumer rather than the width of the critical section