skip to content

Deadlock Shapes

The stalls Go actually ships: an unbuffered send nobody receives, a WaitGroup whose Done is skipped on an error path, a goroutine blocking on a channel while it holds a Mutex.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why does one goroutine running `ch := make(chan int); ch <- 1; v := <-ch` block forever on the send?

level: juniorimportance: must knowfreq 72%

answer

  1. zero capacity stores nothing
  2. a send needs a receiver right now
  3. who is left to run the next line?
  4. a parked goroutine executes no statements
  5. one slot of capacity, or a second goroutine

basics

~20 s

An unbuffered channel holds nothing, so a send blocks until another goroutine is ready to receive. The only receive here is the next line of the same goroutine, which cannot run until the send finishes.

solid answer

~50 s

`make(chan int)` with no size gives a channel of zero capacity, which is a rendezvous rather than a queue: the value is handed straight from a sender to a receiver, so `ch <- 1` parks the goroutine until some other goroutine executes a receive. In this code the only receive is the following statement in the same goroutine, and a parked goroutine executes no statements, so the send is waiting for something it is itself blocking. It is not a scheduling accident — no interleaving completes it. Two fixes: start a real second goroutine that receives (or sends), or give the channel a slot with `make(chan int, 1)` so the send has somewhere to put the value and the later receive drains it. The shape to recognise is a blocking channel operation whose counterpart is unreachable from any goroutine that can still run.

code

go · 7 lines
go
ch := make(chan int)
go func() { ch <- 1 }() // a real second goroutine sends
fmt.Println(<-ch)       // prints 1

buf := make(chan int, 1)
buf <- 1                // fits in the one free slot, no receiver needed
fmt.Println(<-buf)      // prints 1

go deeper

for a junior

Be ready to say it out loud in one sentence: an unbuffered channel has no storage, so a send waits for a receiver, and here the receiver is the same goroutine. Then give both fixes without being asked.

for a middle

Explain the rendezvous mechanically — the sender parks on the channel until a receiver arrives and the value is copied straight across — and say precisely what a buffer of n changes and what it does not.

for a senior

Show that you recognise the shape away from the toy program: a result sent on an unbuffered channel to a caller that took an early error return leaks a goroutine silently. Talk about naming the counterpart on every path, including error paths.

for a principal

Frame it as an API rule for code other teams call: a package that hands out a channel must document who sends, who receives and who closes, because a caller that returns early turns your unbuffered send into a permanent leak inside their process.

