A mutable time.Ticker

Just a Penguin - Slide

At the core of Slide, there is a ticker which runs at a user specified interval. The ticker is used to perform operations on a loop at the given interval. For example, Slide’s ticker runs every x interval and finds a new background from the database and applies it as the wallpaper. Another ticker is used to check for new images and download them at a user defined interval.

The normal use case for a time.Ticker is as follows:

package main

import "time"

func main() {
    interval := time.Second * 5
    ticker := time.NewTicker(interval)

    for range ticker.C {
        // do something every 5 seconds
    }
}

In Slide, we allow users to modify the interval at which backgrounds are changed. If the interval changes, the ticker needs to be updated (or replaced?) to reflect these changes, without requiring a full application restart. We need a way to change the interval with as minimal an impact to the application as possible.



Try replacing the ticker

One attempt to solve the problem is to replace the running ticker with a new ticker at a new interval, however this does not work.

interval := time.Second * 5
ticker := time.NewTicker(interval)

go func() {
    for range ticker.C {
        fmt.Printf("Tick!: %s\n", time.Now().String())
    }
}()

// wait for 2 ticks, then change interval
time.Sleep(interval * 2)

ticker = time.NewTicker(time.Second)

// wait for another 10 seconds and end the ticker
time.Sleep(10 * time.Second)
ticker.Stop()

However, this doesn’t work. ticker.C still refers to the channel of the original ticker, so it will continue running at the original interval.

Tick!: 2009-11-10 23:00:05 +0000 UTC m=+5.000000001
Tick!: 2009-11-10 23:00:10 +0000 UTC m=+10.000000001
Tick!: 2009-11-10 23:00:15 +0000 UTC m=+15.000000001

Attempts to dereference the ticker and replace its value with a new ticker (e.g. *ticker = NewTicker(newInterval)) also fail, causing the old ticker to stop running.



Wrapping time.Ticker

What if there was a way to extend the functionality of time.Ticker and made it possible to update this interval?

The following implementation allows just that. A ticker is created with an interval just as before, but a channel is used to send a time.Duration to update the ticker.

In the loop() method of the ticker, a select handles messages from 3 different channels:

  1. Update: a value sent on the update channel means the underlying time.Ticker must be stopped and replaced with a new instance which has the new interval (the value passed down the channel)
  2. Tick: this takes a tick from the underlying time.Ticker and pushes it into the mutable ticker’s channel. This passes the tick on to the familiar ticker.C channel which is accessible by applications using the mutable ticker.
  3. Done: a value is received from the Done() channel when the cancel function of the ticker’s context is called. This is triggered in MutableTicker.Stop(). This returns from the loop and ends the goroutine.
// NewMutableTicker provides a ticker with an interval which can be changed
func NewMutableTicker(t time.Duration) *MutableTicker {
    ticker := time.NewTicker(t)

    c := make(chan time.Time)
    d := make(chan time.Duration)

    ctx, cfn := context.WithCancel(context.Background())

    tk := &MutableTicker{
        ctx:      ctx,
        cfn:      cfn,
        ticker:   ticker,
        updateCh: d,
        c:        c,
        C:        c,
    }

    go tk.loop()

    return tk
}

// MutableTicker is a time.Ticker which can change update intervals
type MutableTicker struct {
    ctx      context.Context
    cfn      context.CancelFunc
    ticker   *time.Ticker
    updateCh chan time.Duration

    // c is a read/write private channel
    c chan time.Time
    // C is a read only channel
    C <-chan time.Time
}

func (m *MutableTicker) loop() {
    for {
        select {
        // the interval of the ticker has been updated.
        case d := <-m.updateCh:
            // stop the old ticker and replace it with a new one
            m.ticker.Stop()
            m.ticker = time.NewTicker(d)
        case t := <-m.ticker.C:
            // updates from the underlying ticker are passed into the writable version of C
            m.c <- t
        case <-m.ctx.Done():
            // use context.Done() to quit the loop when the CancelFunc is called.
            return
        }
    }
}

// UpdateInterval modifies the interval of the Ticker.
func (m *MutableTicker) UpdateInterval(t time.Duration) {
    m.updateCh <- t
}

// Stop the Ticker.
func (m *MutableTicker) Stop() {
    m.cfn()

    if m.ticker != nil {
        m.ticker.Stop()
    }

    close(m.c)
    close(m.updateCh)
}


Using it

Simple!

interval := time.Second * 5
ticker := NewMutableTicker(interval)
defer ticker.Stop()

go func() {
    for range ticker.C {
        fmt.Printf("Tick!: %s\n", time.Now().String())
    }
}()

// wait for 2 ticks, then change interval
time.Sleep(interval * 2)

ticker.UpdateInterval(time.Second * 2)

time.Sleep(10 * time.Second)

And you get the following output:

Tick!: 2009-11-10 23:00:05 +0000 UTC m=+5.000000001
Tick!: 2009-11-10 23:00:10 +0000 UTC m=+10.000000001
Tick!: 2009-11-10 23:00:12 +0000 UTC m=+12.000000001
Tick!: 2009-11-10 23:00:14 +0000 UTC m=+14.000000001
Tick!: 2009-11-10 23:00:16 +0000 UTC m=+16.000000001
Tick!: 2009-11-10 23:00:18 +0000 UTC m=+18.000000001