When several select cases can proceed, Go chooses one uniformly at random. Here's what that means for timeouts, cancellation, non-blocking sends, fan-in, and nil channels.
· 8 min read

Go select Statements: Timeouts, Cancellation, and Fan-In Patterns


It is easy to read select cases as though they were ordered. They are not. The Go spec says that when several cases can proceed, “a single one that can proceed is chosen via a uniform pseudo-random selection.” Source order gives a cancellation case at the top of the block no special treatment.

That selection rule is the key to understanding timeout, cancellation, and fan-in patterns.

How select chooses a case

Execution proceeds in a defined order:

  1. Channel operands of receives, and the channel and the right-hand-side value of sends, are evaluated exactly once, in source order, when you enter the select.
  2. If one or more communications can proceed, one is chosen uniformly at random.
  3. Otherwise, if there is a default, it runs.
  4. If there’s no default, the statement blocks until some communication can proceed.

Step 1 is the one that bites people. Every send expression is evaluated even if that case loses:

func pick() chan int { fmt.Println("pick called"); return make(chan int, 1) }
func compute() int   { fmt.Println("compute called"); return 42 }

func main() {
	done := make(chan struct{})
	close(done)

	select {
	case pick() <- compute(): // both funcs run before a case is selected
	case <-done:
		fmt.Println("done case chosen")
	}
}

Both pick() and compute() run, whichever case is selected. The spec is explicit: “Any side effects in that evaluation will occur irrespective of which (if any) communication operation is selected to proceed.” So keep expensive or stateful calls out of case expressions. A select is not a lazy dispatch table.

The one exception is the left-hand side of a receive assignment. In case a[f()] = <-c4:, f() runs only if that case is chosen.

While we’re on surprises: break inside a select terminates the select, not an enclosing for. Same as switch statements. To leave the loop you need a label or a return.

Adding a timeout to a channel receive

The classic Go channel timeout uses time.After:

select {
case v := <-results:
	handle(v)
case <-time.After(2 * time.Second):
	return errors.New("timed out")
}

Perfectly fine for a one-shot. Inside a hot loop, use an explicit timer you can stop and reset instead of allocating a fresh one every iteration:

func drain(ch <-chan string, idle time.Duration) error {
	timer := time.NewTimer(idle)
	defer timer.Stop()

	for {
		select {
		case v, ok := <-ch:
			if !ok {
				return nil
			}
			process(v)

			timer.Reset(idle)

		case <-timer.C:
			return fmt.Errorf("no data for %s", idle)
		}
	}
}

This is simpler than the timer-reset code you may remember. Since Go 1.23, timer channels are synchronous, and after Reset returns a later receive cannot observe a value from the timer’s previous settings. Before Go 1.23, callers had to stop and drain the timer before resetting it. Go 1.23 also made unreferenced timers eligible for garbage collection before they fire, so time.After is fine when you do not need to reuse a timer. The time package docs spell out both sets of guarantees.

Cancellation with context.Done

context.Context exposes cancellation as a channel, which drops straight into a select case:

func worker(ctx context.Context, jobs <-chan Job, out chan<- Result) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // context.Canceled or DeadlineExceeded

		case job, ok := <-jobs:
			if !ok {
				return nil
			}

			// Sends need the cancellation case too, or this blocks forever
			// when the consumer goes away.
			result := process(job)
			select {
			case out <- result:
			case <-ctx.Done():
				return ctx.Err()
			}
		}
	}
}

The nested select on the send is the part everyone forgets. If no receiver can ever return, a goroutine parked on an unguarded out <- ... cannot exit. The cancellation case lets it stop waiting for the send; it does not interrupt process, so long-running work should accept the context too. For how contexts propagate through a call tree, see Context in Go.

Now the random-selection consequence. A cancelled context does not guarantee that ctx.Done() wins when jobs also has a value ready. Both can proceed, so either might. Usually that’s harmless — you’ll catch cancellation on a later iteration. You can give already-observed cancellation preference with a preliminary poll:

select {
case <-ctx.Done():
	return ctx.Err()
default:
	// not cancelled, carry on
}

That poll is not strict priority: cancellation can happen after it, and a following select still chooses randomly among cases that are ready together. If starting more work after cancellation is unacceptable, design the work handoff so that decision is synchronized explicitly.

