Random Ticker in Go

11 December 2019 
— by Filip
#go#golang#time
Photo by Fabrizio Verrecchia

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:

  1. A signal is received on the stopc channel, in which case the loop will cleanly terminate.
  2. The timer t expires, in which case we will advance to the nested select statement, which will either send current time on channel C (if there is someone to receive the message), or skip the turn and move on to the next iteration of the for 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!

Additional Resources

  1. Ticker implementation from Go’s Time Package
  2. Code on Github