AlgoMaster Logo

Done Channel Pattern

Medium Priority15 min readUpdated June 6, 2026

A done channel is a single channel passed to every goroutine that needs to know when to stop. Closing it broadcasts a "shut down now" signal to every listener at once. This is how Go code coordinated cancellation before context.Context existed, and it still works well when you don't need deadlines, values, or call-tree propagation.

What a Done Channel Is

A done channel is just a regular channel that the program uses as a one-way signal. The sender never sends a value on it. Instead, the sender closes the channel, and every goroutine reading from it unblocks at once.

The conventional type is chan struct{}. The empty struct struct{} is a value with zero fields, and it takes zero bytes of memory. The done channel never carries a value, so there's no reason to pay for one. chan struct{} is the Go idiom that says "this channel is for signalling, not for data".

Here's the smallest possible version, a worker that loops until the done channel is closed.

Three details. The worker takes the channel as <-chan struct{} (receive-only), not chan struct{}. This pattern is one of the main reasons channel directions exist. The worker uses select to watch the done channel without blocking on it. And main calls close(done) exactly once. Closing a channel that's already closed panics, and only the goroutine that owns the channel should close it.

The empty struct payload matters because the channel never actually carries data:

We'll explain why close is so much more useful than send in the next section.

Closing as a Broadcast

The trick that makes the done channel pattern work is a specific behavior of closed channels in Go. A receive on a closed channel never blocks, and it always returns the zero value of the channel's element type immediately, no matter how many goroutines try to receive.

That single rule does the work of a broadcast. One call to close(done) unblocks every goroutine parked on <-done. There's no fan-out loop, no per-goroutine signal, no shared boolean to poll. Compare that to trying to broadcast with sends:

This works, but only because we hard-coded "3" in two places. Add a fourth worker and forget to send the fourth signal, and that worker hangs forever. Now the close version:

Same result, but the sender doesn't need to know how many workers exist. Add a fifth or a fiftieth worker and the single close(done) still unblocks all of them. That's the property the pattern depends on.

One coordinator, one closed channel, many workers receiving at once. Each worker's <-done returns immediately with the zero value of struct{}, the goroutine breaks out of its loop, and returns. The coordinator doesn't need to know how many workers existed.

The reason this works is that closing changes the channel's state, not its queue. After close(done), the channel permanently remembers it's closed. Any new receive, including ones from goroutines that haven't yet reached their <-done line, returns immediately. You don't need every worker to be parked at the exact moment of the close. A worker that hits <-done an hour later still gets the signal.

Wiring Done Into Worker Loops

A worker that does real work usually has more than one channel to watch. It pulls jobs from a jobs channel, sends results to a results channel, and watches done to know when to stop. The canonical shape uses select with one case per channel and done always wins when it's closed.

The worker has two cases in its select. When done is closed, the <-done case is always ready, so select may pick it even if jobs also has values waiting. The point of cancellation is to stop, not to drain the queue. The worker also checks ok on the jobs receive. If the producer closes jobs (the normal end-of-work signal), ok becomes false and the worker exits cleanly. So this worker has two exit paths: explicit cancellation via done, or natural drain via close(jobs).

The two paths correspond to two different shutdown stories. close(jobs) means "no more work is coming, finish what you have and exit". close(done) means "stop right now, even if there's work left". Real systems need both.

If you want done to win every time (strictly preempt remaining jobs), nest the select:

The first non-blocking select peeks at done. If it's closed, the worker returns immediately. Otherwise it falls through to the normal blocking select. This shape is less common because most code is fine with "shutdown happens within a couple of iterations", but it's the option when you need strict priority.

Done Across Fan-Out and Fan-In

The done-channel pattern composes naturally with the fan-out and fan-in patterns. A fan-out pool has N workers consuming from one jobs channel. A fan-in merger has N producers all sending into one results channel. Both shapes need a way to stop early, and a single done channel passed to every goroutine does the job.

Here's a fan-out producer that generates orders, a pool of workers that processes them, and a merger that collects results. Every goroutine listens on the same done channel.

Whenever a goroutine wants to send on a channel, wrap the send in a select with a <-done case. Without that, a goroutine trying to send into a full or unbuffered channel can block forever even after done is closed.

If the consumer is still reading, the send goes through. If the consumer has gone away (which is usually why done was closed in the first place), the receive on done fires instead and the goroutine returns. A range loop over an input channel exits naturally when the channel is closed, so it doesn't need the done guard for the receive itself. It needs the done guard when it tries to forward the received value somewhere else, which is what the merger does above.

