Select: Waiting on Several Channels at Once

The select statement, which blocks until one of several channel operations can proceed and picks a ready one at random. The default clause for non-blocking sends and receives, time.After for timeouts, and the nil-channel trick that disables a branch. Compiled and run against Go 1.26.5.

A single channel receive is a decision with one option: you wait for that channel, and you proceed when it’s ready. Real concurrent programs are rarely that tidy. You want to read from whichever of two workers finishes first, or take a result but give up after a second, or drain several channels until they all close. For all of these you need to wait on more than one channel operation at the same time, and act on whichever becomes possible. That is exactly what select does.

select looks like a switch, but every case is a channel operation rather than a value comparison. It blocks until one of its cases can proceed, runs that case, and moves on. If several cases are ready at once, it picks one of them. This chapter is about how that choice is made, and about three smaller powers select gives you: a non-blocking mode, a timeout, and a way to switch a branch off entirely.

The shape of it

Each case in a select is a send or a receive. The statement evaluates all of them, and if none can proceed yet it blocks, parking the goroutine until one can. Then it commits to a single ready case, runs its body, and the select is done. It does not loop on its own; if you want to keep selecting you wrap it in a for.

The interesting question is what happens when two cases are ready at the same instant. Go’s answer is deliberate and worth internalizing: when multiple cases are ready, select chooses one uniformly at random. Not the first in source order, not the one declared highest. Random, by design, so that a busy channel can’t starve a quiet one purely because it was written first.

You can watch the randomness directly. A closed channel is always ready to receive, yielding immediately, so if we close both channels then every iteration has both cases ready and the only thing deciding the winner is select itself:

package main

import "fmt"

func main() {
	a := make(chan int)
	b := make(chan int)
	close(a)
	close(b)

	countA, countB := 0, 0
	for range 10000 {
		select {
		case <-a:
			countA++
		case <-b:
			countB++
		}
	}
	fmt.Printf("a: %d   b: %d\n", countA, countB)
}

Five runs of this against Go 1.26.5 gave a: 4992 b: 5008, then 5081 / 4919, 4967 / 5033, 5003 / 4997, and 5085 / 4915. Every split sits within about one percent of even. That is the uniform choice at work, isolated from any goroutine scheduling because closed channels need no sender. Don’t rely on case order for priority: there is no priority. If you genuinely need “prefer A, fall back to B,” you write that logic yourself with a nested select, not by ordering the cases.

default: making select non-blocking

Add a default case and the meaning of select changes completely. Normally select blocks when no case is ready. With a default present, an empty ready-set runs the default immediately instead of waiting. That turns a blocking channel operation into a non-blocking one: try to receive, and if nothing is there, do something else right now.

package main

import "fmt"

func main() {
	ch := make(chan int) // nobody ever sends

	select {
	case v := <-ch:
		fmt.Println("received", v)
	default:
		fmt.Println("nothing ready, moving on")
	}

	fmt.Println("did not block")
}
nothing ready, moving on
did not block

Nobody sends on ch, so the receive can’t proceed, and rather than parking forever the select takes the default and the program continues. The same trick works for a send: select with a case ch <- v and a default attempts the send and falls through if no receiver is ready, which is how you drop work under backpressure instead of blocking on it. Reach for default only when you actually want that “give up immediately” behavior. A select with a default inside a tight for loop is a busy-wait that pins a CPU core spinning, and it’s one of the more common ways to accidentally burn a processor in Go.

time.After: a timeout as just another case

Because a timeout is “wait, but not forever,” and select is built to wait on channels, timeouts fall out naturally once you have a channel that fires after a delay. time.After(d) returns a <-chan Time that has exactly one value sent on it after duration d. Drop it into a select alongside your real work and whichever happens first wins:

package main

import (
	"fmt"
	"time"
)

func main() {
	slow := make(chan string)
	go func() {
		time.Sleep(200 * time.Millisecond)
		slow <- "done"
	}()

	select {
	case res := <-slow:
		fmt.Println("got:", res)
	case <-time.After(50 * time.Millisecond):
		fmt.Println("timed out after 50ms")
	}
}
timed out after 50ms

The worker takes 200ms; the timeout channel fires at 50ms, so the timeout case is ready first and the select commits to it. If the worker had finished in under 50ms, the slow case would have won instead and printed its result. One caveat the standard library documents: time.After creates a timer that isn’t garbage-collected until it fires, so calling it repeatedly in a hot loop leaks timers until each expires. For a one-shot timeout like this it’s perfect; for a loop, prefer a time.NewTimer you can Stop and reset.

A nil channel is never ready

Here’s the trick that turns select from useful into powerful, and it follows from a single rule about channels: an operation on a nil channel blocks forever. A receive from nil never yields, a send to nil never completes. Inside a select, a case whose channel is nil is therefore never ready, so it’s effectively invisible. That gives you a switch: set a channel variable to nil and its branch goes dark; the rest of the select carries on without it.

This is the clean way to drain several channels that close at different times. When one closes, you nil out its variable so its now-perpetually-ready closed case stops hijacking the loop:

package main

import "fmt"

