Skip to content
intermediate Phase 9 · Channels, Select, and Channel Patterns

Closing Channels and Ranging Over Them

Signal completion with close and terminate receivers using the comma-ok form.

40m
0 problems
Topic Progress 0%

How Does the Consumer Know It Is Over?

A worker processes a job channel forever. The producer finishes, the last job is consumed, and the worker sits in <-jobs for the rest of the process's life — one idle goroutine per completed batch. The consumer's actual question is one the channel API cannot yet answer: is the queue empty, or is the stream finished? Empty means wait, there is more coming. Finished means stop. On an open channel those two states look identical from the receive side.

Closing answers it. close(ch) marks the channel done — no further sends will ever be accepted — and makes every subsequent receive return immediately with the channel's zero value:

jobs := make(chan Job)

go func() {
	for _, j := range loadJobs() {
		jobs <- j
	}
	close(jobs) // I will send no more; drain what is there
}()

for {
	j, ok := <-jobs // comma-ok: the second value says "still live?"
	if !ok {
		break // channel closed and drained
	}
	process(j)
}

The two-value receive is the detection mechanism: ok is true when a real value arrived and false when the receive returned the zero value because the channel is closed. This is deliberately not the same as receiving a value that happens to be the zero value — Job{} sent on a live channel gives ok == true, while a closed-and-drained channel gives ok == false with j == Job{}. Existence and content stay separate questions.

One ordering rule makes the loop above safe: close does not delete queued data. Receivers drain every buffered value first and only then see ok == false. Closing a channel with 10 items still buffered delivers all 10 before reporting done. The panic territory lives elsewhere — sending on a closed channel panics immediately, and closing an already-closed channel panics too. So close is a one-way door that must happen exactly once, and only after the last send.

The mental model worth carrying: closing is not shutdown of anything, it is an end-of-stream marker on one data path. The goroutines keep running; the worker stops only because its loop found ok == false and returned. Anything else still waiting — other channels, other loops — knows nothing about this close.

range: The Loop That Ends Itself

The counting loop from Channel Fundamentals and the comma-ok loop above both hide the same duplicated truth — the receiver had to know how many sends happened, or manually unwrap the two-value form. range over a channel removes both:

func wordCounts(docs []string) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out) // whoever sends, closes
		for _, d := range docs {
			out <- countWords(d)
		}
	}()
	return out
}

func totalWords(docs []string) int {
	sum := 0
	for n := range wordCounts(docs) { // runs until out is closed AND drained
		sum += n
	}
	return sum
}

for n := range out is equivalent to receiving with comma-ok in a loop and breaking on !ok. It terminates when the channel is closed and empty; until then it blocks waiting for the next value exactly as <-out would. Two properties make this the idiomatic consumer shape:

  • No counting. The producer's send count and the consumer's receive count are no longer two copies of one number. The producer decides the stream is over with close, and the consumer reacts.
  • Drain-then-stop. If the producer closes with values still buffered, range yields them all before exiting. Nothing sent is ever dropped because of the close.

The defer close(out) placement is the pattern to internalize: the sender closes, at the end of sending, registered as a defer so an early return (or a panic caught upstream) still marks the stream done. Notice also what the function signature — <-chan int — communicates: callers can receive but cannot send or close. Directional types get their own topic (Directional Channels), but the pipeline shape above already leans on them, because the alternative is trusting every caller not to send into a stream the producer owns.

The trap that makes people distrust range is the un-closed channel. for n := range out where nobody ever calls close(out) is a goroutine parked forever — a leak that only surfaces as creeping memory. Range makes the consumer simpler and moves the termination responsibility entirely onto the producer. If you own the sends, you own the close; if you see a range over a channel and cannot point at the line that closes it, that is the bug to find first — it reappears as a debugging exercise in Finding and Preventing Goroutine Leaks.

Who Owns the Close? Multi-Sender Discipline

With one producer the rule is short — the sender closes. Multiple producers break the naive version instantly:

