`errgroup` as the goroutine-fanout default
byob-lifecycle.3
concurrencycontextlifecycle
Problem: a CLI command that fans out N parallel calls (concurrent HTTP
fetches, parallel store reads, multiple subprocess invocations) needs
ctx cancellation, first-error short-circuit, and bounded concurrency.
Hand-rolling that with sync.WaitGroup + an error channel +
context.WithCancel invariably gets the cancellation ordering wrong on
first attempt and re-derives the same primitive across every command.
Idea: byob default for "fan out N tasks, wait, surface the first error"
is golang.org/x/sync/errgroup with a context-aware Group. Pick up the
ctx threaded by byob-lifecycle.1, derive a Group with
errgroup.WithContext(ctx), spawn with g.Go(func() error { ... }),
collect with g.Wait(). The first non-nil error cancels the derived
ctx; every other goroutine sees ctx.Done() and returns. Bounded
concurrency via g.SetLimit(n).
Pairs with the goroutine-exit-path memory: errgroup is the byob
implementation of "every goroutine has a documented exit path" for the
fanout case.
Tradeoffs: stdlib sync.WaitGroup + error channel works for one-off
cases and avoids a (small, well-maintained) third-party dep on
golang.org/x/sync. The dep is golang.org/x adjacent-stdlib, used
by Kubernetes, gh CLI, and most production Go — not a foreign dep. The
packaging cost is one go.mod line; the wiring cost saved is several
lines per fanout site, plus the cancellation-ordering bugs you don't
write.
When not to use:
- Long-lived background goroutines (process-lifetime workers): use plain ctx + select + a parent shutdown channel.
- A single goroutine spawned for parallelism inside one blocking call:
a plain
go+ result channel is fine. - "Fire N tasks, wait for all, collect every error" — errgroup
short-circuits on first error. Use a
[]errorslice pluserrors.Joinif you need all of them.
Design
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
func parallelFetch(ctx context.Context, urls []string) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // bounded concurrency
out := make([]Result, len(urls))
for i, u := range urls {
g.Go(func() error {
r, err := fetch(ctx, u) // ctx cancels on first error
if err != nil {
return fmt.Errorf("fetch %q: %w", u, err)
}
out[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}
ctx threading per byob-lifecycle.1 supplies the parent context; errgroup's derived ctx cancels on first error so in-flight goroutines abort rather than running to completion only to have their results discarded. Loop variables don't need shadowing in Go 1.22+.