How do you merge several receive-only Go channels into one channel a consumer can range over?
answer
- one goroutine per input channel
- no forwarder knows it is last
- somebody must close the output once
- a WaitGroup, waited on elsewhere
- return the channel, then wait separately
basics
~20 sStart one forwarding goroutine per source that copies every value into a single shared output channel, and start one extra goroutine that waits for all forwarders to finish and then closes that output exactly once.
solid answer
~40 sA fan-in merge gives each source its own goroutine running `for v := range src { out <- v }`, all sending into one `out` channel the merge function creates. No single forwarder knows whether it is the last to finish, so closing `out` is delegated: a separate goroutine waits on a `sync.WaitGroup` that tracks the forwarders and then calls `close(out)` once. The merge function returns `out` immediately rather than waiting, so the caller can start receiving while the forwarders are still running. That combination is what makes `for v := range merged` terminate exactly when every source has been drained and closed. Since Go 1.25 `wg.Go(f)` starts a forwarder and marks it done when it returns, which removes the hand-written counter bookkeeping.
code
go · 16 linesfunc merge[T any](srcs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, src := range srcs {
wg.Go(func() {
for v := range src {
out <- v
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}go deeper
Be ready to write this on a whiteboard: one goroutine per input, one shared output, one closer. Say out loud which goroutine closes the output and why it is not any of the forwarders.
Explain the delegation of closing: a WaitGroup counts the forwarders, and a separate goroutine closes the output once the count reaches zero, because no forwarder can tell whether the others are still sending.
Expect to be pushed on termination: a source that never closes leaves the merged channel open forever, and a consumer that stops early leaves forwarders parked. Say how you bound both.
Own the seam you hand other teams: whether the merge takes a Context, whether the returned channel is receive-only, and whether merging is even right versus one consumer per source with its own failure isolation.
## What fan-in means A Go channel has exactly one queue and any number of goroutines may send into it. Fan-in (also called merging) exploits that: you take N independent input channels and produce **one** output channel carrying every value that appeared on any of them, so a consumer can write a single `for v := range merged` loop instead of juggling N sources. The reason you need a pattern at all is that a receive expression names one channel. `for v := range a` blocks until `a` is closed, so you cannot simply loop over `a` and then over `b` — values on `b` would sit unread the whole time. ## The canonical shape ```go func merge[T any](srcs ...<-chan T) <-chan T { out := make(chan T) var wg sync.WaitGroup for _, src := range srcs { wg.Go(func() { for v := range src { out <- v } }) } go func() { wg.Wait() close(out) }() return out } ``` Three separate jobs are visible here, and interviewers probe each one. **One forwarder per source.** Each goroutine owns exactly one input. Its loop ends naturally when that input is closed and drained, which is how the merge learns that a source is finished — there is no registry, no counting of values, no polling. **A `sync.WaitGroup` counting the forwarders.** A `WaitGroup` is a counter: it goes up when work starts and down when work finishes, and `Wait` blocks until it reaches zero. `wg.Go(f)` (Go 1.25 and later) does both halves for you — it registers the work, runs `f` in a new goroutine, and deregisters when `f` returns. **A closer goroutine.** The output channel must be closed, or the consumer's range loop never ends. It must be closed **once**, and only after the last forwarder has stopped sending. No forwarder can decide that on its own: from inside one forwarder you cannot tell whether the others are still running. So the decision is moved to a goroutine whose whole job is `wg.Wait()` then `close(out)`. ## Why the merge function returns before anything is drained The merge function is a **constructor**, not a drain. On an unbuffered `out`, every forwarder blocks on its first send until somebody receives, and nobody can receive until the caller holds the channel. If the merge function waited for the forwarders itself, it would never return and the forwarders would never be released. Returning immediately, and closing asynchronously, is the only shape where producer and consumer overlap in time. ## The single-goroutine alternative You can also merge in one goroutine with a `select` over all sources. It is more code and it has a sharp edge: **a closed channel is always ready to receive**, returning the zero value immediately. A `select` loop that keeps a closed case in play therefore spins at full CPU. The fix is to assign a nil channel to that case's variable once it reports closed, because a receive from a nil channel blocks forever and so disables the case. The loop ends when every case has been nil-ed out. Since Go's `select` cannot be written over a slice of channels of unknown length without reflection, the goroutine-per-source version is what you write in practice, and it is what an interviewer expects on the whiteboard. ## Types and ownership Returning `<-chan T` rather than `chan T` is deliberate: the caller can only receive, so it cannot send into a channel it did not create nor close a channel it does not own. That is a compile-time expression of "the merge owns the output". The sources are declared `<-chan T` for the same reason: the merge only reads them. Whoever produces a source is responsible for closing it, and if a source is never closed, its forwarder never returns, `wg.Wait` never returns, and the merged channel never closes — a hang the consumer sees as a range loop that simply stops yielding. ## Adding sources later The set of sources is fixed at the moment `merge` is called. Registering another forwarder afterwards races with the closer goroutine's `Wait`. The safe way to add a late source is to merge again: `merged2 := merge(merged, newSource)`. Merges compose, because the output of a merge is just another channel. ## Buffering An unbuffered `out` is correct and is the default choice. A buffer only decouples bursts; it changes neither the ordering nor the closing rules, and it hides a slow consumer for a while instead of applying backpressure to the sources.
- How would you add a new source to a merge that is already running?You do not add it to the existing WaitGroup — registering new work while the closer goroutine may already be inside `Wait` is a race. Instead compose: call the merge again with the existing merged channel and the new source, `merged2 := merge(merged, newSrc)`. The output of a merge is an ordinary channel, so merges nest cleanly.
- Does the merged output channel need a buffer?No. Unbuffered is the correct default: it makes each forwarder wait for the consumer, which is real backpressure onto the sources. A buffer only smooths bursts. It does not change the ordering, does not change who closes the channel, and does not prevent a forwarder blocking once the buffer fills — it just delays the moment you notice a slow consumer.
- What happens if one source channel is never closed by its producer?That forwarder's range loop never ends, so the WaitGroup counter never reaches zero, the closer goroutine stays parked in `Wait`, and the merged channel is never closed. The consumer's range loop simply hangs after the last value instead of exiting. Merge termination is entirely inherited from the sources' termination.
Several conveyor belts feeding one chute: each belt gets its own loader, and a supervisor locks the chute only after every loader has walked away.
saying these in an interview costs you the question
- Closes the merged output channel inside each forwarding goroutine
- Waits for the forwarders before returning the merged channel
- Thinks the merged channel closes when the first source closes
- Selects over sources in one goroutine but never disables closed cases
- Returns a bidirectional chan T so the caller can close it