out := make(chan Result)

for _, src := range sources {
	go func(src Source) {
		for _, item := range src.Items() {
			out <- process(item)
		}
		close(out) // source #2 closes it too -> panic: close of closed channel
	}(src)
}

Whoever finishes first closes; whoever finishes second panics the whole program. The runtime catches double-close and send-on-closed aggressively because both indicate a violated contract, not a recoverable condition. Three established fixes, in order of preference:

1. Let exactly one goroutine close — a coordinator, not a worker. Give the closer no sending duties:

out := make(chan Result)
var wg sync.WaitGroup

for _, src := range sources {
	wg.Add(1)
	go func(src Source) {
		defer wg.Done()
		for _, item := range src.Items() {
			out <- process(item)
		}
	}(src)
}

go func() {
	wg.Wait() // waits for ALL producers
	close(out)
}()

for r := range out {
	consume(r)
}

The detached closer goroutine watches the WaitGroup and closes only after every producer has called Done. Workers never touch close, so no panic is possible regardless of finish order.

2. Redirect each producer through a private channel when the coordinator shape does not fit: each goroutine closes its own channel, and a merger ranges over them. That merger is exactly the fan-in pattern from Fan-Out and Fan-In.

3. Never close from the receiving side. The consumer cannot know no more sends are coming — that is the entire question it is waiting to have answered. Closing from the receiver inverts the contract and races with in-flight producers.

The full ownership checklist: the side that sends decides when sending is done; the close happens after the last send (defer it inside the sending function); every consumer must be able to reach the close — if your consumer might block on a receive that no producer will ever fulfill because it exited early, you need cancellation instead of close alone, which is where select with a done channel and eventually context come in.

A closing detail people miss: closed channels are always ready to receive. v, ok := <-closedCh returns the zero value instantly, which is why a case <-done: in a select fires immediately once someone closes done. Close is a broadcast, and the next topic is where that property becomes a feature.

Close as Broadcast: One Signal, Every Listener

Data channels deliver one value to one receiver. Closing delivers one fact to all of them — and that asymmetry solves a shape of problem the data path cannot: telling a group that something is over without knowing how many are listening or who arrives first.

func startWorkers(n int) (<-chan struct{}, func()) {
	quit := make(chan struct{})
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			<-quit // every worker blocks here until the close
			log.Printf("worker %d stopping", id)
		}(i)
	}
	return quit, func() {
		close(quit) // one close wakes all n workers
	}
}

Send 1 on quit and exactly one worker wakes; close quit and all n wake at once, now and forever — any worker that starts later also reads the closed signal immediately. The value form of this problem (broadcast) has no single-channel solution; you would need a fan-out list of personal channels plus bookkeeping for late arrivals. Close replaces all of it.

Three consequences make this pattern safe to build on:

  • Receives never block on a closed channel. After close, <-quit returns instantly every time, so a worker that misses the original moment still stops on its next check.
  • Broadcast does not consume. Unlike a value, close does not deliver-then-vanish; the state is permanent, which is what lets arbitrarily many receivers observe it.
  • ok == false gives you the same signal in a select. case <-done: with a closed done becomes ready immediately — the mechanics that make select able to wait on data and shutdown at once.

The discipline that keeps broadcast channels from being confused with data channels: a channel used only for shutdown should never carry a meaningful payload, because no receiver can be sure who consumed it. And the reverse mistake is common too — using close to signal completion when the consumers still need remaining data. Order matters: finish sending data on jobs, then close jobs so range drains it, and keep quit as a separate channel if workers must be interrupted before their work is drained. Conflating the two — closing the work channel to interrupt workers — silently discards whatever was still queued, a loss that shows up later as missing results with no error anywhere.

Quiz

1. A producer closes `ch` while three values are still buffered. What does a `for v := range ch` consumer observe?

Question 1 options

2. Two goroutines both send on `out` from the same loop body and each calls `close(out)` when done. What happens when the second one finishes?

Question 2 options

