Go, Generics, and Concurrency
Using generics to implement type-safe and reusable async/await in Go
Adding generics to Go addressed one of the most common complaints about the language. Many people wanted generics so they would have the ability to create type-safe, reusable data structures. While that’s certainly important, the more interesting thing is how generics help us simplify algorithms. Let’s look at how to make concurrency simpler in Go using generics and reimplement the async/await pattern in other languages in Go in a way that feels like Go.
Go uses goroutines, channels, and select statements to provide an easy-to-understand model of how data flows through concurrent code. The drawback to this design is that there’s a lot of boilerplate. It’d be nice to replace that repetitive code with something closer to what you see in languages that use the async/await model for concurrency.
In my book, Learning Go, I give the following example of something that’s easy to implement with Go concurrency:
Say you are writing a web service that calls three other web services. We send data to two of those services, and then take the results of those two calls and send them to the third, returning the result. The entire process must take less than 50 milliseconds, or an error should be returned.
At the end of the concurrency chapter, we build out an example of how to implement this, but it would be better if we could abstract away the data types away from the process. Any time you want to have a repeatable process with different data types, you should consider generics as part of the solution.
Here’s a list of tools we will create to help us build this example:
- Run any function asychronously
- Wait for multiple functions to complete
- Automatically add context cancellation support to a function
- Take the output of one concurrent function and use it as the input for another concurrent function
We’ll start with running any function asynchronously. Of course, Go already provides a way to do that: the go
keyword. The drawback to using it is that the only concurrent-safe way to get data out of a goroutine is to use a channel (technically, you could also write to a mutex-protected shared variable, but that makes it even harder to understand how data flows through your application). Changing your function to write to a channel instead of simply returning a result is intrusive and makes your code harder to test. This is why it is recommended that you write your concurrent logic in a normal function, and then start the goroutine using a closure that handles the concurrent work for you. For example, let’s look at solving the first part of our problem. We’re going to call two functions concurrently with the same data, wait for their results, and return them. Implementing this looks like:
RunProcess
isn’t terribly hard to understand, but it’s a lot of code to write, and it’s impossible to re-use this logic with different functions. Let’s see how generics makes this reusable.
First, we need a way to represent any function. Here’s a good way to do that:
Most function calls in Go should have a context.Context
for the first parameter and an error
for the last return value. The only downside to the definition of Func
is that it has a single additional input parameter and a single additional return value. This isn’t a very big limitation, because we can wrap any function with more parameters or output values in a closure that meets the type definition and declare types that contains all of the other input parameters or return values. Here’s a simple example:
We’ll keep updating RunProcess
as we build out our tools.
Next, we need to represent the value returned from an asynchronous function. We’ll store the result in a generic Promise
struct:
The Promise
has the return value and the error, along with a done
channel. Notice that the val
and err
fields are unexported; we want to block access to these until the values are populated.
We launch our concurrent functions with the Run
function:
Pass a context.Context
, the data to process, and a function that meets the Func
type into Run
, and it immediately returns back a *Promise
that will (eventually) contain the result of runing the supplied function with the supplied data. Unlike languages that have async
built into them, there’s no special keyword that indicates that a function always runs concurrently. That means we can take any function that meets our Func
type and launch it with Run
. As we saw earlier, it’s trivial to make any function match this type using a closure.
Now we need a way to get the function output from the Promise
. We do this using the Get
method on *Promise
:
Run
and Get
work together to ensure that the data is written to the Promise
before it is accessible. We never write to the done
channel stored in Promise
. Instead, it is closed to signal when processing has completed by using the done channel pattern.
Reading from a channel with no available data blocks until data is written to the channel or the channel is closed. Once a channel is closed, any attempt to read from it returns immediately. The goroutine launched in Run
closes the done
channel after the values from the function are stored into the Promise
instance. Any call to Get
will pause until that channel is closed, but once it is closed, it will immediately return the data stored in the Promise
.
Let’s update our RunProcess
function to use Run
and Get
:
This version of RunProcess
hides all of the goroutine launching and channel management. It’s a big improvement in readability, but it’s not quite the same as what we originally had. Before, if the call to OtherThingToDoConcurrently
finished before ThingToDoConcurrently
and it finished with an error, the code wouldn’t wait around; RunProcess
would exit immediately, returning the error. Let’s see how to add support for this.
What we need is a function that waits for one or more Promise
instances to complete, but which returns immediately if any of them return an error
. Here’s a first attempt at an implementation:
This function launches a goroutine for each Promise
passed into it. Inside the goroutine, we call Get
to wait until the Promise
has a value. If an error was returned, the error is written to the errChan
channel. A sync.WaitGroup
is used to wait until all of the goroutines have completed. One more goroutine is launched to wait for the WaitGroup
to complete. When it does, a done channel is closed. Finally, we use a select
statement to see which happens first: either the done channel is closed or an error is written to the errChan
channel. If an error is written, we return that error immediately. Otherwise, we wait until done is close and return nil
.
Logically, this looks correct, but it has a problem: it won’t compile. Here’s the error you get:
cannot use generic type Promise[V any] without instantiation
Since Promise
is a generic type, you must specify the type parameters whenever you use it. Fixing this doesn’t seem like a big deal. Here’s a second attempt to write Wait
:
This version compiles, but it has a subtle problem. If all of the Promise
instances passed to WaitTakeTwo
return the same type, it works:
Running this code produces the output:
<nil>
20 <nil>
30 <nil>
But if there are different return types, your code will not compile. Here’s a short example:
When you compile this code you get the error:
type *Promise[string] of p2 does not match inferred type *Promise[int] for *Promise[V]
In order to build a Wait
function that works when the Promise
instances don’t all return values of the same type, we need to define a new, non-generic interface, Waiter
, and we need to make Promise
implement this interface:
Now we can write Wait
in terms of Waiter
, and everything works correctly:
Let’s go back and re-implement RunProcess
using Promise
, Run
, and Wait
:
This code is very easy to follow and the concurrency management has been completely abstracted away.
Let’s see what else we can do with generics and closures to make concurrency easier in Go. Proper context cancellation management is verbose. Rather than require developers to implement it themselves, let’s do it for them by writing a function that takes in a Func
and returns a Func
that implements context cancellation:
WithCancellation
takes advantage of our old friend, the done channel pattern. In fact, we use two done channels. Every context has a Done
method that returns a channel. This channel is closed when a context is cancelled either by a timeout or by a call to a cancel
function associated with the context. We also create our own done channel. The function returned by WithCancellation
invokes the passed-in Func
in a goroutine. When that goroutine completes, our done channel is closed. We use a select
statement to wait for either the done channel to close or for the channel returned by the context’sDone
method to be closed. If ours closes first, we return the results of the passed-in Func
. If the context’s done channel closes first, we return a zero value and the error from the context.
Returning that zero value relies on an interesting Go generics trick. In order to get a zero value for a generic type, you use the code var zero V
. Remember, if you don’t assign a value to the variable in a var
statement, the variable is set to the type’s zero value. Any time you need to get the zero value for a generic type, take advantage of this pattern.
Now that we have WithCancellation
, we can implement the problem defined earlier. Here’s what it looks like:
We now have type-safe, readable concurrent code that performs a relatively complicated operation in a very straightforward way.
There’s one last operation that people like to do with promises: chain them together. Let’s take a look at an implementation of Then
:
Then
looks a lot like Run
, with a few minor differences. The first difference is that we pass in a *Promise[T]
instead of a T
. Second, rather than use the passed-in value directly, we call p.Get()
to retrieve the value from the Promise
. If the Promise
contains a non-nil error
, then we assign the error to the new Promise
and return immediately. Otherwise, we call our new function with the value from the passed-in Promise
.
Using Then
looks like this:
Running this code prints out:
20 <nil>
With just two new types( Func
and Promise
)and three new functions (Run
, WithCancellation
, and Then
), we are able to write concurrent code that’s easier to use, and more importantly, easier to understand. This is just a simple example of how generics will improve Go development. They provide developers another way to produce readable code that’s maintainable for years to come. If you look at your existing projects, I’m sure you’ll find places where generics will help you, too.
You can find the code from this blog post in the gcon
library at https://github.com/jonbodner/gcon.