7

Timers and Tickers

Many long-lived applications impose limits on how long an operation can last. They also perform tasks such as health checks periodically to ensure all components are working as expected. Many platforms provide high-precision timer operations, and the Go standard library provides portable abstractions of these services in the time package. We will look at timers and tickers in this chapter. Timers are tools for doing things later, and tickers are tools for doing things periodically.

The key sections we will review in this chapter are the following:

  • Timer – running something later
  • Tickers – running something periodically

At the end of this chapter, you will have seen how to work with timers and tickers and how you can monitor other goroutines using heartbeats.

Technical Requirements

The source code for this particular chapter is available on GitHub at https://github.com/PacktPublishing/Effective-Concurrency-in-Go/tree/main/chapter7.

Timers – running something later

If you want to do something later, use time.Timer. A Timer is a nice way of doing the following:

// This is only for illustration. Don't do this!
type TimerMockup struct {
     C chan<- time.Time
}
 
func NewTimerMockup(dur time.Duration) *TimerMockup {
     t := &TimerMockup{
          C: make(chan time.Time,1),
     }
     go func() {
          // Sleep, and then send to the channel
          time.Sleep(dur)
          t.C <- time.Now()
          }()
     return t
}

So, a timer is like a goroutine that will send a message to a channel after sleeping for the requested amount of time. The actual implementation of Timer uses platform-specific timers, so it is more accurate and not as simple as starting a goroutine and waiting. One thing to keep in mind is that when you receive the event from a timer channel, it means the timer duration elapsed when the message was sent, which is not the same as when the message was received.

You might have noticed that the timer uses a channel with a capacity of 1. This prevents goroutine leaks if the timer channel is never listened to by another goroutine. A buffered channel means the event will be generated when the duration elapses, but if no goroutines are listening to the channel, the event will wait in the channel until it is read or the timer is garbage-collected.

A common use of a timer is to limit the running time of tasks:

func main() {
     // timer will be used to cancel work after 100 msec
     timer := time.NewTimer(100 * time.Millisecond)
     // Close the timeout channel after 100 msec
     timeout := make(chan struct{})
     go func() {
           <-timer.C
           close(timeout)
           fmt.Println("Timeout")
     }()
     // Do some work until it times out
     x := 0
     done := false
     for !done {
           // Check if timed out
           select {
           case <-timeout:
                done = true
           default:
           }
           time.Sleep(time.Millisecond)
           x++
     }
     fmt.Println(x)
}

The timer setup can be greatly simplified by using the time.AfterFunc function. The following function call can replace the timer setup and goroutine in the preceding code snippet. The time.AfterFunc function will simply call the given function after the given duration:

time.AfterFunc(100*time.Millisecond,func() {
     close(timeout)
     fmt.Println("Timeout")})

A similar approach would be to use time.After:

ch := time.After(100*time.Millisecond)

Then, the ch channel will receive a time value after 100 milliseconds.

Stopping a timer is easy. In the preceding program, if the long-running task finishes before it times out, we want to stop the timer; otherwise, it will print out an erroneous Timeout message. A call to Stop() may manage to stop the timer if it hasn’t expired yet, or the timer may expire after you call Stop(). These two cases are illustrated in Figure 7.1.

Figure 7.1 – Stopping a timer before and after it fires

Figure 7.1 – Stopping a timer before and after it fires

If Stop() returns true, then you managed to stop the timer. However, if Stop() returns false, the timer expired, and thus, it has already stopped. This doesn’t mean that the message from the timer channel has been consumed yet and may be consumed after Stop() returns. Do not forget that the timer channel has a capacity of 1, so the timer will send to that channel even if nobody is receiving from it.

The Timer type allows you to reset the timer. The behavior is different between a timer created by NewTimer, and a timer created by AfterFunc, as follows:

  • If the timer is created by AfterFunc, resetting the timer will either reset the time the function will run for the first time (in which case, Reset will return true) or it will set the time the function will run one more time (in which case, Reset will return false).
  • If the timer is created by NewTimer, resetting can only be done on a stopped and drained timer. Also, draining and resetting a timer cannot be concurrent with the goroutine that receives from the timer. The correct way of doing this is shown in the following code block. The important point to note here is that while timer draining and resets happen, it is not possible to receive from the timer channel using the timeout case of the select statement. In other words, while resetting a timer, no other goroutine should be listening from that timer’s channel:
select {
     case <-timer.C:
     // Timeout
     case d:=<-resetTimer:
           if !timer.Stop() {
                <-timer.C
           }
     timer.Reset(d)
}

Different and interesting use cases for timers and especially AfterFunc come up often. For timeouts, context.Context is a more idiomatic tool. We will look at that in the next chapter.

Tickers – running something periodically

It may be a reasonable idea to run a function periodically using repeated calls to AfterFunc:

var periodicTask func()
periodicTask = func() {
   DoSomething()
   time.AfterFunc(time.Second, periodicTask)
}
time.AfterFunc(time.Second,periodicTask)

With this approach, each run of the function will schedule the next one, but variations in the running duration of the function will accumulate over time. This may be perfectly acceptable for your use case, but there is a better and easier way to do this: use time.Ticker.