The cleanup story at the end of main is also worth tracing. close(done) tells every goroutine in the pipeline to stop. The merger's WaitGroup waits for each merge goroutine to exit, then closes out, which lets the for range results loop in main exit. Every goroutine returns, every channel is closed, no leaks.

Without Done: The Leak Version

It's worth seeing what happens when you skip the done channel. The channel-based leak is the version this pattern was invented to prevent.

Imagine a worker that reads jobs and sends results, but with no done channel and no other exit signal:

Three workers are still alive after main has lost interest. Two of them are blocked on results <- job * 2 waiting for a receiver that will never come back. The third might be blocked on <-jobs or also stuck on the send. Either way, none of them will ever exit. The producer goroutine is also blocked on jobs <- i because nobody is reading. Four leaked goroutines for the price of one early-return.

Now the corrected version with a done channel:

After close(done) runs, every goroutine watching it returns. The WaitGroup waits for the workers to actually exit, so by the time main prints the goroutine count, only main is left. In production code, this close(done) is often a defer at the top of the coordinator function, which fires on every exit path including a panic.

Any channel send or receive inside a goroutine you don't fully control needs a done case in its `select`. Otherwise the goroutine can block on the channel operation indefinitely, even after the rest of the program has decided to give up.

Lifetimes, Cleanup, and Who Closes Done

The done channel pattern has a small but important rule: only the coordinator closes done. The coordinator is whoever owns the lifetime of the worker goroutines, usually the function that started them. Workers receive from done, they never send to it and never close it.

This matters because of two channel rules. First, closing an already-closed channel panics. Second, only one goroutine should close a channel, by convention, so it's clear who has the authority. Both rules together mean: pass done to workers as the receive-only type <-chan struct{} so the type system enforces "you can't close this from here".

The lifecycle has three phases. Setup: the coordinator creates the done channel and a WaitGroup, then starts workers that receive done as <-chan struct{}. Run: workers loop, watching done in their select. Shutdown: the coordinator calls close(done) exactly once, every worker's <-done unblocks, and the coordinator calls wg.Wait() to confirm every worker has actually exited.

The defer pool.Close() guarantees the workers get the shutdown signal no matter how main exits, including a panic. The WaitGroup inside Close does the second important thing: by the time Close returns, every worker has actually exited. Without wg.Wait(), close(done) would tell the workers to stop, but the caller might move on before they actually do. For libraries or long-running coordinators that shut down one subsystem at a time, the synchronous wait is what you want.

A subtle point: never close a channel from a worker goroutine. If a worker hits an error and wants to bring the whole pool down, it should signal the coordinator (with a separate error channel or a callback), and the coordinator decides whether to close done. Otherwise you have two goroutines that both think they're allowed to close the same channel, which means a guaranteed panic on the second close.

Bridge to context.Context

The done channel pattern is the foundation, but production Go code rarely uses raw done channels anymore. The standard library's context package generalizes the same idea and adds three things on top: deadlines, request-scoped values, and propagation through call trees.

A context.Context exposes a method Done() <-chan struct{}. That's literally a done channel, returned by an interface method. So everything you learned in this chapter applies directly to contexts: you watch ctx.Done() in your select, you check ctx.Err() to find out why it fired, and the parent decides when to cancel.

Here's the same worker rewritten with context:

The shape is almost identical to the done-channel version. cancel() plays the role of close(done), ctx.Done() plays the role of the done channel, and ctx.Err() tells you why cancellation happened (manual cancel, deadline exceeded, parent cancelled). Contexts can also be derived from other contexts, so cancelling a parent automatically cancels every child.

What contexts add over plain done channels:

FeatureDone channelcontext.Context
Broadcast cancellationYes (via close)Yes (via cancel)
Wait without blockingYes (via select)Yes (select on ctx.Done())
Deadlines and timeoutsNo, build it yourselfWithTimeout, WithDeadline
Reason for cancellationNo, just "closed"ctx.Err() returns Canceled or DeadlineExceeded
Tree propagationNo, pass to every goroutine manuallyAutomatic via derived contexts
Standard library supportNoEvery blocking std lib call accepts a context

The done channel doesn't disappear; it just gets wrapped inside the context.

The rule of thumb: use a raw done channel when the lifetime is local to one function and the type signatures stay clean. Use context.Context when cancellation crosses function boundaries, when you need a deadline, or when you're calling into anything in the standard library that takes a ctx context.Context first argument.

Quiz

Done Channel Quiz

10 quizzes