3. A worker pool's consumer must stop immediately when the caller cancels, even though workers may still be sending. Which structure fits?

Question 3 options

4. Why is `close(done)` preferred over `done <- 1` for telling several goroutines to shut down?

Question 4 options

Flashcards

Question

What do the two values mean in `v, ok := <-ch`?

Answer

`v` holds the received value; `ok` is true when it came from a live channel and false when the receive returned the zero value because the channel is closed and drained. The flag separates 'the stream ended' from 'someone sent a value that happens to be zero' — the distinction a plain `v := <-ch` cannot make.

Question

When does a `for v := range ch` loop terminate, and who is responsible for making that happen?

Answer

It terminates when the channel is closed and fully drained — never on emptiness alone. Responsibility belongs to the producer: whoever sends the values closes the channel after the last send, conventionally with `defer close(out)` inside the sending goroutine. An unclosed channel means a range loop parked forever.

Question

A program panics with send on closed channel. Where is the contract violation, and how do you restructure a multi-producer version?

Answer

Some goroutine sent after another goroutine closed — closing ended the stream, so no further sends are legal. In multi-producer code, workers must never call close; instead a coordinator goroutine waits on a sync.WaitGroup for all producers and closes the output channel exactly once, letting consumers range over it safely.

Question

Why does closing a channel broadcast to all receivers while sending a value wakes only one?

Answer

A value is consumed by whichever receiver wins it — the data is gone afterward. Close sets a permanent state on the channel itself: every receive afterwards returns instantly with ok=false, now and in the future. That permanence is why close carries shutdown signals and a single send cannot.

Question

What is the difference between using close to finish a work channel and using it to interrupt workers?

Answer

Closing a work channel means 'drain everything I sent and then stop' — range delivers all remaining items before ending. Closing to interrupt means workers should stop now, which discards whatever is queued. Conflating them silently loses work; interruption needs a separate done/context signal so data loss never hides behind a clean shutdown.

Revision Notes

Key Takeaways

  • 1. close marks end-of-stream, it does not discard buffered values — range delivers everything still queued before stopping
  • 2. The comma-ok receive separates 'stream ended' from 'value happens to be zero'; range wraps that check in a loop
  • 3. Exactly one sender closes, exactly once, after the last send; send-on-closed and double-close are both panics
  • 4. With multiple senders, a coordinator closes after WaitGroup.Wait — workers never call close
  • 5. A closed channel is always ready to receive, which turns close into a broadcast signal for shutdown

Interview Tips

  • • "How do you terminate a consumer loop over a channel?" — close from the producer side after the last send, range drains then exits; immediately follow with the multi-producer variant (WaitGroup + single closer)
  • • "What's the difference between an empty channel and a closed one?" — empty blocks waiting for future data; closed returns instantly with ok=false; use that contrast to explain how range terminates
  • • "A goroutine panics with 'send on closed channel' — how do you prevent it?" — name the ownership violation: close belongs to the sending side and after the last send; restructure so senders cannot race the closer
  • • "Could close() be used to cancel workers?" — argue it signals 'finish draining' not 'abort now'; propose a separate done channel or context so cancellation never silently drops queued work

Cheat Sheet

Closing Channels and Ranging

Receive forms and what they report:

Form Meaning
v := <-ch value; cannot detect end of stream
v, ok := <-ch ok=false ⇒ closed and drained, v is zero value
for v := range ch loops until closed AND drained
<-ch on closed, empty returns immediately, forever

Ownership rules: only a sender closes; exactly once; after the last send (defer close(out)); receivers never close. Multi-sender → coordinator goroutine closes after wg.Wait().

Panics to know: send on closed channel, close of closed channel. Receiving from closed never panics.

Code template (single producer):

func produce(in []T) <-chan T {
	out := make(chan T)
	go func() {
		defer close(out)
		for _, v := range in {
			out <- v
		}
	}()
	return out
}

for v := range produce(data) {
	handle(v)
}

Continue Learning

Before You Start

Related Reading