Random Ticker in Go
The problem
I'm writing something like a scraper, and I want this program to be able to send requests every x seconds, at a slightly different interval every time. How to accomplish this idiomatically in Go?
I think it makes sense to model the API of our "random" ticker on the Ticker from Go's standard library:
// RandomTicker is similar to time.Ticker but ticks at random intervals
// between min and max duration values (stored internally as int64
// nanosecond counts).
type RandomTicker struct {
C chan time.Time
stopc chan struct{}
min int64
max int64
}
We model the RandomTicker
as a struct
, which exposes channel C
on which ticks will be sent. stopc
will be used internally to cleanly stop the ticker once it is no longer needed. min
and max
are counts of nanoseconds and define the smallest possible and the largest possible interval between two ticks.
The loop
When a new RandomTicker
is initialized a loop will be started (in a goroutine) so that the ticker can run in the background and send ticks on the C
channel:
func (rt *RandomTicker) loop() {
t := time.NewTimer(rt.nextInterval())
for {
select {
case <-rt.stopc:
t.Stop()
return
case <-t.C:
select {
case rt.C <- time.Now():
t.Stop()
t = time.NewTimer(rt.nextInterval())
default:
// skip if there is no receiver
}
}
}
}
The loop is a temporal state machine of sorts. On each iteration of the for
loop one of two things can happen:
- A signal is received on the
stopc
channel, in which case the loop will cleanly terminate. - The timer
t
expires, in which case we will advance to the nestedselect
statement, which will either send current time on channelC
(if there is someone to receive the message), or skip the turn and move on to the next iteration of thefor
loop.
Successful send on channel C
will stop the old timer t
and initialize a new one, with a new interval generated by the nextInterval
method. In case no one is receiving on channel C the timer t
will be reused for the next iteration of the for
loop.
Random interval generation
The nextInterval
function is very simple:
func (rt *RandomTicker) nextInterval() time.Duration {
interval := rand.Int63n(rt.max-rt.min) + rt.min
return time.Duration(interval) * time.Nanosecond
}
It generates a random count of nanoseconds, between the min
and max
values defined in the RandomTicker
struct and returns the generated value as time.Duration
, since this is what we need to initialize time.Timer
. It is important to note that in order for the rand
functions to generate random values it is necessary to seed the random number generator. It can be done, for example, in the main
function of the program which uses RandomTicker
:
rand.Seed(time.Now().UTC().UnixNano())
Exiting cleanly
We also need a way to cleanly stop the goroutine used by RandomTicker
so we expose a Stop
method on the RandomTicker
struct:
// Stop terminates the ticker goroutine and closes the C channel.
func (rt *RandomTicker) Stop() {
close(rt.stopc)
close(rt.C)
}
Closing the stopc
channel shuts down the loop running in the goroutine. Closing the C
channel alerts any listeners that nothing will be sent on this channel anymore.
Initialization function
The initialization function needs to store the min
and max
duration values, initialize the channels, and start the loop in a goroutine:
// NewRandomTicker returns a pointer to an initialized instance of the
// RandomTicker. Min and max are durations of the shortest and longest
// allowed ticks. Ticker will run in a goroutine until explicitly stopped.
func NewRandomTicker(min, max time.Duration) *RandomTicker {
rt := &RandomTicker{
C: make(chan time.Time),
stopc: make(chan struct{}),
min: min.Nanoseconds(),
max: max.Nanoseconds(),
}
go rt.loop()
return rt
}
min
and max
are store as counts of nanoseconds, so that we can easily use these values with functions from the rand
package. That it!