Metronome's Cacophony (3/14) - concurrency in Go - Ticker

Synchronous approach (ctd)

Ticker

Ticker is a very convenient tool used to schedule tasks to happen at regular interval. It is created with an invocation of time.NewTicker and needs a time.Duration parameter. It will create a channel, which will be receiving time.Time messages at intervals equal this duration. Ticker channel is available at ticker.C.

Simple example:

 1package main
 2
 3import (
 4  "time"
 5  "fmt"
 6)
 7
 8func main() {
 9   // make note of the time program started (so we can stop it after elapsed time
10   start := time.Now()
11
12   // create Ticker which will send messages with current time to channel Ticker.C every 111ms
13   ticker := time.NewTicker(111 * time.Millisecond)
14
15   // drain channel from messages until the channel is closed (never going to happen here!)
16   for t := range ticker.C {
17
18      // print t (of type time.Time), which is the next message received from the channel
19      fmt.Println(t)
20
21      // if the code run for over a second, break loop
22      if time.Since(start) > time.Second {
23         break
24      }
25   }
26
27   // this will stop the Ticker from sending more messages
28   ticker.Stop()
29}

Output from the above example:

 12018-05-01 12:55:38.265970476 +0100 BST m=+0.111373928
 22018-05-01 12:55:38.37699173 +0100 BST m=+0.222395179
 32018-05-01 12:55:38.488017207 +0100 BST m=+0.333420642
 42018-05-01 12:55:38.599015454 +0100 BST m=+0.444418887
 52018-05-01 12:55:38.709945783 +0100 BST m=+0.555349266
 62018-05-01 12:55:38.821024122 +0100 BST m=+0.666427555
 72018-05-01 12:55:38.93198204 +0100 BST m=+0.777385485
 82018-05-01 12:55:39.043010703 +0100 BST m=+0.888414136
 92018-05-01 12:55:39.153975803 +0100 BST m=+0.999379252
102018-05-01 12:55:39.26494749 +0100 BST m=+1.110350972
11
12Process finished with exit code 0

Note: there is also time.Tick available, which is a convenience wrapper for time.NewTicker but it does not allow for Ticker shutdown and, as such, might cause memory leaks. In some cases it will be sufficient but be careful with it. To be frank, the channel Ticker.C also cannot be closed, even by invoking Ticker.Stop() (look at comment to Stop() method in the source). By using time.NewTicker & Ticker.Stop() however, you can at least stop dispatch of further messages.

Note: expect to read more about the channels towards the end of this series. In this context they are just a side-kick, while normally they are the superheroes of Go concurrency models.

One more example for running a piece of code once, after given interval:

 1package main
 2
 3import (
 4   "time"
 5   "fmt"
 6)
 7
 8func main() {
 9   // print current time so that we can see proof of how long the execution took
10   fmt.Println(time.Now())
11
12   // Ticker will send first message after a second
13   ticker := time.NewTicker(time.Second)
14
15   // this will stop a Ticker once this function ends
16   // it is a Go's idiomatic approach to defer cleanup calls 
17   // on resources as close to where they are created 
18   defer ticker.Stop()
19
20   // this will block further execution until message arrives
21   <- ticker.C
22
23   fmt.Println(time.Now())
24}

Output:

12018-05-01 13:26:26.75039728 +0100 BST m=+0.000190487
22018-05-01 13:26:27.750577483 +0100 BST m=+1.000370695
3
4Process finished with exit code 0

Java analogy: java.util.Timer in conjunction with java.util.TimerTask will get you similar functionality to that of a Go Ticker. You can even specify the associated thread name, run the timer's thread as daemon or remove cancelled tasks from queue. I don't feel like I am missing out yet.

Synchronous scenario with Ticker timing

Now that we know a bit more about Ticker, let's see how we can employ it to provide, yet another, solution to our problem.

 1func(bpm param.Bpm, performer metronome.BeatPerformer) {  
 2  
 3   beatCount := 0  
 4  
 5   //create Ticker instance, initialised with desired interval  
 6   ticker := time.NewTicker(bpm.Interval())  
 7     
 8   //defer ensures the Ticker stops sending messages after we leave this function (sic! function, not block)
 9   defer ticker.Stop()  
10  
11   //for each Ticker's timing message  
12   for range ticker.C {  
13  
14      volume := volumeMeter()  
15  
16      performer(beatCount, volume)  
17  
18      if beatCount++; beatCount >= numberOfBeats {  
19         break  
20      }  
21   }  
22}

