Posted on Leave a comment

Better scheduling in Go

In a lot of situations, we would like to be able to schedule functions in Go. While there are many current tools for doing scheduling (such as Cron), I would much prefer if the entire working of my program was contained in the same code/binary and I did not have to go to the system crontab to discover what is going on.

Thankfully, the time standard library already comes with A LOT of tools to schedule events. Let’s look at some of them and when to use it.

Using time.After()

time.After() allows us to perform an action after a duration. For example:

NOTE: The helper constants time.Secondtime.Minute and time.Hour are all of type time.Duration. So if we have to supply a duration, we can multiply these constants by a number (e.g. time.Second * 5) and the expression returns a time.Duration.

package main

import (
    "fmt"
    "time"
)

func main() {
    // This will block for 5 seconds and then return the current time
    theTime := <-time.After(time.Second * 5)
    fmt.Println(theTime.Format("2006-01-02 15:04:05"))
}
2019-09-22 09:33:05

Using time.Ticker

time.After() is great for one-time actions, but the power of cron jobs are in performing repeated actions.

So, to do that we use a time.Ticker. For most use cases, we can use the helper function time.Tick() to create a ticker. For example:

package main

import (
    "fmt"
    "time"
)

func main() {
    // This will print the time every 5 seconds
    for theTime := range time.Tick(time.Second * 5) {
        fmt.Println(theTime.Format("2006-01-02 15:04:05"))
    }
}
2019-09-22 10:07:54
2019-09-22 10:07:59
2019-09-22 10:08:04
2019-09-22 10:08:09
2019-09-22 10:08:14

NOTE: When using a Ticker, the first event will be triggered AFTER the delay.

Dangers of using time.Tick()

When we use the time.Tick() function, we do not have direct access to the underlying time.Ticker and so we cannot close it properly.

If we never need to explicitly stop the ticker (for example if the ticker will run all the time), then this may not be an issue. However, if we simply ignore the ticker, the resources will not be freed up and it will not be garbage collected.

Limitations using time.Tick()

There are several things we can’t easily do with time.Ticker:

  • Specify a start time
  • Stop the ticker

Extending time.Tick() using a custom function

To overcome the limitations of time.Tick(), I’ve created a helper function which I use in my projects.

func cron(ctx context.Context, startTime time.Time, delay time.Duration) <-chan time.Time {
    // Create the channel which we will return
    stream := make(chan time.Time, 1)

    // Calculating the first start time in the future
    // Need to check if the time is zero (e.g. if time.Time{} was used)
    if !startTime.IsZero() {
        diff := time.Until(startTime)
        if diff < 0 {
            total := diff - delay
            times := total / delay * -1

            startTime = startTime.Add(times * delay)
        }
    }

    // Run this in a goroutine, or our function will block until the first event
    go func() {

        // Run the first event after it gets to the start time
        t := <-time.After(time.Until(startTime))
        stream <- t

        // Open a new ticker
        ticker := time.NewTicker(delay)
        // Make sure to stop the ticker when we're done
        defer ticker.Stop()

        // Listen on both the ticker and the context done channel to know when to stop
        for {
            select {
            case t2 := <-ticker.C:
                stream <- t2
            case <-ctx.Done():
                close(stream)
                return
            }
        }
    }()

    return stream
}

What’s happening in the function

The function receives 3 parameters.

  1. Context: The ticker will be stopped whenever the context is cancelled. So we can create a context with a cancel function, or a timeout, or a deadline, and when that context is cancelled, the function will gracefully release it’s resources.
  2. Time: The start time is used as a reference to know when to start ticking. If the start time is in the future, it will not start ticking until that time. If it is in the past. The function calculates the first event that will be in the future by adding an appropriate multiple of the delay.
  3. Duration: This is the interval between ticks. Calculated from the start time.

Examples of using the custom function

Run on Tuesdays by 2pm

ctx := context.Background()

startTime, err := time.Parse(
    "2006-01-02 15:04:05",
    "2019-09-17 14:00:00",
) // is a tuesday
if err != nil {
    panic(err)
}

delay := time.Hour * 24 * 7 // 1 week

for t := range cron(ctx, startTime, delay) {
    // Perform action here
    log.Println(t.Format("2006-01-02 15:04:05"))
}

Run every hour, on the hour

ctx := context.Background()

startTime, err := time.Parse(
    "2006-01-02 15:04:05",
    "2019-09-17 14:00:00",
) // any time in the past works but it should be on the hour
if err != nil {
    panic(err)
}

delay := time.Hour // 1 hour

for t := range cron(ctx, startTime, delay) {
    // Perform action here
    log.Println(t.Format("2006-01-02 15:04:05"))
}

Run every 10 minutes, starting in a week

ctx := context.Background()

startTime, err := time.Now().AddDate(0, 0, 7) // see https://golang.org/pkg/time/#Time.AddDate
if err != nil {
    panic(err)
}

delay := time.Minute * 10 // 10 minutes

for t := range cron(ctx, startTime, delay) {
    // Perform action here
    log.Println(t.Format("2006-01-02 15:04:05"))
}

Conclusion

With this function, we have much better control over scheduling in our projects. We can set a specific time to begin, and a duration. We also nicely clean up any resources used.

Let me know what you think.

Spread the love
Posted on Leave a comment
Leave a Reply

Your email address will not be published. Required fields are marked *