// lesson: select-or-channel
Select and the Or-Channel
You know select picks a ready case uniformly at random. The drill here is
what select is for: composing cancellation signals. A done-channel is a
chan struct{} that is only ever closed, and closing it broadcasts to every
current and future receiver โ that's the one channel operation with fan-out.
The classic composition is the or-channel: given N done-channels, produce one channel that closes when any of them does. Two viable shapes:
// Recursive: peel off up to 3 channels per layer; the last case recurses
// on the rest plus out itself, so a close of out (the parent dying) also
// wakes up and retires every recursive layer underneath it.
func or(chans ...<-chan struct{}) <-chan struct{} {
switch len(chans) {
case 0:
return nil
case 1:
return chans[0]
}
out := make(chan struct{})
go func() {
defer close(out)
switch len(chans) {
case 2:
select {
case <-chans[0]:
case <-chans[1]:
}
default:
select {
case <-chans[0]:
case <-chans[1]:
case <-chans[2]:
case <-or(append(chans[3:], out)...):
}
}
}()
return out
}
// Flat: one goroutine, reflect.Select over N cases, exits after one fires.
cases := make([]reflect.SelectCase, len(chans))
for i, ch := range chans {
cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)}
}
out := make(chan struct{})
go func() {
defer close(out)
reflect.Select(cases) // blocks until any one channel is ready or closed
}()
The recursive shape is the one worth internalizing: each layer only ever
looks at 2 or 3 concrete channels plus one recursive call, so the select
statement stays a fixed size no matter how many inputs you started with โ
but the goroutine count that buys you is roughly N/2, not N/3. Only the
top layer's three slots are all fresh inputs; every layer below it is
also carrying an ancestor's out down through the recursion (that's what
lets a close at the top retire every idle layer underneath), and that
passenger occupies one of the three slots at each subsequent layer. Run
the numbers and it lands exactly on N/2 for the inputs this lesson
cares about: 6 inputs cost 3 goroutines, 30 cost 15, 100 cost 50. The flat
shape uses exactly one goroutine regardless of N, at the cost of building
a reflect.SelectCase slice and paying reflection overhead on every
call โ worth it only if goroutine count matters more than that overhead.
The trap is the obvious third shape: one goroutine per input channel. It works โ and leaks a goroutine for every input that never closes. In a server that builds an or-channel per request, that's an unbounded leak. The tests below count goroutines. They will notice.
โบ Or-Channel
30 ptsImplement:
func Or(chans ...<-chan struct{}) <-chan struct{}
The returned channel must close as soon as any input channel closes. Required semantics:
- Zero inputs: return a channel that never closes. Returning
nilis fine โ receiving from a nil channel blocks forever. - One input: you may return it directly.
- Many inputs: the result closes promptly (within a second is plenty) after the first input closes, even with 100 inputs.
- An input that is already closed when
Oris called must close the result promptly too. - No leaks: once the result has closed, every goroutine you started must
exit โ even though the other inputs are still open and will never close.
The tests compare
runtime.NumGoroutine()before and after (with settling time), across repeated calls with 100 inputs each.
Log in to submit a solution and earn points.