Search code examples
gotimercronticker

Golang time.Ticker to tick on clock times


I am working on a Go program and it requires me to run certain function at (fairly) exact clock times (for example, every 5 minutes, but then specifically at 3:00, 3:05, 3:10, etc, not just every 5 minutes after the start of the program).

Before coming here and requesting your help, I tried implementing a ticker does that, and even though it seems to work ok-ish, it feels a little dirty/hacky and it's not super exact (it's only fractions of milliseconds off, but I'm wondering if there's reason to believe that discrepancy increases over time).

My current implementation is below, and what I'm really asking is, is there a better solution to achieve what I'm trying to achieve (and that I can have a little more confidence in)?

type ScheduledTicker struct {
    C chan time.Time
}

// NewScheduledTicker returns a ticker that ticks on defined intervals after the hour
// For example, a ticker with an interval of 5 minutes and an offset of 0 will tick at 0:00:00, 0:05:00 ... 23:55:00
// Using the same interval, but an offset of 2 minutes will tick at 0:02:00, 0:07:00 ... 23:57
func NewScheduledTicker(interval time.Duration, offset time.Duration) *ScheduledTicker {
    s := &ScheduledTicker{
        C: make(chan time.Time),
    }

    go func() {
        now := time.Now()

        // Figure out when the first tick should happen
        firstTick := now.Truncate(interval).Add(interval).Add(offset)

        // Block until the first tick
        <-time.After(firstTick.Sub(now))

        t := time.NewTicker(interval)

        // Send initial tick
        s.C <- firstTick

        for {
            // Forward ticks from the native time.Ticker to the ScheduledTicker channel
            s.C <- <-t.C
        }
    }()

    return s
}

Solution

  • Most timer apis across all platforms work in terms of system time instead of wall clock time. What you are expressing to is have a wall clock interval.

    As the other answer expressed, there are open source packages available. A quick Google search for "Golang Wall Clock ticker" yields interesting results.

    Another thing to consider. On Windows there are "scheduled tasks" and on Linux there are "cronjobs" that will do the wall clock wakeup interval for you. Consider using that if all your program is going to do is sleep/tick between needed intervals before doing needed work.

    But if you build it yourself...

    Trying to get things done on wall clock intervals is complicated by desktop PCs going to sleep when laptop lids close (suspending system time) and clock skew between system and wall clocks. And sometimes users like to change their PC's clocks - you could wake up and poll time.Now and discover you're at yesterday! This is unlikely to happen on servers running in the cloud, but a real thing on personal devices.

    On my product team, when we really need want clock time or need to do something on intervals that span more than an hour, we'll wake up at a more frequent interval to see if "it's time yet". For example, if there's something we want to execute every 12 hours, we might wake up and poll the time every hour. (We use C++ where I work instead of Go).

    Otherwise, my general algorithm for a 5 minute interval would be to sleep (or set a system timer) for 1 minute or shorter. After every return from time.Sleep, check the current time (time.Now()) to see if the current time is at or after the next expected interval time. Post your channel event and then recompute the next wake up time. You can even change the granularity of your sleep time if you woke up spuriously early.

    But be careful! the golang Time object contains both a wall clock and system clock time. This includes the result returned by Time.Now(). Time.Add(), Time.Sub(), and some of the other Time comparison functions work on the monotonic time first before falling over to wall clock time. You can strip the monotonic time out of the Time.Now result by doing this:

    func GetWallclockNow() time.Time {
        var t time.Time = time.Now()
        return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
    }
    

    Then subsequent operations like Add and After will be in wall clock space.