Better scheduling in Go

Sunday, September 22, 2019

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 them.

TL;DR

I also have a package Kronika, which includes an Every function that can be used for scheduling.

ctx := context.Background()

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

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

for t := range kronika.Every(ctx, start, interval) {
	// Perform action here
	log.Println(t.Format("2006-01-02 15:04:05"))
}

Check out Kronika, a package that adds some nice helpers around the time package.

https://github.com/stephenafamo/kronika

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
}

This function is included in the Kronika package, you can use it with kronika.Every()

What’s happening in the function

The function receives 3 parameters.

  1. Context: The ticker will be stopped whenever the context is canceled. So we can create a context with a cancel function, or a timeout, or a deadline, and when that context is canceled, the function will gracefully release its 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 2 pm

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.

Check out Kronika, a package that adds some nice helpers around the time package.

https://github.com/stephenafamo/kronika

Let me know what you think.

OTHER POSTS

Powered By Swish