time.Ticker has an API very similar to that of time.Timer: You can create a ticker using time.NewTicker, and then listen to a channel that will periodically deliver a tick until it is explicitly stopped. The period of the tick will not change based on the running time of the listener. The following program prints the number of milliseconds elapsed since the beginning of the program for 10 seconds:

func main() {
     start := time.Now()
     ticker := time.NewTicker(100 * time.Millisecond)
     defer ticker.Stop()
     done := time.After(10 * time.Second)
     for {
           select {
                case <-ticker.C:
                      fmt.Printf("Tick: %d
", 
                      time.Since(start).Milliseconds())
                case <-done:
                      return
           }
     }
}

What happens if you cannot finish the task before the next tick arrives? Should you worry about receiving a bunch of ticks if you miss several of them? Fortunately, time.Ticker deals with these situations reasonably. Let’s assume we have a task that we trigger using a ticker that may or may not finish before the next tick arrives. This could be a network call to a third-party service that takes longer than expected or a database call under heavy load. Whatever the reason, when the next tick arrives, the task is not ready to receive it because the task is not yet been finished.

Figure 7.2 – Normal ticker behavior versus missed signals

Figure 7.2 – Normal ticker behavior versus missed signals

The behavior of Ticker in this situation is illustrated in Figure 7.2. In the leftmost diagram, the task finishes consistently before the next tick arrives, so execution is periodic with uniform intervals. The middle diagram shows a situation where the first execution of the task is completed before the next tick arrives, but the second execution takes longer, and the application misses the tick. In this case, the next tick simply arrives as soon as the application listens to the channel. The third execution starts later than usual, but the fourth execution recovers the regular rhythm. The rightmost diagram shows a situation where the first execution of the task takes so long that multiple ticks are missed. When this happens, the next tick arrives as soon as the task listens to the channel, and subsequent ticks arrive at the regular rhythm. In short, at most, one message is waiting in the ticker channel. If you miss multiple ticks, you only receive one tick for those missed ticks.

An important point to remember is that you must stop tickers when you are done with them using the Stop() method. Unlike a Timer that will fire once and then be garbage-collected, a Ticker has a goroutine that continuously sends ticks via a channel, and if you forget to stop the ticker, that goroutine will leak. It will never be garbage-collected. Instead, use defer ticker.Stop().

Heartbeats

A timeout is useful to limit the execution time of a function. When that function is expected to take a long time to return, or it does not return at all, timeouts don’t work. You need a way to monitor that function to ensure that it is making progress and that it is still alive. There are several ways this can be done.

One way of doing so is by writing a long-running function to report its progress to a monitor. These reports do not have to arrive uniformly. If the monitor realizes that the long-running function has not been reported for a while, it can attempt to stop the process, alert the administrator, or print an error message. Such a monitoring function is given in the following code block. This function expects to receive information from the heartbeat channel from the long-running function. If a heartbeat signal does not arrive between two consecutive timer ticks, the process is assumed to be dead, and the done channel is closed in an attempt to cancel the process:

func monitor(heartbeat, done chan struct{}, tick <-chan time.Time) {
     // Keep the time last heartbeat is received
     var lastHeartbeat time.Time
     var numTicks int
     for {
          select {
                case <-tick:
                      numTicks++
                      if numTicks >= 2 {
                           fmt.Printf("No progress since 
                           %s, terminating
", 
                           lastHeartbeat)
                           close(done)
                           return
                      } 
                case <-heartbeat:
                      lastHeartbeat = time.Now()
                      numTicks = 0
          }
     }
}

The long-running function has the following general structure:

func longRunningProcess(heartbeat, done chan struct{}) {
     for {
           // Do something that can take a long time
           DoSomething()
           select {
                case <-done:
                     return
                case heartbeat <- struct{}{}:
                      // This select statement can have a 
                      // default case for 
                      // non-blocking operation
           }
     }
}

The ticker determines the maximum allowed duration for the long-running function to remain quiet:

func main() {
     heartbeat := make(chan struct{})
     done := make(chan struct{})
     // Expect a heartbeat at least every second
     ticker := time.NewTicker(time.Second)
     defer ticker.Stop()
     go longRunningProcess(heartbeat, done)
     go monitor(heartbeat, done, ticker.C)
     <-done
}

This heartbeat implementation simply sends a struct{}{} value. It can also send an increasing sequence of values to show progress or other types of metadata so that a progress indication can be logged or displayed to the end user.

There is no guarantee that a hung goroutine will have the chance to read from the done channel and gracefully return. It may just sit there waiting for an event that will never happen, with no indication of progress. This is especially relevant for third-party libraries or APIs over which you have no control. There isn’t much you can do in that case. You can close the done channel and hope that the goroutine will eventually terminate. You should, however, log such occurrences so they can be dealt with outside the program. I have seen instances where such situations are handled by placing the process that cannot be terminated in a separate binary. The second binary performs the long-running tasks, and after a while, it dies because of the unfixable resource leak. It is brought up again either by the orchestration software or by the program itself.

Summary

Timers and tickers allow you to do things in the future and do things periodically. We only looked at a few use cases here. They are versatile tools that show up quite often in unexpected places. The Go runtime provides extremely efficient implementations of these tools. You need to be careful, though because they invariably complicate the flow. Make sure to close your tickers.

In the remaining chapters, we will start putting things together and look at some real-life use cases for concurrency patterns.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.15.248