skip to content

In what order does a Go service tear down its subsystems, and who closes each input channel?

level: middleimportance: must knowfreq 64%

answer

  1. shutdown runs the way data runs
  2. front door first, flusher last
  3. one side of a channel owns its close
  4. wait for the senders, then close
  5. release in reverse of construction

basics

~20 s

Tear down in the direction data flows: stop accepting, let each sending goroutine return, close its output channel from that sending side, wait for the receivers to drain it, then release shared resources like a flusher last.

solid answer

~50 s

Shutdown runs front to back, along the data flow. First the entry points stop accepting: the HTTP side stops taking connections and the queue consumer stops fetching. Then each channel is closed by the goroutine that sends on it, and only once that goroutine has returned — so the order is `senders.Wait()`, then `close(items)`, never the other way round and never from the receiving side, because a send into a channel that has already been closed panics. Closing the input is what lets the downstream stage finish rather than abandon work: a worker ranging over the channel drains what is already queued and then returns, whereas one that only watches `ctx.Done()` drops it. After the last stage's `Wait` returns, release the shared dependencies — a log or metrics flusher, a database handle — in reverse of the order you constructed them.

code

go · 24 lines
go
func run(ctx context.Context, flusher io.Closer) error {
	items := make(chan Item)
	var senders, workers sync.WaitGroup

	senders.Add(1)
	go func() {
		defer senders.Done()
		consume(ctx, items) // stops fetching once ctx is done
	}()

	workers.Add(1)
	go func() {
		defer workers.Done()
		for it := range items { // drains, then returns on close
			handle(it)
		}
	}()

	<-ctx.Done()
	senders.Wait() // nothing can send on items any more
	close(items)   // closed by the sending side, not the receiver
	workers.Wait() // everything accepted has been handled
	return flusher.Close()
}

go deeper

for a junior

Know the shape of the sequence: stop taking new work, finish what was accepted, then close the shared things. Be able to say that the goroutine which sends on a channel is the one that closes it.

for a middle

Explain why each step must precede the next: why the join comes before the close, why the close is what lets a receiver drain rather than abandon, and why shared dependencies are released in reverse of construction.

for a senior

Show the failures that come from getting it wrong — a crash from closing a channel out from under a producer, silent loss of accepted work, writes landing on a released handle — and where the ordering should live so it survives a new subsystem being added.

for a principal

Treat the teardown sequence as part of the service's wiring contract: one place owns it, every new subsystem has to declare where it sits, and reviewers ask that question rather than discovering the answer during an incident.

## Shutdown has a direction A typical service `main` wires several subsystems together: an HTTP entry point, a queue consumer, a pool of handlers behind a channel, and a flusher that everything writes into. When the process is asked to stop, these cannot be torn down in an arbitrary order, and "all at once" is an order too — a bad one. Teardown follows the direction data flows, from the front door to the last thing that persists anything. ### 1. Stop accepting The front of the pipeline goes first. The HTTP side stops taking new connections (its own graceful-stop call handles the requests already in flight), and the consumer loop stops fetching new items — usually because its `select` now sees `ctx.Done()` and returns instead of fetching again. This step is what makes the rest finite. If the front door is still open, every later step is chasing a moving target: you wait for the workers, and more work arrives while you wait. ### 2. Let each sender return, then close its output Every internal channel has a **sending side** and a **receiving side**, and the ownership rule is fixed: the channel is closed by the side that sends on it, after that side has stopped sending. Concretely: ``` senders.Wait() // no goroutine can send on items any more close(items) // safe precisely because of the line above ``` Both halves matter. * **Closed by the sender, not the receiver.** A receiver does not know whether a producer is mid-send. Closing from the receiving side, or from `main` "because shutdown started", races with a send that is already under way; the sending goroutine then crashes the process on a channel that vanished under it. * **After the sender returned, not before.** With several producers, the join has to cover all of them; "one of them noticed the cancellation" is not the same as "none of them will send again". If ownership is genuinely tangled — many senders, no single owner — the honest answer is to change the design so one goroutine owns the close, not to add defensive checks; there is no way to test whether a channel is closed before sending. ### 3. Let the downstream drain Closing the input is not merely a formality; it is the difference between finishing accepted work and dropping it. A stage written as ``` for it := range items { handle(it) } ``` keeps handling until the channel is both closed and empty, then returns. A stage written to return the instant `ctx.Done()` fires abandons whatever is still queued in the channel. Both are legitimate designs, but they are different promises, and shutdown ordering is where the difference becomes visible: items your service already accepted are either completed or silently lost. This is also why the cancellation and the close are not redundant. Cancellation stops the *front*; the close is what tells the *back* that the front is finished. ### 4. Release shared resources last The last group is the things that are not in the data flow at all: a metrics or log flusher, a database handle, an open file, an outbound client. Every subsystem writes into them, so nothing can be closed while any subsystem might still be alive. They come off in the reverse of the order you constructed them, which is the same thing as saying: something built first is depended on by things built later, so it dies last. A flusher is the clearest case. It is not downstream of anything by channel, so following the pipeline never reaches it. It is released after the final `Wait` returns, and its own flush is the last useful work the process does. ## The whole sequence 1. Cancel the root context — the single trigger. 2. Entry points stop accepting. 3. For each stage, front to back: wait for its senders, close its output channel, wait for its receivers. 4. Close shared dependencies, reverse of construction. 5. Return from `main`. ## The two mistakes an interviewer is listening for **Closing everything up front.** "Shutdown started, so let me close all the channels and cancel everything" reverses steps 2 and 3 and turns a graceful stop into a crash, because `main` is not the sender on any of those channels. **Releasing before joining.** Closing the flusher or the database handle right after the cancellation, on the assumption that cancellation means stopped. It does not; only the wait means stopped. ## Why the order is worth writing down The ordering is a property of the wiring, not of any one subsystem, so it lives in exactly one place — the function that constructed everything. When someone adds a fifth subsystem, the question they must answer is where it goes in this sequence: what it accepts, who sends into it, and who depends on it. A service where that question has no owner is one where shutdown quietly stops working the next time it grows.

  • Where does a flusher that every subsystem writes into fit in that order?
    Last. It is not downstream of anything by channel, so walking the pipeline never reaches it — you release it after the final `Wait` returns. Ordering shared dependencies is the reverse of construction: built first, closed last. Closing it earlier means a worker's write lands on a closed resource at a moment decided by scheduling.
  • Why close the input channel at all if the workers already watch ctx.Done()?
    Because the two mean different things. A worker that returns on `ctx.Done()` abandons items already sitting in the channel; a worker ranging until close finishes everything the service already accepted. Cancellation stops the front of the pipeline, and the close is the signal that reaches the back — you need both.
  • What if three goroutines all send into the same channel?
    Then no single one may close it. Join all of them first and close it from the coordinating side once the join returns, or restructure so one goroutine owns the send. There is no way to ask whether a channel is already closed, so defensive checks are not an option — the fix is ownership.

Closing a restaurant: stop seating, let the kitchen finish the tickets already on the rail, then turn off the gas. Turning off the gas first does not make anyone leave faster.

saying these in an interview costs you the question

  • Closes an input channel from the receiving side
  • Closes every channel in main as soon as shutdown starts
  • Closes the shared database handle before workers have returned
  • Assumes cancelling the context drains queued items
  • Tears down in construction order rather than reverse
  • Says 'check whether the channel is closed before sending'