select and Timeouts

A select statement waits on several channel operations at once and runs whichever is ready first. It's how a goroutine listens for results and a timeout and a "stop" signal at the same time. This lesson covers select, timeouts with time.After, non-blocking operations with default, tickers, and the for-select loop that long-running goroutines are built on.

Waiting on several channels

select looks like a switch, but each case is a send or receive. It blocks until one case can proceed, then runs that case only. If several are ready, it picks one at random:

GO
package main

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(200 * time.Millisecond)
		slow <- "slow server"
	}()
	go func() {
		time.Sleep(20 * time.Millisecond)
		fast <- "fast server"
	}()

	for range 2 {
		select {
		case msg := <-fast:
			fmt.Println("got", msg)
		case msg := <-slow:
			fmt.Println("got", msg)
		}
	}
}
Output
got fast server
got slow server

Timeouts

time.After(d) returns a channel that receives a value after the duration d. Put it in a select to stop waiting when something takes too long:

GO
package main

import (
	"fmt"
	"time"
)

func query(delay time.Duration) <-chan string {
	ch := make(chan string, 1) // buffered: the sender never blocks, even if nobody receives
	go func() {
		time.Sleep(delay)
		ch <- "rows"
	}()
	return ch
}

func main() {
	for _, d := range []time.Duration{30 * time.Millisecond, 500 * time.Millisecond} {
		select {
		case res := <-query(d):
			fmt.Println("result:", res)
		case <-time.After(100 * time.Millisecond):
			fmt.Println("timed out after 100ms")
		}
	}
}
Output
result: rows
timed out after 100ms

The result channel is buffered with room for one value. If the timeout wins, the slow goroutine can still send its result into the buffer and finish, instead of blocking forever — a leaked goroutine. For timeouts that must also stop the work itself, use a context (lesson 56).

Non-blocking operations with default

A default case runs immediately if no other case is ready, so the select never blocks. Use it to try a send or receive without waiting:

GO
package main

import "fmt"

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

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

	select {
	case e := <-events:
		fmt.Println("handling", e)
	default:
		fmt.Println("nothing to do")
	}
}
Output
queued click
queued scroll
queue full, dropped resize
handling click

The for-select loop

A goroutine that runs until told to stop is usually a for loop around a select, with one case for work and one for a "done" signal. Closing a channel is the standard way to broadcast "stop" — every receiver sees it at once:

GO
package main

import (
	"fmt"
	"sync"
)

func worker(jobs <-chan int, done <-chan struct{}, results chan<- string) {
	for {
		select {
		case j := <-jobs:
			results <- fmt.Sprintf("job %d done", j)
		case <-done:
			results <- "worker stopping"
			return
		}
	}
}

func main() {
	jobs := make(chan int)
	done := make(chan struct{})
	results := make(chan string)

	var wg sync.WaitGroup
	wg.Go(func() { worker(jobs, done, results) })

	for i := 1; i <= 3; i++ {
		jobs <- i
		fmt.Println(<-results)
	}
	close(done)
	fmt.Println(<-results)
	wg.Wait()
}
Output
job 1 done
job 2 done
job 3 done
worker stopping

chan struct{} carries no data — it's used purely as a signal, and struct{} takes no memory.

Tickers

A time.Ticker delivers a value on its channel at a regular interval. Combine it with a stop condition in a for-select loop for periodic work such as polling or heartbeats, and always Stop it when finished:

GO
package main

import (
	"fmt"
	"time"
)

func main() {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()
	deadline := time.After(180 * time.Millisecond)

	ticks := 0
	for {
		select {
		case <-ticker.C:
			ticks++
			fmt.Println("tick", ticks)
		case <-deadline:
			fmt.Println("finished after", ticks, "ticks")
			return
		}
	}
}
Output
tick 1
tick 2
tick 3
finished after 3 ticks

Disabling a case with a nil channel

A receive from a nil channel never proceeds, so setting a channel variable to nil switches its case off. This is the clean way to merge two channels until both are closed:

GO
package main

import "fmt"

func send(vals ...int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for _, v := range vals {
			ch <- v
		}
	}()
	return ch
}

func main() {
	a, b := send(1, 2, 3), send(10, 20)
	sum := 0
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // a is finished: stop selecting on it
				continue
			}
			sum += v
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			sum += v
		}
	}
	fmt.Println("sum:", sum)
}
Output
sum: 36

Try it yourself

Ask three "mirrors" for the same file and use whichever answers first. Change the delays and see which one wins.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
fastest mirror: asia
Next: protecting shared data with mutexes.