## What an unbuffered channel promises `make(chan int)` — with no second argument — creates a channel of capacity zero. A channel with zero capacity has nowhere to put a value, so a send is not "drop the value into the channel and carry on"; it is "hand this value to a receiver, now". The runtime implements it as a rendezvous: the sending goroutine is parked on the channel's queue of waiting senders until another goroutine arrives at a receive on the same channel, at which point the value is copied directly from the sender to the receiver and both goroutines become runnable again. The mirror image holds too — a receive on an unbuffered channel parks until some sender arrives. "Parked" is the important word. A parked goroutine is not spinning, not polling, and not partially executing: it has been taken off the run queue and executes nothing at all until whatever it waits on happens. ## Why this particular program can never finish ``` ch := make(chan int) ch <- 1 // parks here v := <-ch // the only receive in the program ``` The send parks waiting for a receive. The one receive that exists is the next statement of the same goroutine — and that statement can only run after the send returns. So the send waits for a receive that is queued behind the send. There is no interleaving of any schedule that finishes this program, no number of cores that helps, and no yield that unsticks it. This is why it is worth learning as a *shape* rather than as a bug in one program: a blocking channel operation is only safe when its counterpart is reachable from a **different** goroutine that is able to run. ## The two fixes, and what each actually changes **Start a real second goroutine.** `go func() { ch <- 1 }()` followed by `v := <-ch` in the original goroutine gives the rendezvous two participants. The `go` statement returns immediately; the new goroutine parks on its send; the parent reaches the receive; the runtime pairs them and copies the value. This is the idiomatic form when the two ends are genuinely different pieces of work. **Give the channel capacity.** `make(chan int, 1)` creates a buffered channel with one free slot. A send into a channel with a free slot completes without any receiver, so `ch <- 1` returns immediately and the later `<-ch` takes the value out. Buffering is not a general cure, though: it is sized to a *known* number of outstanding sends. Send twice into a channel of capacity one before receiving and the second send parks exactly as before. Choosing a buffer because "it seemed to unstick it" turns a deterministic hang into one that appears only under load. ## The same shape, in code that looks less silly The one-goroutine version is the teaching case; production versions of the same shape are: - a helper that computes a result and sends it on an unbuffered channel, called by a function that takes an early error return and never receives — the helper goroutine parks forever, holding everything it captured; - a handler that sends work to a worker goroutine that has already returned; - a shutdown path that sends a "stop" signal on an unbuffered channel to a goroutine that is itself blocked sending a result back. Each is the same sentence: *the counterpart is not reachable*. ## What does not help - **`close(ch)`** does not release a blocked sender. Closing tells receivers there will be no more values; a send on a closed channel **panics**, and a close that races with a send is a bug in its own right. Closing is the sender's job, not the receiver's. - **`time.Sleep`** changes timing, never reachability. If the counterpart cannot run, waiting longer does nothing. - **`runtime.Gosched()`** yields the processor, but the receive is still queued behind the send in the same goroutine. - **Raising `GOMAXPROCS`** adds parallelism, not participants. ## The checklist to carry away For every blocking channel operation, name the goroutine that performs the matching operation and confirm it can reach it on **every** path, including the error paths. If you cannot name it, you have written a permanent block, and only its position in the program decides whether that shows up as a wedged program in a test or as one quietly leaked goroutine in production.

  • Does giving the channel a buffer of one fix this in general?
    Only for a known number of outstanding sends. `make(chan int, 1)` lets exactly one send complete without a receiver; a second send before any receive parks just as the unbuffered one did. Sizing a buffer to real work is fine, but adding capacity because a hang went away converts a deterministic failure into one that reappears under load.
  • What happens if the goroutine that was supposed to receive takes an early error return instead?
    The sender stays parked for the life of the process. Nothing crashes and nothing logs; you simply have a goroutine that will never run again, still holding the memory it captured. Repeated per request, that is a slow leak. Either size the channel so the send cannot block, or make the send a `select` with a `<-ctx.Done()` case.
  • Would closing the channel from another goroutine unblock the pending send?
    No — it makes it worse. A send on a closed channel panics, so closing while a sender is parked crashes the program rather than releasing it. `close` is a message to receivers: further receives return the zero value with `ok` false. By convention the sending side closes, and only when it knows no more sends will happen.

An unbuffered channel is a baton handoff, not a drop box. You cannot hand the baton to yourself: someone else has to have their hand out at the same moment.

saying these in an interview costs you the question

  • Says the send returns immediately and the value waits inside the channel
  • Thinks make(chan int) has a default buffer of one
  • Expects the runtime to run the next line while the send is parked
  • Says closing the channel would let the blocked send through
  • Adds a time.Sleep or runtime.Gosched instead of a receiver
open as a page

Why does sync.WaitGroup.Wait hang forever when a worker goroutine returns early on an error?

level: middleimportance: must knowfreq 66%

basics

~20 s

A WaitGroup is only a counter: Add raises it, Done lowers it, Wait blocks until it reaches zero. A worker that returns before reaching its Done leaves the counter above zero, so Wait never unblocks. Put Done in a defer.

open as a page

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

level: seniorimportance: should knowfreq 52%

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.

open as a page

Two goroutines lock two account mutexes in opposite order and wedge — what do you capture from the live process?

level: seniorimportance: nice to knowfreq 42%

basics

~10 s

Capture full goroutine stacks before restarting: /debug/pprof/goroutine?debug=2 if net/http/pprof is registered, or GOTRACEBACK=all plus SIGQUIT. Two goroutines parked in sync.(*Mutex).Lock inside mirrored transfer calls are the cycle.

open as a page