Go, Generics, and Concurrency

Jon Bodner
8 min readApr 25, 2022

--

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 Promisebefore 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.

--

--

Jon Bodner

Staff Engineer at Datadog. Former SDE at Capital One. Author of Learning Go. Interested in programming languages, open source, and software engineering.