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:
- Initiate the fetch request
- Hand the associated IO operation off to the event loop
- 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:
- Declare a channel upon which to pass the result
- Initiate a goroutine which fetches the data
- Wait on the channel to receive a single result
- 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!