func main() {
	nums := make(chan int)
	letters := make(chan string)

	go func() {
		nums <- 1
		nums <- 2
		nums <- 3
		close(nums)
	}()
	go func() {
		letters <- "a"
		letters <- "b"
		close(letters)
	}()

	for nums != nil || letters != nil {
		select {
		case n, ok := <-nums:
			if !ok {
				nums = nil // disable this branch
				continue
			}
			fmt.Println("num:", n)
		case s, ok := <-letters:
			if !ok {
				letters = nil // disable this branch
				continue
			}
			fmt.Println("letter:", s)
		}
	}
	fmt.Println("both channels drained")
}

Two runs produced different interleavings of the numbers and letters, which is expected since two goroutines are sending concurrently, but both ended with all values delivered and both channels drained. The mechanism is the important part. Remember from the channels chapter that a receive on a closed channel returns immediately with the zero value and ok == false. Without the nil trick, once nums closed its case would be ready on every single iteration, and the loop would spin forever handing you 0, false. Setting nums = nil removes it from contention, and the for condition uses the same two variables to know when both are exhausted and it’s time to stop.

The for-select loop

One select handles one event. To handle a stream of them you put the select inside a for, and this for-select shape is the backbone of long-running goroutines: an event loop that reacts to whichever channel speaks next and keeps going until told to stop. The idiomatic stop signal is a dedicated done channel that you close, since a closed channel is permanently ready and every receiver sees it at once.

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		for i := 1; i <= 3; i++ {
			ticks <- i
		}
		close(done) // signal: no more work
	}()

	for {
		select {
		case n := <-ticks:
			fmt.Println("tick", n)
		case <-done:
			fmt.Println("stopping")
			return
		}
		time.Sleep(time.Millisecond)
	}
}
tick 1
tick 2
tick 3
stopping

The loop selects between real work on ticks and a shutdown on done. Each tick prints; when the sender finishes and closes done, that case becomes ready, the goroutine prints stopping and returns. The chan struct{} is a convention worth adopting for pure signals: struct{} is a zero-size type carrying no data, so the channel exists only to be closed or received-from, and the reader’s intent (“this is a signal, not a value”) is clear. This done-channel pattern is the manual version of cancellation, and it’s the seed of the context package we reach later in the series, which generalizes exactly this idea to whole trees of goroutines.

time.Ticker for periodic work

time.After fires once. A lot of concurrent work is periodic instead: emit a heartbeat every second, poll a queue every hundred milliseconds, flush a buffer on a fixed cadence. You could call time.After fresh at the top of every loop iteration, but each call allocates a brand-new timer, and you saw above why that is wasteful in a hot loop. The right tool for a repeating tick is time.NewTicker(d), which builds one timer that fires on its channel ticker.C every d, again and again, until you stop it.

It drops straight into the for-select shape. Here a ticker beats every 100ms while a done channel decides when to quit:

package main

import (
	"fmt"
	"time"
)

func main() {
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	done := make(chan struct{})
	go func() {
		time.Sleep(350 * time.Millisecond)
		close(done)
	}()

	beats := 0
	for {
		select {
		case t := <-ticker.C:
			beats++
			fmt.Println("heartbeat", beats, "at", t.Format("15:04:05.000"))
		case <-done:
			fmt.Println("stopping after", beats, "beats")
			return
		}
	}
}
heartbeat 1 at 17:51:35.306
heartbeat 2 at 17:51:35.406
heartbeat 3 at 17:51:35.506
stopping after 3 beats

Three beats, each 100ms apart, then the done channel wins and the loop returns. The value the ticker sends is a time.Time, the instant the tick fired, which is often useful and just as often ignored.

The line you must not forget is defer ticker.Stop(). A Ticker holds a runtime timer that keeps firing whether or not anyone is listening; if you drop the ticker without stopping it, that timer lives on and leaks. Stop releases it. Note one subtlety: Stop halts future ticks but does not close ticker.C, so you never range over a ticker channel expecting it to end. You stop the loop with a separate signal, exactly as done does here.

The contrast with time.After is the whole point. time.After(d) inside a loop is one fresh timer per iteration, each firing once and then waiting to be garbage-collected; a Ticker is one timer for the life of the loop, firing repeatedly. When you want “every d,” reach for NewTicker and stop it when you’re done. When you want “at most d, once,” time.After is right. Mixing them up (a time.After in a hot loop, a ticker you forget to stop) is how periodic code springs timer leaks.

Final thoughts

select waits on several channel operations and proceeds with one that’s ready, choosing uniformly at random when more than one is, so case order carries no priority. A default case makes the whole thing non-blocking, at the cost of a busy-loop if you spin on it. time.After gives you a timeout as just another case. And because any operation on a nil channel blocks forever, assigning nil to a channel variable disables its branch, which is the idiom for draining channels that close at different times. Together these turn select into the control center of a concurrent Go program: the place where one goroutine coordinates the several channels feeding it. Next we look at how to wait for a whole group of goroutines to finish, which is the other half of coordinating fan-out work.

Next: WaitGroup and the fan-out — counting goroutines to completion, and a loop-variable bug the language finally deleted.

Comments