Day 27 — errgroup
Day 27 — errgroup
Stage III · ~3h (theory-heavy)
Goal: Use golang.org/x/sync/errgroup for structured concurrent work: wait for a group, return the first error, cancel siblings, and bound parallelism with SetLimit.
errgroup is not in the standard library—it lives in golang.org/x/sync. It is the de-facto companion for production Go concurrency.
Why this day exists
Manual patterns from Day 26 work, but:
- Error channels are easy to mishandle
- Cancel + WaitGroup boilerplate repeats
- Limits are reinvented poorly
errgroup.Group encodes a battle-tested contract:
Run N functions; wait; return the first non-nil error; optionally cancel the rest.
Theory 1 — Module setup
go get golang.org/x/sync/errgroupimport "golang.org/x/sync/errgroup"Pin versions via go.mod like any other dependency. Keep x/sync reasonably current; API used here is stable.
Theory 2 — Basic Group
var g errgroup.Group
g.Go(func() error {
return taskA()
})
g.Go(func() error {
return taskB()
})
if err := g.Wait(); err != nil {
return err
}Rules:
Gostarts a goroutine immediately.
Waitblocks until all finish.
- First non-nil error is returned; others are discarded (they still run unless using cancel context).
- If a function panics,
Waitpanics with the same value (don’t use panic for control flow).
Without a derived context, siblings keep running after the first error—they just don’t change the returned error.
Theory 3 — WithContext for fail-fast cancel
g, ctx := errgroup.WithContext(parent)
g.Go(func() error {
return work(ctx, a)
})
g.Go(func() error {
return work(ctx, b)
})
err := g.Wait() // ctx cancelled when first Go func returns errorSemantics:
WithContextreturns a child context cancelled when the firstGofunction returns a non-nil error or whenWaitreturns
- Pass
ctxinto all blocking ops
- After
Wait, resources associated with the group are done
This is the structured form of Day 26 fail-fast.
Theory 4 — SetLimit
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // max concurrent Go funcs running
for _, item := range items {
item := item
g.Go(func() error {
return process(ctx, item)
})
}
if err := g.Wait(); err != nil {
return err
}SetLimit(n)caps concurrent goroutines inside the group
- Additional
Gocalls block until a slot frees (or panic if limit is 0? — limit must be positive; 0 means unlimited historically—check docs for your version; use positive n)
- Call
SetLimitbeforeGo
This replaces many hand-rolled worker pools for the “run this function per item” shape.
Theory 5 — What errgroup is not
| Not for | Prefer |
|---|---|
| Streaming pipelines with continuous channels | Channel stages |
| Long-lived worker servers | Dedicated pool + lifecycle |
| Collecting all errors | Mutex slice + errors.Join, or custom |
| Shared mutable aggregation without sync | Still need mutex/atomic |
Wait returns one error. For multierror:
var (
mu sync.Mutex
errs []error
)
// inside Go: on error append under mu; return nil to not cancel
// after Wait: errors.Join(errs...)Or return nil from workers and join manually—know you give up fail-fast cancel unless you cancel yourself.
Theory 6 — Panic and recovery policy
Prefer errors over panics. If a dependency panics:
- errgroup will surface panic on
Wait
- Recover inside
Goonly if you have a clear policy:
g.Go(func() error {
defer func() {
if r := recover(); r != nil {
// convert to error if required by policy
}
}()
return risky()
})Usually: fix the panic source.
Worked example — parallel fetch with limit
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/errgroup"
)
func headOK(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("%s: %w", url, err)
}
res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("%s: status %d", url, res.StatusCode)
}
return nil
}
func checkAll(ctx context.Context, urls []string, limit int) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for _, u := range urls {
u := u
g.Go(func() error {
return headOK(ctx, u)
})
}
return g.Wait()
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
urls := []string{
"https://example.com",
"https://example.com/404", // may fail
}
if err := checkAll(ctx, urls, 4); err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("all ok")
}Use -race in tests; live network optional—prefer fake servers (httptest) in automated tests.
Worked example — pure CPU with cancel
func squares(ctx context.Context, n int) ([]int, error) {
out := make([]int, n)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.GOMAXPROCS(0))
for i := 0; i < n; i++ {
i := i
g.Go(func() error {
if err := ctx.Err(); err != nil {
return err
}
if i == n/2 {
return fmt.Errorf("boom at %d", i)
}
out[i] = i * i // each index exclusive — no race
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}Index-exclusive writes need no mutex.
Lab 1 — Install and basic Wait
Workspace: ~/lab/90daysofx/01-go/day27
mkdir -p ~/lab/90daysofx/01-go/day27 && cd ~/lab/90daysofx/01-go/day27
go mod init example.com/day27
go get golang.org/x/sync/errgroup- Run three tasks: two succeed, one fails.
- Print error from
Wait.
- Race-clean.
go test -race ./...Lab 2 — WithContext fail-fast
- Tasks sleep 50ms, 100ms, 200ms.
- The 50ms task returns an error.
- Instrument longer tasks to exit early via
ctx.Done().
- Assert they did not run to full sleep (timing with tolerance).
Lab 3 — SetLimit correctness
Spawn 100 tasks each holding a critical section counter:
var current atomic.Int64
var max atomic.Int64
// on enter: c := current.Add(1); update max; sleep; current.Add(-1)SetLimit(5) ⇒ observed max concurrent ≤ 5. Race-clean.
Lab 4 — Refactor Day 23/24 pool to errgroup
Replace hand-rolled worker pool for batch jobs with errgroup + SetLimit. Keep context timeout. Compare line count and clarity in journal.
Optional: multierror mode that does not cancel—document tradeoff.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting to use returned ctx |
All work must observe it |
SetLimit after many Go |
Set limit first |
| Capturing loop variable wrong | item := item before Go |
| Assuming all errors returned | Only first; design multierror explicitly |
| Mixing panic with Wait | Convert or prevent panics |
Not go get x/sync |
Module dependency required |
Checkpoint
- Added
golang.org/x/sync/errgroupto a module
- Explains Group vs WithContext
- Uses
SetLimitand proved concurrency bound
- Fail-fast lab shows sibling cancellation
- Refactored one older pool toward errgroup
go test -race ./...clean
Commit
git add .
git commit -m "day27: errgroup WithContext + SetLimit labs"Write three personal gotchas before continuing.
Tomorrow
Day 28 — Memory model lite: happens-before, what races really mean, and documenting synchronization so future you trusts the code.