Coordinating goroutines with errGroup
By: Ricardo Linck
Tags: Distributed Systems, Concurrent Programming, Golang, Software Development, Software Engineering

Coordinating different goroutines to achieve a single goal

Photo by Hunter Harritt on Unsplash

In a previous article I talked about how we can use the context.Context struct to share data and group distinct goroutines. One thing that’s missing from that article, is how to synchronise that group of things. Synchronise concurrent tasks means either wait for all of them to finish before doing something else, or cancelling all of them in case things go wrong. That’s where the sync/errgroup comes to the scene. Here are the official docs for the package. The package name conveys two things: used to synchronise, and we are going to be dealing with errors . That pretty much matches what was mentioned above, right? The name errgroup may sound a bit weird as the mechanism to synchronise goroutines, but bear with me, it will make sense later. Essentially you will be registering goroutines to that group and wait for them to finish, one way or the other. The main struct inside the package is the errgroup.Group .

To create a new Group you can call errgroup.WithContext that will receive a context.Context as parameter and return a pointer to a new group and a new context.

There are two methods you will use in this struct:

(*errgroup.Group).Go — register a function to the groups and start it as a new goroutine;

— register a function to the groups and start it as a new goroutine; (*errgroup.Group).Wait — waits until all registered goroutines finish or an error occurs in any goroutine of the group. That’s why it uses the error as the synchronisation mechanism. If an error occurs, the context (that one that was returned by the errgroup.WithContext ) gets cancelled and the error gets returned. One thing to pay attention here is that the goroutines that have already started executing will continue to do so, so it’s important to get the context passed along to the goroutine and check if that got cancelled inside the goroutine (usually that’s a simple thing to achieve with the select statement);

This is extremely useful for “scatter and gather” type of problems. As an example, when you receive a request you need to fetch different information for that request from different external sources. In order to improve performance, you can fetch data from each source in a goroutine. That will benefit from concurrency and execute multiple things while waiting for the external source to respond. Here’s an example of a little program that fetches data related to a user email. We fetch the user name from a Name Service, and the loyalty points from an internal mapping (that simulates a database or memory cache). You can check the full implementation on my github. However I’ll add snippets to the relevant bits and explain what’s happening.

I’ll start with the main function and then we will dig down into the actual fetching functionality.

The first is step is to determine how we are going to store the data returned from those goroutines. In this example we will fetch the data and store the results into a buffered channel, a very common pattern. In order to make it work, we need to create the context we will be using (with timeout, to spice things up, of course), the results channel, and the actual Group.

PS: I’m also firing the nameservice.StartServer() in a goroutine just because I wanted it to be an external service and I was too lazy to do anything fancier 🤷‍♂.

We will also configure all the fetchers we want to use, I’ve created an interface and a helper decorator for that, just to reduce the boilerplate and focus exactly on what we want to fetch and how.

Now we can run the each of the fetchers using the g.Go . That will register our fetchers and execute them in separate goroutines.

After firing our fetchers we now wait for them to complete, with errors or not. In terms of handling results (and errors) I’m just printing them to stdout . Obviously in a real application you would want to do something with it, but for this test, that’s enough.

This is the fetcher interface and the helper method.

This helper simply fires the underlying fetcher in a goroutine and selects whatever happens first:

the provided context gets cancelled (either by another goroutine of the group returning an error, or because of the timeout we set); the fetcher actually finishes the job and sends something on the result channel; or the fetcher errors out.

I used multiple channels to make it easier to select what happens. The select statement will wait until any of the used channels return something. Once that happens it will move on.

It’s important to notice that in case some error happens inside the underlying fetcher, we only log it and do not return that error to the Group. This means that we won’t abort the execution of other goroutines for this Group when an error occurs, we will only ignore that we couldn’t get the result and move on. In various cases it’s better to have some data, than no data at all. If you want to abort the execution in case any error occurs, you can simply return the error in line 38.

Here’s what the NameFetcher looks like:

This is quite simple. We only make a HTTP request to the configured name service and deserialise the response into a struct. The only important thing here is that we are passing the context.Context that was provided to the actual HTTP request. That is useful in case the context gets cancelled midway through the request, as HTTP client will take care of closing it properly without any additional code.

What I called the PartnerFetcher is much simpler. Just a lookup in a map. There’s a time.Sleep to simulate some time spent in a call. That’s useful as we can configure the context timeout to be less than a second in main and see that the output of that fetcher is not present on the responses, as well as a timeout error. Here’s what the output should look like:

{name {ricardo linck}}

{loyalty_points 20}

In case we tune the timeout to 500 milliseconds for example, the output will look like:

{name {ricardo linck}}

2020/12/13 12:51:20 context deadline exceeded

exit status 1

Note that as things are running concurrently in goroutines the output of the error, that is printed out inside the Fetcher appears after the actual results printing.

Closing Thoughts

Very often we need to use concurrent programming to boost performance in our applications. Which is not always an easy task to begin with. But then, we need to go a step further and synchronise these concurrent things. Which can be even harder. The sync/errgroup package simplifies that task. There’s still quite a lot of design decisions you have to make around how you want your application to behave. The package brings a lot of flexibility and takes away some boilerplate and complexity of having to do that manually.

There are many applications that require synchronising concurrent tasks. We focused on one of a kind, which is very common with distributed systems. Other examples could include bringing up multiple long-lived services that need to be shut down gracefully when a signal is received, or when some critical error happens. Doing things in parallel usually require synchronisation too. There are many different scenarios where this technique is useful.