This is a post in the
metronome-cacophony series.
Other posts in this series:
- May 01, 2018 - Introduction
- May 01, 2018 - Basic synchronous solution
- May 01, 2018 - Ticker
- May 01, 2018 - Goroutine
- May 01, 2018 - WaitGroup
- May 01, 2018 - Sharing state
- May 01, 2018 - Atomicity
- May 01, 2018 - Mutex
- May 01, 2018 - Channels introduction
- May 01, 2018 - Channel select
- May 01, 2018 - Goroutines and channels
- May 01, 2018 - Solution with channel
- May 01, 2018 - Videos and final word
- May 01, 2018 - Appendices
Metronome's Cacophony (2/14) - concurrency in Go - Basic synchronous solution
Synchronous approach
Before we move on I will try to explain what the big word is
synchronous
/ˈsɪŋkrənəs/
adjective
existing or occurring at the same time. "glaciations were approximately synchronous in both hemispheres"
-- by Google
Did it help? Did it resonate with you?
Well, for me it felt like the definition contradicts my understanding of the term in context of software development.
By the looks of it, many people are in the same boat, just look at this StackOverflow debate.
So, my take on it is that synchronous would translate as "connected" and most likely refers to the mechanism of coordinating tasks with each other. It would describe the dependency of subsequent task on the deliverable from preceding task. That dependency means that a task cannot start until previous has finished. Sometimes this deliverable is just the fact previous task completed. The key however is, that this exchange happens at the same time (or is connected).
In other words, synchronous execution of tasks would be an execution where the subsequent task's start waits for the completion of a preceding task.
Note: this definition does not imply anything to do with the number of cores or threads. It is possible to execute two tasks in synchronous manner with each running on a separate thread. In fact using Go's unbuffered channels on separate Goroutines gets you something like that (apart from the fact that you cannot force Goroutines to execute on separate threads). It is more about the way the dependency between them is managed.
In case you like analogies let me bring a food processing analogy of synchronous processing. Imagine a kitchen setting and that you are asked to chop all items for a salad. Another person is waiting for chopped items to appear in a bowl before seasoning and mixing can start. You are given carrots, leeks, potatoes, apples, a chopping board and a knife. You probably will choose to chop all carrots, then leeks, and so on, before passing it further. If we define one task as chopping and another as mixing then you have executed this synchronously as you wouldn't progress onto mixing before chopping is finished.
As you can see this approach makes sense in certain situations.
In programming, this still is the standard way of solving problems when the execution time of individual lines of code is predictable and acceptably short. Examples could be concatenating first and last name, iterating over months in calendar or implementing "times" table for primary school, etc...
So, despite the much prolonged explanation above, synchronous execution is the most straightforward way of writing a program. One that you most likely will be familiar with, if you've done any programming.
Trivial scenario
The simplest (compact) metronome's engine solution I can think of
1func(bpm param.Bpm, performer metronome.BeatPerformer) {
2
3 //measure the volume before the beats are to be performed
4 volume := volumeMeter()
5
6 //loop over beatCount values ranging \[0, numberOfBeats)
7 for beatCount := 0; beatCount < numberOfBeats; beatCount ++ {
8
9 //delegation to beat performer which will (as an example) print Ticks and Tocks
10 performer(beatCount, volume)
11
12 //planned delay so that the beats appear at equal intervals
13 //Bpm.Interval() will perform simple calculation of interval length
14 // to match required bpm value.
15 time.Sleep(bpm.Interval())
16 }
17}
It is an example of synchronous execution.
Every task in this snippet is executed after preceding one has finished. First we obtain the volume (line 4), then we perform the beat (line 10), wait (line 15) and repeat the performing and waiting until predefined number of beats is reached.
Note: You can see the moment we are obtaining a volume
measurement but the body of volumeMeter()
is
nowhere to be seen.
If that bothers you, you can read more about volumeMeter
in Appendix A.
Speaking briefly you can assume that it is a function that provides current ambient volume and is designed to
simulate short- and long-running tasks in controlled manner.
I have mentioned already that I will attempt to visualise the execution of each scenario. Let's have a look at first diagram, derived directly from the execution of the above code.
Note: Diagram convention is explained in Appendix B
Let's get back to our example.
Looking at the second row of the chart it is clear that the volume measurement happened first, followed by predefined number of beats. Compare the first and third row and the frequency looks spot on. The actual sequence took longer that the simulated one only because the simulated one does not take into consideration time taken to measure the volume.
So, it looks like we are sorted!
Ohh... one moment, I am receiving a phone call from a product owner.
Synchronous scenario with volume measurement before each beat
I have been asked to provide a solution to slightly different problem now. I am assured the change would be minimal (yeah ... right). Basically, the single, initial volume measurement will not work well, even in typical scenario. The metronome is started right before the musician plays. That means the measurement is done while it is quiet and then is drowned by the music.
Should be simple, right? Let's look at the solution to this new set of requirements (comments removed for brevity)
1func(bpm param.Bpm, performer metronome.BeatPerformer) {
2 for beatCount := 0; beatCount < numberOfBeats; beatCount ++ {
3 volume := volumeMeter()
4 performer(beatCount, volume)
5 time.Sleep(bpm.Interval())
6 }
7}
It was just a matter of moving volume measurement into the loop, right before the beat is performed.
Let's look at the timeline: Interactive diagram
Oh dear, the actual execution time has stretched.
Of course! The call to volumeMeter
takes some time, so I cannot expect that fixed delay will produce correct
frequency of beats.
We need to anticipate the delay caused by volumeMeter
, even though it is out of our control (in real life,
because here we are simulating it).
Synchronous scenario with adaptive delay
Optimistic scenario
Looks like a fairly simple exercise. Here is the code:
1func(bpm param.Bpm, performer metronome.BeatPerformer) {
2 for beatCount := 0; beatCount < numberOfBeats; beatCount ++ {
3 //let's find out the duration of volume measurement...
4 start := time.Now()
5
6 volume := volumeMeter()
7
8 performer(beatCount, volume)
9
10 //...so that we can adapt the planned delay accordingly (or skip it if took too long)
11 if adaptiveDelay := bpm.Interval() - time.Since(start); adaptiveDelay > 0 {
12 time.Sleep(adaptiveDelay)
13 }
14 }
15}
I have used one of the most basic benchmarking methods there is in Go:
1 start := time.Now()
2 //"do stuff" you want to time
3 fmt.Println(time.Since(start))
it will print elapsed time (read: time it took to "do stuff") with nanosecond precision.
13.314µs
2
3Process finished with exit code 0
Function time.Since
will return the time.Duration
type which ships with a handful of methods to make
conversion, rounding, and truncating easy. We see a nice unit symbol because time.Duration.String()
method
is delivering the appropriate textual representation of the duration value.
Java analogy: the simplest drop-in replacement would be System.nanoTime()
which gives means to obtain
reading from most precise system timer. When it comes to accurate measurement of elapsed time in Java I would
use this construct. However, it returns primitive long so does not ship with any tools to manipulate or
present the result nicely though.
We could also use System.currentTimeMillis()
for rough, low resolution measurement that does not
have to be exactly right. Use it with caution for short time-spans. Don't use it for critical parts.
As it is expected, there is plethora of other classes that let you solve similar problem, whether in frameworks
(StopWatch
in Spring, Guava and Apache Commons) or core Java (java.time.Instant.now()
from Java 8 onwards,
java.util.Date.getTime()
most of other methods in this class is deprecated in favour of new Java 8 Date Time API).
And the chart will verify our presumptions: Interactive diagram
Marvellous! All looks good.
Wait... I suddenly remembered that our hardware specialist once told me, that this volume sampler (that I am testing with) is the best in its class. The majority of devices on the market are not performing too well. Let's plug a different model in, to see if that's the case.
I always valued conclusions drawn from an analysis on a base sample of 2.
Note: To prove the case, the volumeMeter
is now going to be using TriangularPatternDuration
so that
the execution time of volumeMeter()
will keep changing in a predictable manner between short and overrunning.
You can see how second row of the diagram will illustrate changing execution time of volume measurement.
Oscillating execution time of volume sampler
And the results are in. Interactive diagram
Oh no!!! This isn't going to please anybody. Metronome that can't keep up? Rubbish. The beat timing (green markers) is inconsistent. If you don't see it, look at the vertical lines of ghost beats which project the ideal timing onto time axis.
Our solution needs rework.
I've read something about the Ticker
and how superior it is to provide timing at a cost of few keystrokes.
This is a post in the
metronome-cacophony series.
Other posts in this series:
- May 01, 2018 - Introduction
- May 01, 2018 - Basic synchronous solution
- May 01, 2018 - Ticker
- May 01, 2018 - Goroutine
- May 01, 2018 - WaitGroup
- May 01, 2018 - Sharing state
- May 01, 2018 - Atomicity
- May 01, 2018 - Mutex
- May 01, 2018 - Channels introduction
- May 01, 2018 - Channel select
- May 01, 2018 - Goroutines and channels
- May 01, 2018 - Solution with channel
- May 01, 2018 - Videos and final word
- May 01, 2018 - Appendices