My teammate shared a snippet of code with me the other day which led to a fun rabbit hole.
TL;DR:
Remember to drain your timer channel before resetting it if the reset is not a result of the timer firing a notification (e.g. you read from the channel)
What is a Go Timer?
As the name implies, a timer waits a duration of time before firing an event (sending a signal to a channel).
Timers can be stopped, and reset. Just like in real life!
Here's an example:
t := time.NewTimer(10 * time.Second)
fmt.Println("Wait for 10 seconds for the timer to send a signal")
<- t.C
fmt.Println("It's been 10 seconds!)
Of course, you could always just use time.Sleep(10 * time.Second)
in the above example. Unlike sleep, you can stop the Timer before the duration has been met.
Example:
t := time.NewTimer(10 * time.Second)
go func() {
<- t.C
fmt.Println("It's been 10 seconds!")
}()
fmt.Println("Never mind, don't wait 10 seconds for the timer")
t.Stop()
time.Sleep(15 * time.Second)
fmt.PrintLn("Slept for 15 seconds!")
And just like a kitchen timer, you can reset it:
t := time.NewTimer(10 * time.Second)
for {
select {
case <- t.C:
// If you're feeling spicy you could reset with a random duration
t.Reset(5 * time.Second)
default:
time.Sleep(time.Second)
}
}
Everything makes sense until it doesn't
So, I thought I had Timers understood until my teammate sent me this snippet of code:
package main
import (
"fmt"
"time"
)
func main() {
signal := make(chan int)
go func() {
for {
signal <- 1
}
}()
t := time.NewTimer(time.Second)
for {
select {
case <-signal:
time.Sleep(2 * time.Second)
t.Reset(time.Second)
fmt.Print("1")
case <-t.C:
fmt.Print("!")
default:
fmt.Print(".")
}
}
}
Maybe you're an expert in timers, channels, and select blocks and can see the bug here.
At first glance, I would never expect to see !
printed. But here is example output:
$ go run main.go
.111!1!1!1111
Shouldn't the timer reset prevent the !
branch from ever getting executed? Have I been misunderstanding and using reset incorrectly?
Looking at the documentation for Reset, this is near the top:
For a Timer created with NewTimer, Reset should be invoked only on stopped or expired timers with drained channels.
D'oh! Even if the timer has been reset, it doesn't change the fact that it has already fired and so the channel contains an event! And select will pick a branch "randomly" if more than one branch is ready.
And just a few lines down, we are given instructions on how to use reset:
If a program has already received a value from t.C, the timer is known to have expired and the channel drained, so t.Reset can be used directly.
If a program has not yet received a value from t.C, however, the timer must be stopped and—if Stop reports that the timer expired before being stopped—the channel explicitly drained:
if !t.Stop() {
<-t.C
}
t.Reset(d)
Putting it all together, correctly
Since we did not receive anything from the timer channel, we need to check if the timer has already went off, and if so we should drain the channel before resetting it. Changing the snippet as so:
for {
select {
case <-signal:
time.Sleep(2 * time.Second)
if !t.stop() {
// drain the channel
<-t.C
}
t.Reset(1 * time.Second)
fmt.Printf("1")
case <-t.C:
fmt.Printf("!")
default:
fmt.Print(".")
}
}
Now, all shall be well!
Example output:
$ go run main.go
.111111111111111111111
Resources:
- https://gobyexample.com/timers
- https://cs.opensource.google/go/go/+/refs/tags/go1.22.4:src/time/sleep.go;l=46-53 https://cs.opensource.google/go/go/+/refs/tags/go1.22.4:src/time/sleep.go;l=104-105
As part of this little journey, we also discovered running this snippet on a MacBook Pro 2019 (intel), vs Ubuntu on WSL (intel) yielded consistently different outputs which I may or may not write about once I have all the facts straight.
Some things I thought about:
- MacOS vs Ubuntu? MacOS vs Windows? Is this even relevant?
- ✨schedulers✨
- Goroutines are not (os) threads. Repeat after me "goroutines are not (os) threads"
Top comments (0)