skip to content

What must a Go type's Close do so callers know its background goroutine has exited?

level: middleimportance: should knowfreq 58%

answer

  1. two signals, opposite directions
  2. cancel returns before the goroutine does
  3. defer close it on the way out
  4. signal first, then receive from stopped
  5. sync.Once makes a second Close harmless

basics

~20 s

Close must signal and then join. It cancels the context or closes the done channel the goroutine watches, then blocks until the goroutine confirms it returned, usually by receiving from a channel the goroutine closes in a defer.

solid answer

~40 s

Cancelling only *asks* the goroutine to stop; when `cancel()` returns, the goroutine may still be running. So a `Close` that merely signals gives the caller no safe point at which to release the file, connection or buffer the goroutine still touches. The fix is a second, exit-side signal: the goroutine does `defer close(w.stopped)` as the very first statement of its body, and `Close` does `w.cancel()` followed by `<-w.stopped`. A `sync.WaitGroup` (or an `errgroup`) works the same way when the type starts several goroutines — `Close` signals, then `wg.Wait()`. Wrap the body in `sync.Once` so a second `Close` is a safe no-op rather than a second receive or a double close, and say in the doc comment that Close blocks until the goroutine has returned.

code

go · 23 lines
go
type Watcher struct {
	cancel  context.CancelFunc
	stopped chan struct{}
	once    sync.Once
}

func New(parent context.Context) *Watcher {
	ctx, cancel := context.WithCancel(parent)
	w := &Watcher{cancel: cancel, stopped: make(chan struct{})}
	go func() {
		defer close(w.stopped) // runs on every exit path
		w.run(ctx)
	}()
	return w
}

func (w *Watcher) Close() error {
	w.once.Do(func() {
		w.cancel()
		<-w.stopped
	})
	return nil
}

go deeper

for a junior

Remember that a type owning a background goroutine needs a Close, and that Close does two things, not one: it asks the goroutine to stop and then waits for it to actually be gone.

for a middle

Be able to write the pattern from scratch: cancel func plus a stopped channel closed by a defer inside the goroutine, waited on by Close, wrapped in sync.Once. Explain why the order of cancel and wait matters.

for a senior

Show that you know a joining Close can block, and that every blocking operation inside the goroutine must therefore be selectable against the stop signal. Talk about how you would test the guarantee without sleeps.

for a principal

Decide what the exported Close is allowed to promise across your codebase, and whether a blocking Close or a Shutdown that takes a deadline is the right default for the teams that import it.

## Two signals, opposite directions A background goroutine owned by a type needs two channels of information, running in opposite directions: 1. **Stop request**, owner to goroutine: a cancelled `context.Context` or a closed `done chan struct{}`. 2. **Exit confirmation**, goroutine to owner: a `stopped chan struct{}` the goroutine closes when it returns, or a `sync.WaitGroup` the owner waits on. Most buggy shutdown code has only the first. `cancel()` returns instantly — all it does is close a channel and mark the context — so the code after it runs concurrently with a goroutine that has not yet noticed. Anything that assumes quiescence after `Close` is then racing: closing the file the goroutine writes to, returning a buffer to a pool it still reads, ending a test whose goroutine still logs (which in Go's testing package means a log call on a finished test), or asserting that the goroutine count is back to its baseline. ## The canonical shape ```go type Watcher struct { cancel context.CancelFunc stopped chan struct{} once sync.Once } func New(parent context.Context) *Watcher { ctx, cancel := context.WithCancel(parent) w := &Watcher{cancel: cancel, stopped: make(chan struct{})} go func() { defer close(w.stopped) w.run(ctx) }() return w } func (w *Watcher) Close() error { w.once.Do(func() { w.cancel() <-w.stopped }) return nil } ``` Four things are load-bearing here. **`defer close(w.stopped)` is the first statement of the goroutine.** Because it is deferred, it runs on every exit path, including a panic that unwinds the goroutine, so `Close` cannot be left hanging by an error return you forgot about. **The goroutine closes `stopped`; the owner only receives from it.** That keeps the sender-side convention intact and means any number of watchers can wait for exit, not just one. **`w.cancel()` comes before `<-w.stopped`.** Reversing them is an instant deadlock: you would wait for a goroutine you have not yet asked to stop. **`sync.Once` makes `Close` idempotent.** Callers close in a `defer` and again in an error path; a second `<-w.stopped` on an already-closed channel would actually be harmless here, but the Once also protects the general case where Close does real teardown, and it gives concurrent callers the right behaviour: `Do` blocks the second caller until the first has finished, so every `Close` returns only after the goroutine is gone. ## When there are several goroutines Swap the single `stopped` channel for a `sync.WaitGroup` held by the type: each goroutine is registered before it starts, marks itself done via `defer` as it returns, and `Close` signals then waits on the group. If the goroutines can fail, an `errgroup.Group` gives you the same join plus the first error back out of `Close` — the joining is the part that matters for the ownership contract; which of the two you use is a question of whether you want an error. ## What Close should promise, and say Write the guarantee in the doc comment, because it is what callers will rely on: *"Close stops the watcher and blocks until its background goroutine has returned. It is safe to call Close more than once."* Once that sentence exists, the caller can legitimately write `defer w.Close()` and then tear down everything the watcher touched on the next line. The test that proves it is a goroutine-count assertion around the pair: record the count, call `New`, do some work, call `Close`, and assert the count is back where it started (allowing a moment for the scheduler, or better, relying on the fact that a joining Close needs no sleep at all — that is the whole point of joining). ## The cost you are taking on A joining `Close` is strictly stronger, but it can now block, and anything that can block can deadlock. If the goroutine can park somewhere the stop signal does not reach — a send on a channel the caller has stopped draining, a lock it cannot get — then `Close` inherits that hang. That is the price of the guarantee, and it is why the goroutine's every blocking operation must also be selectable against the stop signal.

  • Why is defer close(stopped) written as the goroutine's first statement rather than at the end of its body?
    So it runs on every exit path. A plain `close(w.stopped)` at the bottom is skipped by an early `return` on error and by a panic unwinding the goroutine, and in both cases `Close` blocks forever on a confirmation that never comes. Deferring it makes exit observable no matter how the goroutine leaves.
  • What breaks if Close waits on stopped before calling cancel?
    It deadlocks. The goroutine is still running its normal loop because nobody has asked it to stop, and `Close` is already parked waiting for it to finish. The order is not stylistic: request first, then join.
  • Two goroutines call Close concurrently. What does sync.Once give you here?
    Exactly one of them runs the body; the other blocks inside `Do` until that body has returned and then continues. So both callers observe the same post-condition — the goroutine has exited — and neither performs the teardown twice.
  • How would you prove in a test that Close really joined?
    Record runtime.NumGoroutine before calling New, then call Close and compare after it returns. Because a joining Close does not return until the goroutine has, the assertion needs no sleep and no retry loop; if it only passes when you add a sleep, Close is signalling but not joining.

saying these in an interview costs you the question

  • Treats calling cancel as proof the goroutine has finished
  • Closes the confirmation channel from Close instead of from the goroutine
  • Waits for the exit signal before sending the stop signal
  • Adds a time.Sleep after Close to let the goroutine catch up
  • Leaves Close non-idempotent so a second call panics or hangs