Why does benbjohnson/clock mock timer not execute when declared inside a goroutine?

487 views Asked by At

This code works as I expect it

import (
    "fmt"
    "time"

    "github.com/benbjohnson/clock"
)

func main() {
    mockClock := clock.NewMock()
    timer := mockClock.Timer(time.Duration(2) * time.Second)
    go func() {
        <-timer.C
        fmt.Println("Done")
    }()
    mockClock.Add(time.Duration(10) * time.Second)
    time.Sleep(1)
}

It prints "Done" as I expect. Whereas this function does not

import (
    "fmt"
    "time"

    "github.com/benbjohnson/clock"
)

func main() {
    mockClock := clock.NewMock()
    go func() {
        timer := mockClock.Timer(time.Duration(2) * time.Second)
        <-timer.C
        fmt.Println("Done")
    }()
    mockClock.Add(time.Duration(10) * time.Second)
    time.Sleep(1)
}

The only difference here is I'm declaring the timer outside the goroutine vs. inside it. The mockClock Timer() method has a pointer receiver and returns a pointer. I can't explain why the first one works and the second doesn't.

1

There are 1 answers

0
blackgreen On BEST ANSWER

The package benbjohnson/clock provides mock time facilities. In particular their documentation states:

Timers and Tickers are also controlled by this same mock clock. They will only execute when the clock is moved forward

So when you call mockClock.Add, it will sequentially execute the timers/tickers. The library also adds sequential 1 millisecond sleeps to artificially yield to other goroutines.

When the timer/ticker is declared outside the goroutine, i.e. before calling mockClock.Add, by the time mockClock.Add gets called the mock time does have something to execute. The library's internal sleeps are enough for the child goroutine to receive on the ticker and print "done", before the program exits.

When the ticker is declared inside the goroutine, by the time mockClock.Add gets called, the mock time has no tickers to execute and Add essentially does nothing. The internal sleeps do give a chance to the child goroutine to run, but receiving on the ticker now just blocks; main then resumes and exits.

You can also have a look at the ticker example that you can see in the repository's README:

mock := clock.NewMock()
count := 0

// Kick off a timer to increment every 1 mock second.
go func() {
    ticker := mock.Ticker(1 * time.Second)
    for {
        <-ticker.C
        count++
    }
}()
runtime.Gosched()

// Move the clock forward 10 seconds.
mock.Add(10 * time.Second)

// This prints 10.
fmt.Println(count)

This uses runtime.Gosched() to yield to the child goroutine before calling mock.Add. The sequence of this program is basically:

  • clock.NewMock()
  • count := 0
  • spawn child goroutine
  • runtime.Gosched(), yielding to the child goroutine
  • ticker := mock.Ticker(1 * time.Second)
  • block on <-ticker.C (the mock clock hasn't moved forward yet)
  • resume main
  • mock.Add, which moves the clock forward and yields to the child goroutine again
  • for loop with <-ticker.C
  • print 10
  • exit

By the same logic, if you add a runtime.Gosched() to your second snippet, it will work as expected, just like the repository's example. Playground: https://go.dev/play/p/ZitEdtx9GdL

However, do not rely on runtime.Gosched() in production code, possibly not even in test code, unless you're very sure about what you are doing.


Finally, please remember that time.Sleep(1) sleeps for one nanosecond.