Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • errgroup
  • concurrency
  • goroutines
  • context
  • sync-waitgroup

Go's errgroup: Cancelling Goroutines Without Reinventing sync.WaitGroup

There's a shape of bug that turns up in almost every Go codebase that fans work out across goroutines: you fire off N requests, one of them fails, and the other N-1 keep running anyway because nothing told them to stop. You get the right error back eventually, several seconds late, after every other goroutine has finished doing work nobody wanted any more.

The usual first fix is to bolt a sync.WaitGroup together with a mutex-protected "first error" variable and a context.CancelFunc called from inside the failing goroutine. It works, but you end up writing the same twenty lines in every package that does fan-out work. golang.org/x/sync/errgroup is that twenty lines, written once, by people who thought about the edge cases you haven't hit yet.

The boilerplate version

Here's roughly what people write before they discover errgroup exists: fetch a batch of URLs concurrently, stop everything as soon as one fails, return the first error.

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	var (
		wg       sync.WaitGroup
		mu       sync.Mutex
		firstErr error
	)
	results := make([][]byte, len(urls))

	for i, url := range urls {
		wg.Add(1)
		go func(i int, url string) {
			defer wg.Done()
			body, err := fetch(ctx, url)
			if err != nil {
				mu.Lock()
				if firstErr == nil {
					firstErr = err
					cancel()
				}
				mu.Unlock()
				return
			}
			results[i] = body
		}(i, url)
	}
	wg.Wait()
	if firstErr != nil {
		return nil, firstErr
	}
	return results, nil
}

None of this is wrong, exactly. It's just a lot of ceremony for "run these concurrently, stop on first failure, tell me what went wrong". Every field in that struct literal (wg, mu, firstErr) exists purely to compensate for the fact that a plain WaitGroup has no concept of failure at all: it just counts goroutines down to zero and has nothing to say about whether any of them were unhappy on the way.

The errgroup version

errgroup.Group is that same pattern, packaged. errgroup.WithContext gives you a group and a derived context that gets cancelled the moment any goroutine in the group returns a non-nil error, or as soon as Wait returns, whichever happens first.

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
	g, ctx := errgroup.WithContext(ctx)
	results := make([][]byte, len(urls))

	for i, url := range urls {
		g.Go(func() error {
			body, err := fetch(ctx, url)
			if err != nil {
				return err
			}
			results[i] = body
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return nil, err
	}
	return results, nil
}

That's the whole thing. No mutex, no manual cancel plumbing, no separate error variable. g.Go starts a goroutine; if it returns an error, errgroup records the first one and cancels the derived ctx, which every other in-flight fetch call is presumably already respecting because it was passed the context and checks it in its HTTP calls. Wait blocks until every goroutine in the group has returned, then hands back that first error (or nil).

Worth noting if you're on anything before Go 1.22: the loop variable capture in that example is fine only because Go 1.22 changed loop variables to be per-iteration. On older toolchains you'd need i, url := i, url inside the loop body before the closure, same as with any goroutine-per-iteration pattern.

Bounding concurrency with SetLimit

Firing off a goroutine per URL is fine for a handful of requests. For a few thousand it's a good way to open a few thousand sockets at once and get rate-limited, or worse. The classic fix is a buffered channel used as a semaphore. errgroup has this built in via SetLimit:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)

for _, url := range urls {
	url := url
	g.Go(func() error {
		return fetchInto(ctx, url, store)
	})
}
if err := g.Wait(); err != nil {
	return err
}

With a limit set, g.Go blocks the caller until a slot is free, so at most 8 fetches run at once. There's also TryGo, which does the same check but returns false immediately instead of blocking if the group is already at capacity, useful when you'd rather skip or defer work than queue for a goroutine slot. Calling SetLimit with a negative number removes the limit again; the zero value has no limit at all.

The gotchas that actually bite

A few things about errgroup are easy to assume incorrectly, usually because the API looks so much like a tidier WaitGroup that you stop thinking about what's different underneath.

First: errgroup does not catch panics. If a function passed to Go panics, that panic unwinds the goroutine it's running in exactly as it would for any other goroutine, which in Go means it crashes the whole process, not just the caller of Wait. This isn't errgroup being careless, it's the same rule as everywhere else: a panic in one goroutine can't be recovered by a different goroutine. If a piece of work might panic and you want that turned into an error instead of a process death, wrap it in your own recover inside the function you pass to Go.

Second: Wait only ever returns the first error. If three of your ten goroutines fail, you get told about one of them, not three, and the other two errors are gone, discarded, never logged unless the individual goroutines log them themselves before returning. If you actually need to see every failure, log inside each goroutine before returning the error, or collect them into a slice behind a mutex yourself, errgroup's whole point is picking a winner and cancelling the rest, not aggregating.

Third: cancellation is still cooperative, because it's still just context.Context underneath. Calling the derived context's cancellation doesn't reach into a running goroutine and stop it; it only sets a channel that the goroutine has to be checking. A fetchInto that ignores ctx entirely, or that's blocked on something that doesn't watch ctx.Done(), will run to completion regardless of what the rest of the group is doing. Errgroup buys you the plumbing to signal cancellation cleanly; it doesn't buy you goroutines that actually stop when asked. That part's still on whatever code you write inside Go.

Fourth, and easy to miss: the zero-value errgroup.Group works fine without WithContext at all, it just collects the first error and doesn't cancel anything. That's the right shape when the goroutines genuinely don't depend on each other and there's nothing useful to cancel, no point wiring up a context just to satisfy a habit.

errgroup lives in golang.org/x/sync, not the standard library proper, but it's maintained by the Go team under the same compatibility promise as the rest of x/. It's about as close to "should have been in sync" as a dependency gets, and reaching for it beats hand-rolling the WaitGroup-plus-mutex-plus-cancel pattern for the tenth time.