A timeout is a channel¶
Level: 101 · anyone who needs to stop waiting
One line: time.After returns a channel that delivers once a duration has passed, so a timeout is just one more case in a select. A time.Timer is that channel with a Stop, and a time.Ticker is a channel that delivers again and again.
Verified output of a_timeout_is_a_channel_go.go — regenerated by tools/run_examples.py, never hand-typed.
1. time.After: a timeout is a select case
answer arrived first: Oslo: 21 C
gave up on Lima
waited at least 1s: true
and less than the 3s Lima would take: true
2. time.NewTimer: the same channel, with a Stop
cap(timer.C) = 0
nothing to receive after Stop
3. time.NewTicker: a channel that delivers again and again
tick 1
tick 2
tick 3
no tick after Stop
Reading the output¶
1. time.After. time.After(d) ↗ returns a channel, waits for d to pass, and then sends the current time on it. That is all there is to it. In a select it is a receive case like any other, and whichever case can proceed first is the one taken. Oslo's forecast was on its channel at once, two seconds before the timeout, so the answer won. Lima's forecast takes three seconds and the timeout was one, so the select gave up. The two lines after that are thresholds, not durations. The wait was at least a second, because a timer sends after at least ↗ its duration, and it was shorter than the three seconds Lima needed. Every pair of events that compete in this program is at least two seconds apart, which is what lets the output be the same on every run.
The goroutine left behind. When the select gave up on Lima, Lima's lookup was still asleep in its goroutine. The timeout ended the wait, not the work. forecast made its channel with room for one value. When that goroutine wakes, it puts its answer in the buffer, which nobody will read, and returns. With an unbuffered channel, that send would wait forever for a receiver, and the goroutine would never end (A leaked goroutine never ends). The Go blog post Go Concurrency Patterns: Timing out, moving on ↗ buffers its channel for the same reason.
2. time.NewTimer. time.After(d) is the same as NewTimer(d).C ↗. Keeping the *Timer gives you Stop and Reset. This program let the timer expire without receiving from it, stopped it, and then tried a receive with default. There was nothing to receive. That line and cap(timer.C) = 0 are the two visible halves of a change in Go 1.23:
- Before Go 1.23, a timer's channel had a buffer of one. The time from an expired timer could still be sitting there after
StoporReset, and code had to drain it by hand. Since Go 1.23 the channel is unbuffered, and a receive afterStoporResethas returned is guaranteed not to get a time from before the call (Go 1.23 release notes ↗,Timer.Stop↗). - Also since Go 1.23, the garbage collector can reclaim a timer or ticker that nothing refers to any more, whether or not it was stopped. The
time.Afterdocumentation ↗ now says there is no reason to preferNewTimerwhenAfterwill do.
The release notes attach a condition. The new behaviour is on when the main program's module says go 1.23 or later in its go.mod, and a newer toolchain building an older module keeps the old behaviour. The programs on this page are single files built outside any module. For those, the go command assumes its own version (modload/init.go ↗), which is why Go 1.25 printed 0 here. With Go 1.23 to 1.26, the same program in a module whose go.mod says go 1.22 would get the one-element buffer back, and so a stale time. (Not machine-checked here.) In Go 1.27 that exception is gone: channels created by package time are always unbuffered, whatever the settings (Go 1.27 release notes ↗).
3. time.NewTicker. A Ticker ↗ sends on its channel once every period until it is stopped. Here it ticked every 100 ms, against a deadline 2.5 s away. Three ticks arrived long before the deadline. The loop then stopped the ticker, and a receive with default found no tick waiting. Two things about a ticker are easy to miss. Stop does not close the channel ↗, so a for range ticker.C loop never ends on its own. And a ticker adjusts its interval or drops ticks when the receiver is slow, so counting ticks is not a way to measure time.
The deadline was made once, before the loop, so it limits the whole loop. A time.After written inside the loop would start a new timer on every pass, and it would limit only a single wait.
a_timeout_is_a_channel_go.go
a_timeout_is_a_channel_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// time.After returns a channel that delivers once a duration has passed, so a
// timeout is one more select case. A Timer is that channel with a Stop, and a
// Ticker is a channel that delivers again and again.
//
// go run a_timeout_is_a_channel_go.go
package main
import (
"fmt"
"time"
)
// forecast answers after delay. Its channel has room for the answer, so a lookup
// that nobody waits for any more can still send, and its goroutine can end.
func forecast(city string, delay time.Duration) <-chan string {
answer := make(chan string, 1)
go func() {
time.Sleep(delay)
answer <- city + ": 21 C"
}()
return answer
}
func main() {
fmt.Println("1. time.After: a timeout is a select case")
select {
case report := <-forecast("Oslo", 0):
fmt.Println(" answer arrived first:", report)
case <-time.After(2 * time.Second):
fmt.Println(" gave up on Oslo")
}
start := time.Now()
select {
case report := <-forecast("Lima", 3*time.Second):
fmt.Println(" answer arrived first:", report)
case <-time.After(1 * time.Second):
waited := time.Since(start)
fmt.Println(" gave up on Lima")
fmt.Println(" waited at least 1s: ", waited >= 1*time.Second)
fmt.Println(" and less than the 3s Lima would take:", waited < 3*time.Second)
}
fmt.Println()
fmt.Println("2. time.NewTimer: the same channel, with a Stop")
timer := time.NewTimer(10 * time.Millisecond)
fmt.Println(" cap(timer.C) =", cap(timer.C))
time.Sleep(2 * time.Second) // a margin: the timer is long past due, and nobody received
timer.Stop()
select {
case <-timer.C:
fmt.Println(" received a stale time after Stop")
default:
fmt.Println(" nothing to receive after Stop")
}
fmt.Println()
fmt.Println("3. time.NewTicker: a channel that delivers again and again")
ticker := time.NewTicker(100 * time.Millisecond)
deadline := time.After(2500 * time.Millisecond)
ticks := 0
for ticks < 3 {
select {
case <-ticker.C:
ticks++
fmt.Println(" tick", ticks)
case <-deadline:
fmt.Println(" deadline passed after", ticks, "ticks")
return
}
}
ticker.Stop()
select {
case <-ticker.C:
fmt.Println(" a tick arrived after Stop")
default:
fmt.Println(" no tick after Stop")
}
}
What to do¶
- Write a timeout as one more case:
case <-time.After(d):, next to the receive you are waiting on. - Give a goroutine you might stop waiting for somewhere to put its answer, such as a channel with a buffer of one. Otherwise it leaks when the timeout wins.
- For a deadline on a whole loop, make the timer once, outside the loop. A
time.Afterinside the loop limits each wait, not the loop. - When the work itself should stop once time is up, use a
contextwith a deadline. Aselecttimeout stops the waiting, not the work (A deadline is a cancel with a clock). - Test timer code without waiting in real time by running it in a
testing/synctestbubble (synctest makes time virtual).
In other languages¶
- The Rust library's Channels ↗ page uses
Receiver::recv_timeout↗, stable since Rust 1.12.0. There the timeout is built into a receive on one channel, not a timer channel you can put next to anything. The crossbeam crate has Go's version:after↗ andtick↗ return receivers that deliver after a duration and periodically, for use in itsselect!. - The Concurrency library's chapters 05, Message passing, and 06, Async, which covers cancellation, are planned.
Sources¶
- The
timepackage:After↗,NewTimer↗,Timer.Stop↗,NewTicker↗ andTicker.Stop↗. - Go 1.23 release notes, Timer changes ↗, the GODEBUG history ↗ for
asynctimerchan, and the Go 1.27 release notes ↗. - The Go blog, Go Concurrency Patterns: Timing out, moving on ↗.
- James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 8, "Selecting channels", the section on timing out on channels.
- Burak Serdar, Effective Concurrency in Go (Packt, 2023), chapter 7, "Timers and Tickers". It was published before Go 1.23, so its advice on stopping and draining timers comes from before the change described above.