Timers & Tickers
Assumes select (04-concurrency/02-coordination/01-select) — you used
time.After in a select there for a one-shot timeout. This lesson doesn't
re-teach select itself; it opens up what time.After allocates under the
hood, and gives you the two related tools you reach for when a single call
to time.After isn't the right fit.
What time.After actually is
time.After(d) isn't magic — it creates a time.Timer internally and hands
you its channel. That's convenient, but it also means the timer keeps
running (and, on Go versions before 1.23, keeps its resources alive) until
it fires, whether or not you're still around to receive from it. In a
one-shot select like the timeout pattern from the last lesson, that's
fine: the function returns shortly after, and the timer either already fired
or is about to. The problem shows up in a loop:
for {
select {
case <-time.After(time.Second): // allocates a NEW timer every iteration
fmt.Println("tick")
case <-done:
return
}
}
Each iteration creates a fresh Timer and throws away the old one's channel
without ever reading from it. Pre-1.23, those abandoned timers linger until
their deadline passes — a slow, steady leak. Go 1.23 made unreferenced
timers collectible sooner, but the shape is still wrong: you're paying for a
new allocation every trip through the loop when one timer, managed
explicitly, would do.
time.NewTimer: a timer you control
time.NewTimer(d) returns a *Timer with a C field — the same kind of
channel time.After gave you, but now you hold the timer itself, not just
its channel:
t := time.NewTimer(2 * time.Second)
defer t.Stop()
select {
case <-t.C:
fmt.Println("timed out")
case <-work:
// Stop reclaims the timer's resources if it hasn't fired yet.
// The deferred call is a no-op if we get here after it already has.
}
Stop() cancels a pending timer — call it once you no longer need the
timer, even if you never received from C. It returns false if the timer
had already fired or been stopped, which matters if you're about to drain
C yourself: a fired timer's value is sitting in the channel and a second
receive will block forever. Reset(d) reschedules a stopped or expired
timer to fire again in d, letting you reuse one Timer value instead of
allocating a new one — exactly the fix for the loop above.
time.NewTicker: repeating on a schedule
A Timer fires once. A Ticker fires repeatedly, every d, until you stop
it:
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("heartbeat")
case <-done:
return
}
}
Unlike an abandoned Timer, an unstopped Ticker doesn't just linger — it
keeps firing and filling its channel forever, so defer ticker.Stop() is
not optional the way it sometimes felt with timers. Always pair
NewTicker with a Stop() in the same scope that created it.
time.Tick: convenience with a catch
time.Tick(d) is a shortcut that returns just the channel, skipping the
Ticker value — no Stop() is possible, ever. Its own documentation warns
against it in production code: the underlying ticker can never be
garbage-collected. It's fine for a main() that runs for the life of the
process and never needs to shut down cleanly, but avoid it inside anything
that starts and stops, like a library function or a request handler.
Designing for testability
The exercise below asks you to write a function that takes <-chan time.Time as a parameter rather than a time.Duration. That's the same
idea from the Durations lesson — pass in what you need instead of reaching
for it — applied to time itself. A function that calls time.After
internally forces its tests to wait out a real duration or race a real
clock. A function that accepts the channel lets a test hand it a channel
that's already got a value sitting in it, making the outcome structurally
guaranteed rather than probabilistic. You already know the nil-channel trick
from the Select lesson — a nil channel blocks forever — and it does double
duty here: it's how you express "no timeout at all" without a special case
in the function body.
Practice
1. First Result Wins
IntermediateYou met
time.Afterin the Select lesson. Here you'll wrap the pattern in a reusable function — and learn why the caller owns the timeout.Implement
FirstResult(result <-chan string, timeout <-chan time.Time) (string, error):- If a value arrives on
resultfirst, return it with a nil error. - If
timeoutdelivers first, return""andErrTimeout(already defined — return that exact sentinel so callers canerrors.Isit). - Receiving from a
nilchannel blocks forever. That's a feature: a niltimeoutmeans "no deadline", and the select simply never takes that branch.
Taking
<-chan time.Timeinstead of atime.Durationis what makes this testable — the tests hand you channels whose state is already decided instead of racing real clocks.package main import ( "errors" "fmt" "time" ) // ErrTimeout is returned when the timeout fires before a result arrives. var ErrTimeout = errors.New("timed out waiting for result") // FirstResult waits for a value on result or a tick on timeout — whichever // is ready first. A nil channel blocks forever, so passing timeout == nil // means "wait as long as it takes". func FirstResult(result <-chan string, timeout <-chan time.Time) (string, error) { // TODO: select on both channels: a result wins → (value, nil); the // timeout wins → ("", ErrTimeout). No default case — you WANT to // block until one side is ready return "", nil } func main() { result := make(chan string, 1) result <- "done" v, err := FirstResult(result, time.After(50*time.Millisecond)) fmt.Println(v, err) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- If a value arrives on