Non-blocking sends and receives with default

default turns select into a try-operation. That’s how you build a lossy queue — say a metrics buffer that would rather throw away samples than block the caller:

type Collector struct {
	samples chan Sample
}

// Record returns false if the buffer is full.
func (c *Collector) Record(s Sample) bool {
	select {
	case c.samples <- s:
		return true
	default:
		return false
	}
}

The failure mode is putting default in a loop with nothing else to do:

// Don't do this: burns a CPU core doing nothing.
for {
	select {
	case v := <-ch:
		process(v)
	default:
	}
}

The goroutine never parks, so the scheduler keeps it runnable and it can consume a core checking an empty channel. Drop the default and let select block. A blocked goroutine still consumes runtime resources, but it does not busy-spin. Reach for default when you have real alternative work, or when “give up immediately” is the semantic you want.

Fan-in: merging several channels into one

Fan-in funnels N producers into a single channel. One goroutine per source, a WaitGroup for completion, and a separate goroutine to close the output:

func merge[T any](ctx context.Context, sources ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup

	for _, src := range sources {
		wg.Add(1)
		go func(src <-chan T) {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case v, ok := <-src:
					if !ok {
						return
					}
					select {
					case out <- v:
					case <-ctx.Done():
						return
					}
				}
			}
		}(src)
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

Two details keep this from breaking. close(out) happens exactly once, after every forwarding goroutine returns, which is why it lives in its own goroutine behind wg.Wait(). Both the input receive and output send are guarded by ctx.Done(), so cancellation can release a goroutine waiting on either side. The generics are cosmetic here; the same shape works with a concrete element type.

Notice that each forwarding goroutine selects on only one source. A source-level select written in Go has a fixed number of cases, while a goroutine per source handles a slice of any length. If the number of cases must be dynamic, reflect.Select supports that at the cost of reflection overhead.

Disabling a case with a nil channel

A receive from a nil channel blocks forever. The spec draws out the consequence: “a select with only nil channels and no default case blocks forever.” This can cause a deadlock, but it is also a precise tool: set a channel variable to nil and its case cannot be selected.

That gives you a concise way to consume two channels until both are closed:

func sum(a, b <-chan int) int {
	total := 0

	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // this case can never proceed again
				continue
			}
			total += v

		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			total += v
		}
	}

	return total
}

Without the nil assignments, the loop becomes the spin loop from two sections ago: a closed channel is permanently ready and returns zero values as fast as the loop can receive them. continue inside a select applies to the enclosing for, which is what we want — see Go’s continue statement for the labelled variants.

The same trick toggles a send case on and off. Hold a chan<- T variable, point it at the real channel when you have something to send, set it to nil when you don’t.

Fairness, starvation, and priority

Selection is uniform among the cases that can proceed at that moment. If two cases are ready on every iteration, each has the same chance on each selection, but a finite run can still be lopsided. The rule does not promise FIFO ordering across channels, and it says nothing about which of several goroutines blocked on the same channel gets woken.

Two consequences are worth carrying around. First, an always-ready case does not get priority over another ready case, but random selection is not a hard starvation bound. A producer whose channel is only sometimes ready also participates in fewer selections. If per-source fairness matters, implement an explicit scheduling policy instead of relying on select or fan-in timing.

Second, two selects can give work that is already waiting a preference. Poll the preferred channel with a default-guarded select, then fall through to a blocking select over everything:

for {
	// Drain urgent work first.
	select {
	case job := <-urgent:
		process(job)
		continue
	default:
	}

	select {
	case job := <-urgent:
		process(job)
	case job := <-normal:
		process(job)
	case <-ctx.Done():
		return
	}
}

The first select never blocks; it only asks whether urgent work exists at that instant. The second blocks, so the goroutine parks when everything is idle. urgent appears in both so work arriving after the poll can still wake the second select, but when urgent and normal are both ready there, selection remains random. This pattern expresses a preference, not strict priority.

One last piece of trivia that isn’t trivia: select {} with no cases blocks the current goroutine forever. You may see it at the bottom of main in programs that do all their work in background goroutines. It works, but waiting on a context or signal channel also gives the program a path to shut down cleanly.