Ok, that reads quite different to previous scenarios.

I have replaced the beat counting for-loop from previous solution with this approach, so I can utilise a handy construct.

The internals of Ticker sends one message per interval to its channel. We use for range channel construct to drain this channel. It means it will keep fetching messages until the channel is empty. The loop won't break out until the channel is closed or there is an explicit instruction (such as break in this case). If there are no messages in the channel but the channel remains open, this construct will block execution. That's why there is no explicit time.Sleep() invocation.

Let's be optimistic and try this code with the good sampler first. Execution timeline

Interactive diagram

Spot on frequency! However, there is some strange gap before the first volume measurement happens. There seems to be a delay of about an interval duration where I didn't expect it.

I will point out that the Ticker does not send its first message as soon as time.NewTicker is invoked. The first message will be sent after given interval.

Armed with the knowledge of Tickers and some basic appreciation of how channels work we can address this.

Synchronous scenario with immediately firing Ticker

 1func(bpm param.Bpm, performer metronome.BeatPerformer) {  
 2  
 3   ticker := time.NewTicker(bpm.Interval())  
 4   defer ticker.Stop()  
 5  
 6   for beatCount := 0; beatCount < numberOfBeats; beatCount ++ {  
 7  
 8      volume := volumeMeter()  
 9
10      //my preference is to wait for the right time here, with pre-fetched volume  
11      // because timing feels more important in our case than freshness of volume
12      <-ticker.C  
13  
14      performer(beatCount, volume)  
15  
16      //<-ticker.C //alternative position  
17   }  
18}

I've removed for range channel construct because it does not allow for any tweaking, which we need.

I've used a channel receive operator <- channel which you have seen in the Ticker example in previous section. In the exact form here it will block, until there is a message, or the channel is closed. Because it is an expression, it returns value(s) but we are not interested in them here (more about this later). We just want to wait until the time is just right to perform a beat.

Note: I could have decided to place <- channel in line 16, just after the performer invocation. Doing so however, will mean that the volume reading will be as fresh as possible but the beat rate will fluctuate. I feel a metronome should, first and foremost, be about frequency though.

Chart for the above example:

Interactive diagram

Good result. But let's test it with worse volume sampler.

Same code, just with oscillating execution time of volume sampler:

Interactive diagram

Reminds me of execution #4 with adaptive delay.

Because we have the power to simulate various conditions, let's take a look at execution chart when simulating random execution time of volume sampler:

Interactive diagram

You can see for yourself that this problem may be harder to solve than expected. We may need to do something extraordinary. Or do we?

Conclusions, so far

The substandard performance of our volume sampler is analogical to what happens in real-life when solving problems other than made-up, like this one. Most common type of lag we have to deal with on a daily basis is probably caused by narrow network bandwidth or overloaded servers (IO bottleneck, CPU load, etc.). Even locally we experience slowdowns, for example when writing to disk. The speed is usually much greater than that of network but the volume of data is also greater. We also expect disk writes to be instant but we got used to slower network. Even on lower level, our programs communicate with local hardware components where data bus may be slow and unreliable. Go can be used in all these areas. We need to learn Go's concurrency tool-set to build software that deals properly with overrunning tasks.

Looking at all the charts so far, we can see that when the volume measurement was sluggish, it renders our product useless. And I don't mean to say that product slightly worse, I actually mean useless. We need to limit the impact this volume measurement has on the program.

All solutions to this point were synchronous. Every beat was preceded by volume measurement (apart from the solution to deprecated set of requirements). The problem is that the volume measurement can take even 2.5 times the interval length (that's what the simulation does). There is no magic here, we need to pull the long-running activities out, and get them to execute alongside the timing loop but without the damaging impact on other parts of the application.

Go is equipped with just the right enabler - Goroutines. Let's learn how to use it.


Other posts in metronome-cacophony series