This week we’re going to tackle Concurrency in Go; specifically Channels, Goroutines and WaitGroups. We’ll mention Mutexs also.
We have seen snippets of these in previous tutorials, although we’ve intentionally avoided getting into the detail so it wouldn’t detract from the main focus of those tutorials.
If, for example, you’ve been following along and you read my tutorial on Context, you may remember that I said Go has the concept of concurrency, whereby we can instantiate tens, hundreds, even thousands, of light-weight routines, each with a very small initial stack size of 2KB.
These routines run, more or less, at the same time; we call them Goroutines.
You probably also recall how we said we have two problems when developing concurrent programs: that of maintaining control over the processes we spawn as Goroutines and; that of communicating between Goroutines, and our main process (also a Goroutine), so we may obtain results, error messages and completion notifications.
This communication problem can be tackled in a number of ways. We can, for example, update state in a variable that all Goroutines can access, possibly a variable which exists in the global namespace…
This isn’t ideal.
Putting aside the use of the global variable - another discussion for another day - it becomes very easy to unknowingly, allow race conditions into our code when moving away from sequential execution, to the execution of things at the same time, or at least in no dependable order, as is the case with Goroutines.
A race condition is literally what it implies. The behaviour of our code will vary depending on which operation executes and completes first, and this could change run-to-run, for a multitude of reasons.
Clearly, this isn’t really a solution at all.
We can eliminate race conditions using Mutexes in our code. A Mutex which stands for “mutual exclusion” is a lock which enforces access by only one process. Other processes outside of the lock are excluded.
Mutex locks can be write locks or read-write locks. In the latter case, a variable may not be accessed at all - not even for reading - while it is under the lock.
Mutexes aren’t our focus here because, while they do provide safety of access, they block other goroutines, until the lock is removed.
We can do better using Channels.
Channels allow Goroutines to hand-off their data, in some cases without waiting, so they can continue their specific run-time operations.
By default Channels are unbuffered. This means that in order to hand-off data there needs to be another process ready to accept that data.
Should we find that our Goroutines are blocked - waiting to hand off their data to the Channel - but there are no Goroutines available to read from the Channel - it may be appropriate to add a buffer to the Channel.
Using buffered channels, Goroutines which wish to hand off their data - which we call “sending on the channel” - do not have to wait for the other Goroutines to be available to accept data from the Channel - something we call “receiving on the channel”.
Syntax
Let’s quickly cover the Go syntax for creating Channels as well as performing send and receive operations.
Channels are composite types. They can hold basic types or other composite types, but Go - being statically typed - requires us to specify what type a Channel will hold.
To create Channels we use the make keyword, and in this first example, we create two Channels, one unbuffered, and the second, a buffered channel. Both hold integers.
unbufferedChan := make(chan int)
bufferedChan := make(chan int, 1)
bufferedChan
has a buffer size of one, which means our Goroutine can send on the Channel, even if there is no Goroutine available to receive from the Channel.
In the next example, we illustrate the syntax for putting a value on the Channel, by sending an integer, and receiving it from the Channel.
We’ll use the unbuffered Channel we created above, unbufferedChan.
package main
import (
"fmt"
)
func main() {
unbufferedChan := make(chan int)
go func(){
fmt.Println(<- unbufferedChan)
}()
unbufferedChan <- 10
}
The example is here if you wish to try it: https://play.golang.org/p/9brgotyeQRf.
Notice how we receive on the Channel in a separate Goroutine, and how that Goroutine is blocking execution waiting to receive on the Channel, even before we send to it?
Is it blocking?
Yes, we know it is blocking, otherwise, this program would exit immediately. Try printing a string instead and see for yourself.
Now, let’s provide a similar example to demonstrate the mechanics of buffered channels.
package main
import (
"fmt"
)
func main() {
bufferedChan := make(chan int,1)
bufferedChan <- 10
//bufferedChan <- 11
close(bufferedChan)
for i:= range bufferedChan{
fmt.Println(i)
}
}
Again, the example is here for you to try: https://play.golang.org/p/TeEFtxQ44U3. Try it now in fact.
First, run the existing code, and then uncomment that additional send operation, where we attempt to send the integer 11 on the channel.
See what happens when you exceed the buffer size?
Now increase the buffer size to 2 and run it again.
Using buffered channels we can place data on a Channel, which isn’t dependent on there being a goroutine available to receive it.
Buffers can be useful, particularly when there is a set amount of work to be done. With a buffer of adequate size, the work of the Goroutines can be done and the results left on the Channel, for subsequent processing.
Share Memory by Communicating
The most important aspect of Channels is that they provide a memory-safe way of communicating between Goroutines, thus eliminating race conditions.
We’ll come to “how” very shortly, but first this:
Many programming languages communicate by sharing memory, but in Go, we share memory by communicating.
If you’ve read anything about Go Channels to-date, you’ll have come across this quote I’m sure.
But, do you understand the distinction here? It’s a little nuanced I think?
So, the next thing we’re going to do in this tutorial is to demonstrate the difference between communicating by sharing memory and sharing memory by communicating.
And, for this, we need to get back on the tools!
Back to the Shopfloor
Yes, we’re going back to our factory - ACME Mac Factory!
Why?
Well, not only did several people comment that this was a great case study when we covered Interfaces, but I also received several questions asking what “ACME” stood for!
A superb example of how it is easy to assume knowledge - and my sincere apologies.
So, let’s clear this up first before we go any further - you *need* to know about ACME!
Acme Corporation is a fictional company which appears in many Warner Brothers Cartoons, typically Road Runner, Wile E. Coyote and some Bugs Bunny.
Acme, which means “pinnacle” I believe, generally make devices for destruction and harm.
Fortunately, these products invariably don’t work very well - at least not in the hands of Mr Coyote!
There’s a great example below.
The launch control software on this Rocket was probably written in C?
Anyway, let’s get back on topic. In this tutorial, we will continue the long tradition of using the fictional ACME Corporation. We’re a subsidiary remember, we do that sexy Manufacturing as Code (MaC) stuff - and we do it in Go!
ACME Mac Factory should let us build on the scenario we set in the Interfaces tutorial, and it’ll work well for demonstrating Concurrency I think. We shall see.
Enough background… we’re in back in the Factory right now, and we’re in the weekly executive team meeting with the Boss.
Operations are up next with their update, so listen up!
Operations Update: 23/10/2020
We’re producing Cars, Motorcycles, Caravans, Trailers and Washing Machines to acceptable levels of quality, subjecting them to all the finishing processes the Boss deems necessary.
However, all this extra processing and focus on QA has impacted production quantity to a very small extent: we are now making just two of each product per week, down from ten of each per week.
The Boss falls out of his Executive Chair.
Production is down by 80%…!? A very small extent...!?
The Boss looks at you, the only Software Engineer in the room: you need to sort this out, he says.
I want quality AND quantity, he says.
Make the codebase work faster, he says.
Get it done, and get it done NOW, he yells.
Not the best meeting you’ve been in, you think to yourself, as you watch a colleague belatedly help the Boss up from the floor.
What ARE you going to do?
Refactor with Goroutines
Having heard a thing or two about Concurrency in Go you immediately have an idea.
Everything in the ACME Mac Factory production process is slow because it all happens sequentially - one thing follows another.
That’s obvious, we have just one production process, and everything goes through that single process at the moment.
If we add more stages to the process, as we did in the Interfaces tutorial when the Boss banzai-screamed, “QUALITY”, the process is going to take longer. Simple as that.
Moreover, if there’s a stoppage or a stage that runs slowly for whatever reason, say perhaps we can’t affix a particular part in the normal time, then that stage blocks everything that follows it. Obviously.
“We need to move from the one production process to several processes we can run at the same time; perhaps one production process per product..?”, you muse.
You half notice the Boss straightening his hair - is that his hair - and recovering his composure, but there’s something in your idea of doing more at the same time… hmm.
The Process Mapping Guy pipes up from across the room; Post-it notes unclipped; safety off.
“We should map and benchmark the current production process” he suggests.
“If only to understand how any changes impact it” he follows.
Though you half-hoped for a game of “Who am I?” when you saw the Post-it notes, you know deep down that Process Mapping Guy is right.
More to the point, the Boss agrees with Process Mapping Guy.
“Get it done”, the Boss says.
Code Review
So, let’s have a look at where we currently are.
We’re able to make two units of each product a week. The Boss wants us back making 10 units of each product a week.
So, how long will it take us to make those 10 units of each product - 50 products in total - based on our current production process?
Check out this example on the Playground: https://play.golang.org/p/V1Sl34Xd2WE.
I’ve only reproduced the results here for the sake of brevity, but the full example can be reviewed and run on the Playground. You may get slightly different results, but these are mine.
Production starting
run completed, stockCount:5, restarting line
run completed, stockCount:10, restarting line
run completed, stockCount:15, restarting line
run completed, stockCount:20, restarting line
run completed, stockCount:25, restarting line
run completed, stockCount:30, restarting line
run completed, stockCount:35, restarting line
run completed, stockCount:40, restarting line
run completed, stockCount:45, restarting line
run completed, stockCount:50, restarting line
Production ended
50 units manufactured in 1m26s
Program exited.
Operations tell us we can only produce two units of each product in the production time available.
If we’re going to make 10, it looks we need to reduce our production time per unit by about 80% on average?
So we’re aiming to make 10 of each in approximately 17 seconds.
Don’t get hung up on the math or numbers here - they’re incidental. Just know that we’ve established a target of 17 seconds for our code execution - down from 1 minute 26 seconds.
Our refactoring will focus on this target.
Sharing memory, a naive implementation
We refactor our codebase to use Goroutines. We’re aiming to produce units concurrently.
In the next example, which you can find on the Go Playground here, https://play.golang.org/p/oAN5_xknL4c, we’ve changed things just a little.
We still restart our production run 10 times, but using Gorotuines we’re restarting 5 separate production lines - one for each product. These lines operate concurrently.
The longest we need to wait for any single production run is the time it takes to make and test the most time-intensive product.
Here are the results.
Production starting
run completed, stockCount:0, restarting line
run completed, stockCount:5, restarting line
run completed, stockCount:10, restarting line
run completed, stockCount:15, restarting line
run completed, stockCount:20, restarting line
run completed, stockCount:25, restarting line
run completed, stockCount:30, restarting line
run completed, stockCount:35, restarting line
run completed, stockCount:40, restarting line
run completed, stockCount:45, restarting line
Production ended
50 units manufactured in 32s
Program exited.
We can see we’ve reduced production time to just 32 seconds, so this is great news, but there’s something odd.
What’s with the zero stockCount after the first run?
Evidently, we produce 50 units, but we seem to be under-reporting stockCount all the way through production?
The reason is that we’ve introduced a race condition on the stockCount variable. The code that prints the current stockCount after each run is accessing the variable before it’s been updated.
See for yourself, uncomment line 118 and run again. Increase the sleep time to 2 seconds and then 3?
More significantly, there’s also a race condition on writing to the stockCount variable. Our Goroutines could conceivably try to update it at the same time.
They don’t in this example, but we shouldn’t rely on this code.
What we’re doing here is communicating by sharing memory. The stockCount variable holds a value that we are sharing in order to manage some state in our program.
The issue is that we have a number of execution paths try to increment the same variable, and, since we’re using concurrency we no longer control when they try to increment it.
What if two Goroutines completed and read the value of stockCount at the same time, and incremented it.
What might happen?
Sharing Memory by Communicating
We can improve the previous code using Mutexes remember, but, in doing so, we’d be missing out on a great Golang language feature, Channels.
In this example, we’re going to refactor the code again, but this time to use Channels - the Go type which allows us to share memory by communicating.
Our code example is here on the Playground: https://play.golang.org/p/my4Q3mgdgvA
Production starting
run completed, stockCount:4, restarting line
run completed, stockCount:9, restarting line
run completed, stockCount:14, restarting line
run completed, stockCount:19, restarting line
run completed, stockCount:24, restarting line
run completed, stockCount:29, restarting line
run completed, stockCount:34, restarting line
run completed, stockCount:39, restarting line
run completed, stockCount:44, restarting line
run completed, stockCount:49, restarting line
Production ended
50 units manufactured in 32s
Program exited.
Okay, we’re still hitting 32 seconds, same as before, so what has changed?
Now that we use Channels, each Goroutine is essentially adding a value to a queue. We have another Goroutine that is taking the values off that queue, and updating stockCount.
We could have hundreds of thousands of Goroutines sending on the channel, and we’d still only have one way of updating the stockCount number.
This avoids the *write* race condition, but as you can see our first run seems to under-report stock by 1 unit.
This is to be expected, the example is a bit basic. We’re using Go WaitGroups, which allow us to block while our Goroutines complete their work.
In this example, the last Goroutine to finish sends on the Channel, and informs the WaitGroup but before we have time to receive it, the program continues execution, prints the current stockCount number and then exits.
I should definitely improve the example, but it’s sufficient for now.
Wait wha.. WaitGroup?
We haven’t covered WaitGroups yet. Let’s do that now.
All we want to know, before moving on with our code, is that all our Goroutines have done their work.
Without Waitgroups, and because Goroutines return immediately, our program would move on and most likely terminate before the work was done.
Waitgroups allow us to wait for one or a group of Goroutines to finish. This is different from Context - we can’t set cancellation rules - we can only wait for completion.
Waitgroups are part of the sync standard library package just like Mutex.
Here’s a quick example.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i:=0; i<100; i++ {
wg.Add(1)
go sayHello(i, &wg)
}
wg.Wait()
}
func sayHello(i int, wg *sync.WaitGroup){
defer wg.Done()
fmt.Printf("Hello again. Run number %v\n", i)
}
The example is here on the Go Playground: https://play.golang.org/p/YZcqhxEf-64.
We create a WaitGroup, increment it for every Goroutine we start using wg.Add
, and then tell our program to wait with wg.Wait
The function called uses wg.Done
to inform the WaitGroup that it has completed its work. Generally, we use defer
, so that we know wg.Done
will be called, but we could also have called wg.Done at the end of the function.
In this example, our Goroutine calls a normal function, not an anonymous function, and so we need to pass the WaitGroup to the function as a reference so that we can call wg.Done
on it.
When using anonymous functions as we did in our production line codebase, we benefited from closures which for our purposes means the WaitGroup variable was available in the function without us needing to pass it as a parameter to the function.
The last takeaway from the example; notice the order in which the greetings print?
They are not in numerical order. This demonstrates how we can’t depend on an execution order when we use Goroutines.
Let’s Recap
Before we try to appease the Boss and make our production target times, let’s quickly recap, as we’ve covered a lot of ground.
We covered syntax/semantics of Channels in Go, we agreed that if we are going to communicate by sharing memory we should ensure we avoid race conditions, probably by using a Mutex.
We decided Channels might work better, and allow us to instead share memory by communicating.
We’re back in the factory, trying to get production up from a meagre 10 units to 50 units per week.
We benchmarked a production target time of 17secs. We’re roughly achieving double that currently, which is a big improvement on 1min 32secs.
We digressed a little with an explanation of WaitGroups.
Directional Channels
You may come across this term but just know there is no such thing.
A Channel on which you can only send or receive would be useless. For a Channel to be useful it must be able to act like the pipeline which it is.
What there is, however, is a way to specify how a Channel can be used when it is passed as a dependency to a function or receiver.
Think of this as an extension to Go’s type safety. We don’t just specify the Channel type but we make what the function can do with the Channel explicit in the type.
We won’t cover it further in this tutorial, and I don’t think I’ve ever used it. Maybe I should since it’s definitely better for readability.
Now, lets hit that target time, so we can all go home
We’re now pretty well versed in Goroutines, Channels and WaitGroups, but can we hit the target time to produce 50 units?
Let’s refactor one more time.
Looking at our code, although we produce 5 products at the same time, we still have a sequential element in the production process, we run the line 10 times, just like we did in the original codebase.
How might we improve this?
One idea might be to do even more work concurrently so that we don’t need to restart the production line 10 times - what if we aimed to run it, say, 5 times?
We’d need to double the output from each run to make our target of 50 units. Let’s try it and see.
Here’s the example of on the playground: https://play.golang.org/p/L1Um12_851R.
It’s a fairly long program again so I won’t reproduce it here. Below is just the program output.
Production starting
run completed, stockCount:9, restarting line
run completed, stockCount:19, restarting line
run completed, stockCount:29, restarting line
run completed, stockCount:39, restarting line
run completed, stockCount:49, restarting line
Production ended
50 units manufactured in 16s
Program exited.
We’ve done it! 50 units produced in 16 seconds.
Process Mapping Guy runs off to inform the Boss.
Summary
Well done us! Thanks to our Go concurrency skills, we now have acceptable levels of both quality and quantity at Acme Mac Factory.
We could go further still with this. What if we refactored the code to use fewer production runs, with more units produced concurrently each time. What impact could that have on the production time?
What if we dispensed with that sequential line entirely, and produced every unit at the same time using Concurrency?
We can bet that production times would plummet but, as savvy Engineers, we decide to keep this up our sleeves.
The code we have is fast enough - which makes it good enough - at least until next week’s Executive meeting!
If you enjoyed this tutorial, please share it with other aspiring Gophers and follow me on Twitter. If you subscribe to my newsletter, I’ll send the tutorials directly to your inbox as soon as they are published.
Love the way you narrate it as a story. Another is to engage in a "Teach-Disciple" conversation.
Say, "Yoda" and his student "Grasshopper" :)