Several producers write to one channel and one consumer reads it. Who closes the channel?
The sender closes, never the receiver, and with several senders no individual one may close because a send on a closed channel panics. The answer is a WaitGroup over the producers with a separate goroutine closing once they have all finished, which is the only place the fact 'nobody will send again' actually exists.
What the interviewer is scoring
- Whether the candidate states that closing is the sender's job and can say why
- That a send on a closed channel is a panic rather than an error
- Does the candidate reach for a WaitGroup plus a closer goroutine rather than a counter
- Whether they know closing is what ends a range over a channel
- That the error path is addressed, since an early return must not leave the consumer blocked
Answer
The rule, and the reason behind it
The sending side closes a channel; the receiving side never does. This is not a convention, it follows from what the operations do. A receive on a closed channel is well defined — it yields the zero value immediately, forever. A send on a closed channel is a panic, and there is no way to test for it first, because any check would race with another goroutine closing between the check and the send.
So closing is a claim that no further sends will happen, and only the senders are in a position to make that claim.
With one producer this is trivial: it closes when it finishes. With several, no individual producer can close, because the others may still be running. The information needed — all producers have finished — does not exist inside any one of them.
The standard shape
Create the fact somewhere it can exist, which means a WaitGroup the producers
signal and a separate goroutine that waits and then closes.
func fanIn(ctx context.Context, sources []Source) <-chan Record {
out := make(chan Record)
var wg sync.WaitGroup
for _, src := range sources {
wg.Add(1) // before the goroutine starts, never inside it
go func(s Source) {
defer wg.Done() // runs on every exit path, including panic
for _, rec := range s.Read() {
select {
case out <- rec:
case <-ctx.Done(): // consumer gave up; stop rather than block
return
}
}
}(src)
}
// The only place that knows every producer has finished.
go func() {
wg.Wait()
close(out)
}()
return out
}
Three details carry the design.
wg.Add(1) is outside the goroutine. Inside, it races with wg.Wait() — the
waiter can observe a counter of zero before a producer has incremented it, close
the channel, and then that producer panics on its first send.
defer wg.Done() rather than a call at the end, so an early return or a panic
still decrements. A producer that returns early without signalling leaves
Wait blocked forever, the channel never closes, and the consumer's range
never ends — a hang with no error anywhere.
The close is in its own goroutine rather than after the loop, because wg.Wait()
on the calling goroutine would block before fanIn could return the channel,
and nobody would ever be reading it.
Closing is what ends a range
The consumer side is then the part people already know, and the reason the close matters:
for rec := range results { // ends when the channel is closed and drained
process(rec)
}
Without the close this loop blocks forever once the producers stop. That is the symptom the question is really about: a program that processes everything correctly and then hangs at the end, with no error and no CPU use, because nobody told the consumer there was nothing more coming.
The two-value form is the other half of the same fact:
rec, ok := <-results // ok is false once the channel is closed and empty
Note and empty. A closed channel still yields buffered values before it starts reporting closure, so closing does not discard anything already sent.
Going the other way
The mirror question — how the consumer tells the producers to stop — has a different answer, and candidates often try to use the same tool for both.
You do not close the data channel to signal upstream, because that would be the
receiver closing and would panic any producer mid-send. You pass a context, or
a separate done channel, and every producer selects on it alongside its send.
That is what the select in the example above is for: without it, a producer
blocked on out <- rec with no reader left is a leaked goroutine holding
whatever it captured, for the life of the process.
This asymmetry is worth stating explicitly in an interview, because it explains why cancellation is a separate channel rather than a reuse of the first: data flows one way and the stop signal flows the other, and closing only ever works in the direction the data travels.
When you do not need any of this
A useful counter-instinct, because a candidate who reaches for channels
reflexively is a recognisable pattern. If the producers are simply computing
values and nothing needs to stream, a WaitGroup writing into a preallocated
slice by index needs no channel, no close and no synchronisation at all, because
each goroutine owns one element. Channels earn their cost when work must flow as
it becomes available, or when ownership genuinely transfers between goroutines.
Closing means "no more sends", which is a promise only a sender can make — so with several senders the close belongs to whatever waits for the last one.
Likely follow-ups
- What happens if a second goroutine closes the same channel?
- How do you signal to the producers that the consumer has given up?
- Why can the receiver not simply close when it has what it needs?
- What does the two-value receive form tell you that the one-value form does not?
Related questions
- How do goroutines leak, and how would you find a leak in a running service?mediumAlso on goroutines and channels5 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min
- Design a rate limiter that allows N requests per client per minute. Write the class.mediumAlso on concurrency4 min
- What does the GIL actually prevent, and how do you decide between threads, processes, and asyncio?mediumAlso on concurrency4 min
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardAlso on golang4 min
- Design a producer-consumer pipeline with a bounded buffer. What do you do when the buffer is full?hardAlso on concurrency5 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on golang5 min
- What problem do virtual threads actually solve, and when do they not help?mediumAlso on concurrency5 min