The select Statement in Go

How select waits on several channels at once, how to add timeouts with time.After, non-blocking sends and receives with default, and stopping loops cleanly.

A plain receive <-ch waits on one channel. Real programs often need to wait on several things at once: a result, a timeout, and a stop signal. select does exactly that. It waits until one of its cases can proceed, then runs that case.

Waiting on Two Channels

package main

import (
	"fmt"
	"time"
)

func main() {
	fast := make(chan string)
	slow := make(chan string)

	go func() {
		time.Sleep(10 * time.Millisecond)
		fast <- "fast result"
	}()
	go func() {
		time.Sleep(50 * time.Millisecond)
		slow <- "slow result"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg := <-fast:
			fmt.Println(msg)
		case msg := <-slow:
			fmt.Println(msg)
		}
	}
}

Output:

fast result
slow result

Each select waits until either channel has a value. The fast one is ready first. The loop runs select twice so both results are received. Without select, receiving from slow first would make the program wait for it even though fast was ready.

Random choice when several are ready:

If more than one case is ready at the same moment, select picks one at random. It does not prefer the first case written. Do not rely on case order for priority.

Adding a Timeout With time.After

time.After(d) returns a channel that receives a value after the duration d. Put it in a select to stop waiting after a limit:

package main

import (
	"fmt"
	"time"
)

func slowLookup() <-chan string {
	ch := make(chan string, 1)
	go func() {
		time.Sleep(200 * time.Millisecond)
		ch <- "found it"
	}()
	return ch
}

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

This prints timed out after 50ms. The lookup takes 200ms, so the timer channel is ready first.

Why the result channel has a buffer of 1:

After the timeout, nobody will receive from ch. If it were unbuffered, the lookup goroutine would wait forever on its send and leak. A buffer of one lets it send and exit even when the result is no longer wanted.

Non-Blocking Operations With default

A default case runs immediately if no other case is ready. That turns a blocking send or receive into a "try once" operation:

package main

import "fmt"

func main() {
	events := make(chan string, 1)

	for _, e := range []string{"click", "scroll", "keypress"} {
		select {
		case events <- e:
			fmt.Println("queued", e)
		default:
			fmt.Println("queue full, dropped", e)
		}
	}
}

Output:

queued click
queue full, dropped scroll
queue full, dropped keypress

The buffer holds one event. Once it is full, default runs instead of blocking. This is useful for things like metrics, where dropping a value is better than slowing the program down.

A select loop with default can burn CPU:

for { select { case v := <-ch: ...; default: } } never waits. It spins in a tight loop and keeps a CPU core at full use. Only add default when you really want "do not wait", not as a way to keep a loop going.

Stopping a Loop With a done Channel

A goroutine that loops forever needs a way to be told to stop. A common shape is a select with the work channel and a done channel:

package main

import (
	"fmt"
	"time"
)

func ticker(done <-chan struct{}) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; ; i++ {
			select {
			case out <- i:
				time.Sleep(10 * time.Millisecond)
			case <-done:
				return
			}
		}
	}()
	return out
}

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

	for n := range nums {
		fmt.Println("tick", n)
		if n == 3 {
			close(done)
			break
		}
	}
	fmt.Println("stopped")
}

This prints tick 1, tick 2, tick 3 and stopped. Closing done makes the producer's select take the <-done case and return, so its goroutine does not leak. In real code, context.Context and its Done() channel play the role of done.

A select With No Cases

select {} blocks forever. It is occasionally used at the end of main in programs where all work happens in other goroutines, but a WaitGroup or signal handling is usually clearer.

Good select habits:

Every blocking select in a long-running goroutine includes a way out, either a done channel, a context, or a timeout. That one habit prevents most goroutine leaks.

Summary

select waits on several channel operations and runs whichever is ready first, picking randomly if several are. Add time.After for timeouts, default for non-blocking attempts, and a done channel or context case so loops can be stopped. Give result channels a buffer when the receiver might give up early, so the sender is never stuck.