I’ve been using go a lot lately for my own projects and have so far found the learning curve to be pretty straight forward. Most of the concepts feel very familiar if you’ve come from any imperative, c based language. However, one new concept, and one of go’s major selling points, is its support for concurrency using CSP, or in go terms: go routines and channels.
CSP, or communicating sequential processes, is a concept developed by Tony Hoare to model concurrent actions within applications — i.e. actions that may happen at the same time rather than strictly sequentially. Go implements these ideas by allowing you to execute code in a go routine, which essentially creates a new thread to execute the code. Crucially, go doesn’t use real OS threads, but rather green threads so this ends up being really cheap and allows for thousands of concurrent processes to be created. The other half of go’s CSP implementation are channels, which are queues that can be used to pass data between go routines and the main thread.
To get a better grip on how all this works I wanted to implement some basic concurrency patterns found in the real world. One obvious example is the sometimes confusing experience of ordering coffee at Starbucks. In case, like me, you haven’t been to a Starbucks in a while lets review the process:
Customer
places order with aBarista
Customer
givesBarista
their nameCustomer
waits for orderBarista
takes order from a listBarista
makes the drinkBarista
shouts an approximation of aCustomer
’s nameCustomer
collects drink
There are a few details I’m skipping over here like having a separate person serve the customers from the baristas, but this should model the main ordering process well enough.
Simple example
We’ll start with a simplified example: a small independent coffee shop that has one employee performing all tasks.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
First, we define a Barista
type, which takes two channels orders
and drinks
, which represent the incoming customer orders and completed drinks respectively.
There are also two methods defined on the Barista
type. makeDrink
represents the act of making a coffee by simply printing the order and waiting for a short period of time. I’ve selected one second here to make running the code faster than real life. waitForOrders
starts an infinite loop that continuously takes drink requests from the orders
channel, makes the required drink using makeDrink
, and then finally puts the completed order on the drinks
channel for the customer to take. Because makeDrink
waits for one second this has the effect of completing a new order every second.
One final point to note is Barista
’s constructor function. It takes the two required channels and creates a new Barista
type, but also calls the waitForOrders
method in a go routine. This is important because if it was called on the main thread the program would block and never return the new Barista
due to the infinite loop in waitForOrders
. By running this function in a go routine we “park” the execution of this method in a new thread and return immediately.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Next up we need to define a Customer
type to consume the drinks Barista
is producing. The set up for this is pretty similar to Barista
: we pass in two channels to represent orders and drinks and then define methods to request an order and take a completed drink.
I won’t go through each line here because it should feel quite familiar from the last example, but one important point to note is that the infinite loop to collect a drink is only triggered once an order has been placed - i.e. in the order
method instead of when the Customer
is initialised. Again, this is called in a go routine to prevent the loop from blocking the main thread.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Finally, we kick all of this off in the main
function by creating a couple of channels and passing these to a Barista
and two Customers
. Once the type have been created we can call the order
method on each customer to request a drink from the Barista
.
This should output the following to stdout:
1 2 3 4 5 6 7 8 9 10 11 |
|
Parellising with Multiple Baristas
So this works well for a small coffee shop, but in the case of a busy Starbucks branch we’ll clearly need to improve things. The obvious way to do this is to add multiple Baristas
so that more than one drink can be made at a time. In other words, we want to parallelise the task. It’s important to make the distinction between concurrent and parallel, but as we’ve already made the simple version correctly concurrent adding parallelism is trivial.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
The only change we’ve made here is to create two Baristas
to handle the workload. If we now run this it should produce the same output, but the ordering of the two drink orders will now be interleaved because they are happening at the same time. For example:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
However, a problem arises if we add some more real world complexity. A Flat White takes about 2 minutes to prepare whereas a filter should take up to 4 minutes to correctly brew. In our current application, Rachid would collect the wrong drink because we rely on an implicit ordering of the drinks
channel.
First, we need to add variable wait times depending on what drink is being made.
1 2 3 4 5 6 7 8 9 |
|
If we now run this code without any further changes we can clearly see the problem on line 10: “Rachid collected a Flat white”.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Synchronizing orders
Starbucks solve this problem by taking a customer’s name when they order so lets try to take a similar approach.
First, we need to define a new type Drink
, which holds the name of the drink ordered and a channel that we’ll put a value on once the order is complete. This represents the action of a barista shouting a customer’s name.
1 2 3 4 |
|
Next, we need to modify the Customer
order methods to pass a Drink
instance and use this new channel rather than the global drinks
channel we previously blocked on. Note that we’re creating a single channel and passing that to both the collectDrinks
method and passing it to the orders
channel. This allows us to synchronize our actions between the separate Barista
and Customer
go routines.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Finally, we need to update Barista.waitForOrders
to use the Drink
type correctly and pass completed orders to the current drink channel.
1 2 3 4 5 6 7 8 |
|
If we run the code again we should now see both customers receiving the correct orders.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Conclusion
There are a few other improvements we could make to more correctly model the real world, like adding a Cashier
abstraction to handle taking orders from Customers
, or adding some kind of bandwidth restrictions on how many drinks can be made on the available espresso machines. However, hopefully this demonstrates the basics of CSP and how to use them in go.
Thanks to Rob Berry, Rachid Belaid, Janosch Oppermann, and Ryan Slade for reviewing this.