Simple Concurrency in Go for Fans of JavaScript's Promise.all

Updated September 7, 2021

Concurrency is unavoidable in modern IO-heavy web apps. I spend most of my time writing TypeScript on Node.js at work, and a lot of time writing Go for personal projects. These are both environments that handle concurrency quite well, but very differently. Basic concurrency in Node.js is much easier, thanks to the runtime handling callbacks and promises gracefully without blocking IO. Go's concurrency primitives (channels and goroutines) are much more powerful, but a bit less approachable by default, in my opinion. A common and very easy way to achieve concurrency in Node.js code is to await an array of Promises with the built-in Promise.all() helper. Especially for embarassingly parallel problems (those where the order of the steps is unimportant and the steps do not depend on one another), Promise.all() can reduce execution time significantly. I find myself using this pattern almost daily when writing web app backends. This article aims to quickly demonstrate how this simple concurrency pattern familiar to Node devs can be easily and idiomatically used in Go code using the errgroup package.

JavaScript's Promise.all

Say we've got a user ID from a request, and need to retrieve their purchase history, saved payment methods, and contact information before sending an HTTP response. For each of these operations, Promise.all() allows us to:

  1. Initiate the fetch request
  2. Hand the associated IO operation off to the event loop
  3. Move on to the next promise in the array without waiting for the current one to complete

This code might look like:

const [
    userDetails,
    userCards,
    userPurchases
  ] = await Promise.all([
      UserModel.findById(userId),
      CardModel.findByUserId(userId),
      PurchaseModel.findByUserId(userId),
]);

Go's errgroup

Using only language primitives, it would be a huge pain to replicate the functionality of Promise.all in Go. For each request, we would need to:

  1. Declare a channel upon which to pass the result
  2. Initiate a goroutine which fetches the data
  3. Wait on the channel to receive a single result
  4. Close the channel

The `errgroup` package exposes an elegant abstraction over all of this boilerplate. It is a bit more verbose than the JavaScript version above, but it gets the job done and is quite concise compared to wiring it up yourself. Consider this Go snippet which performs the same steps as the precious example:

var userDetails *user.Details
var userCards []payment.Card
var userPurchases []shop.Purchase

errGroup, groupCtx := errgroup.WithContext(ctx)

errGroup.Go(func() error {
    var err error
    userDetails, err = user.FindDetailsByID(groupCtx, userID)
    return err
})

errGroup.Go(func() error {
    var err error
    userCards, err = payment.FindCardsByUserID(groupCtx, userID)
    return err
})

errGroup.Go(func() error {
    var err error
    userPurchases, err = shop.FindPurchasesByUserID(groupCtx, userID)
    return err
})

err := errGroup.Wait()
if err != nil {
    // ... handle the error
}

For each of the closures executed inside an errGroup.Go() call, the package executes it on a new goroutine, evaluates the error returned, and if it is non-nil, passes it to the caller of errgroup.Wait(). It's important to use the context created by the errgroup inside these closures, because as soon as an error is returned, all pending goroutines using that context are sent a cancel signal.

Conclusion

When I learned about the errgroup package, it was a bit of an epiphany. Many use cases that benefit from concurrency don't require advanced patterns, and I found myself missing Promise.all in Go (though I must admit, I generally greatly prefer Go to TypeScript or JavaScript). errgroup satisfies that need, and now I find myself using it all the time. Hopefully you can get some use out of it, too!


Copyright 2021 Nate Anderson