errgroup — Practice Tasks¶
← Back to errgroup index
A graded set of exercises. Each task includes a problem statement, a starter snippet (sometimes), an expected behaviour, and a hint section. Solutions live in the "Reference solution" subsection for self-checking. Do not peek until you have something compilable.
Task 1 (Junior) — Convert manual WaitGroup to errgroup¶
Problem. Convert the following to use errgroup.Group. Preserve the "return the first error" semantics.
func processAll(items []Item) error {
var wg sync.WaitGroup
errCh := make(chan error, len(items))
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := process(it); err != nil {
errCh <- err
}
}(item)
}
wg.Wait()
close(errCh)
var firstErr error
for err := range errCh {
if firstErr == nil { firstErr = err }
}
return firstErr
}
Hints.
- The errgroup version is roughly 6 lines.
- Remember
item := itemfor Go < 1.22.
Reference solution.
func processAll(items []Item) error {
var g errgroup.Group
for _, item := range items {
item := item
g.Go(func() error { return process(item) })
}
return g.Wait()
}
Task 2 (Junior) — Three parallel HTTP fetches¶
Problem. Write a function fetchThree(ctx context.Context, urls [3]string) ([3][]byte, error) that fetches all three URLs in parallel and returns their bodies. If any fetch fails, abort the others and return the error.
Reference solution.
func fetchThree(ctx context.Context, urls [3]string) ([3][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
var bodies [3][]byte
for i, u := range urls {
i, u := i, u
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil { return err }
resp, err := http.DefaultClient.Do(req)
if err != nil { return err }
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil { return err }
bodies[i] = b
return nil
})
}
if err := g.Wait(); err != nil { return [3][]byte{}, err }
return bodies, nil
}
Note: the array indices are disjoint, so writes to bodies are race-free.
Task 3 (Junior) — Bounded parallel sum¶
Problem. Given nums []int and a function heavy(n int) int that is CPU-bound, compute the sum of heavy(n) for every n. Use at most runtime.NumCPU() goroutines.
Reference solution.
func parallelSum(nums []int) int {
var g errgroup.Group
g.SetLimit(runtime.NumCPU())
partial := make([]int, len(nums))
for i, n := range nums {
i, n := i, n
g.Go(func() error {
partial[i] = heavy(n)
return nil
})
}
_ = g.Wait()
total := 0
for _, p := range partial {
total += p
}
return total
}
Task 4 (Junior) — Service startup¶
Problem. Write a function startServices(ctx context.Context) error that starts three components in parallel: startDB(ctx), startCache(ctx), startHTTP(ctx). Each returns error. If any one fails, abort the others and return the error.
Reference solution.
func startServices(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return startDB(ctx) })
g.Go(func() error { return startCache(ctx) })
g.Go(func() error { return startHTTP(ctx) })
return g.Wait()
}
Task 5 (Middle) — Parallel map with bound¶
Problem. Implement a generic parallelMap with bounded concurrency:
func parallelMap[I, O any](
ctx context.Context,
in []I,
limit int,
fn func(context.Context, I) (O, error),
) ([]O, error)
Hints. Allocate the output slice up-front. Use disjoint indices. Thread ctx.
Reference solution.
func parallelMap[I, O any](
ctx context.Context,
in []I,
limit int,
fn func(context.Context, I) (O, error),
) ([]O, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
out := make([]O, len(in))
for i, v := range in {
i, v := i, v
g.Go(func() error {
r, err := fn(ctx, v)
if err != nil { return err }
out[i] = r
return nil
})
}
if err := g.Wait(); err != nil { return nil, err }
return out, nil
}
Task 6 (Middle) — Cancel on success¶
Problem. Given candidates []string and matches(ctx context.Context, s string) bool, find the first matching candidate. As soon as one is found, cancel the rest and return.
Hints. Use a sentinel error to signal "found."
Reference solution.
var errFound = errors.New("found")
func findFirst(ctx context.Context, candidates []string) (string, error) {
g, ctx := errgroup.WithContext(ctx)
var match string
var mu sync.Mutex
for _, c := range candidates {
c := c
g.Go(func() error {
if matches(ctx, c) {
mu.Lock()
if match == "" { match = c }
mu.Unlock()
return errFound
}
return nil
})
}
err := g.Wait()
if errors.Is(err, errFound) { return match, nil }
if err != nil { return "", err }
return "", errors.New("no match")
}
The mutex is used to record the first match deterministically; without it, two simultaneous winners could each write match, and you'd get whichever the scheduler ran last.
Task 7 (Middle) — Producer with drop on overflow¶
Problem. Read items from producer <-chan Item and process each with up to 4 goroutines. If 4 are already busy, drop the item and increment drops.
Reference solution.
func processWithDrop(ctx context.Context, producer <-chan Item) (drops int, err error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4)
for item := range producer {
item := item
if !g.TryGo(func() error { return process(ctx, item) }) {
drops++
}
}
return drops, g.Wait()
}
Task 8 (Middle) — All-errors collection¶
Problem. Like Task 1 (process all items), but return all errors, not just the first. Use errors.Join.
Reference solution.
func processAllCollect(items []Item) error {
var g errgroup.Group
var mu sync.Mutex
var errs []error
for _, item := range items {
item := item
g.Go(func() error {
if err := process(item); err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("item %v: %w", item.ID, err))
mu.Unlock()
}
return nil
})
}
_ = g.Wait()
return errors.Join(errs...)
}
Task 9 (Middle) — Pipeline with three stages¶
Problem. Build a three-stage pipeline:
- Reader produces
Rawitems intochan Raw. - Parser reads
Raw, producesParsedintochan Parsed. - Writer reads
Parsedand stores them.
Use one errgroup for all three stages.
Reference solution.
func pipeline(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
raw := make(chan Raw, 16)
parsed := make(chan Parsed, 16)
g.Go(func() error {
defer close(raw)
return readInputs(ctx, raw)
})
g.Go(func() error {
defer close(parsed)
for r := range raw {
p, err := parse(r)
if err != nil { return err }
select {
case parsed <- p:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
g.Go(func() error {
for p := range parsed {
if err := write(ctx, p); err != nil { return err }
}
return nil
})
return g.Wait()
}
Note the defer close(channel) after each producer stage. Without it, the consumer hangs.
Task 10 (Senior) — Quorum write¶
Problem. Given 5 replicas, write to all 5 in parallel. Return success as soon as 3 succeed. Cancel the remaining writes.
Reference solution.
var errQuorum = errors.New("quorum reached")
func quorumWrite(ctx context.Context, replicas []string, payload []byte) error {
g, ctx := errgroup.WithContext(ctx)
var ok int32
need := int32((len(replicas) / 2) + 1)
for _, r := range replicas {
r := r
g.Go(func() error {
if err := write(ctx, r, payload); err != nil {
return nil // failures don't count, don't abort
}
if atomic.AddInt32(&ok, 1) == need {
return errQuorum
}
return nil
})
}
err := g.Wait()
if errors.Is(err, errQuorum) { return nil }
return errors.New("quorum not reached")
}
Variant: Add failure tracking so the function aborts if more than len(replicas) - need writes have failed (quorum impossible).
Task 11 (Senior) — Weighted concurrency with semaphore¶
Problem. Each task has a Weight int. Total weight in flight must not exceed 100. Process all tasks; fail fast on first error.
Reference solution.
func processWeighted(ctx context.Context, tasks []Task) error {
sem := semaphore.NewWeighted(100)
g, ctx := errgroup.WithContext(ctx)
for _, t := range tasks {
t := t
if err := sem.Acquire(ctx, t.Weight); err != nil {
return err
}
g.Go(func() error {
defer sem.Release(t.Weight)
return run(ctx, t)
})
}
return g.Wait()
}
SetLimit cannot do weights. Always pair errgroup with semaphore.Weighted for heterogeneous tasks.
Task 12 (Senior) — Crawler with depth and per-host limit¶
Problem. Crawl a website starting from one URL, follow links, do not exceed depth 3, and never have more than 4 in-flight requests per host.
Outline.
type Crawler struct {
maxDepth int
hostSem map[string]*semaphore.Weighted
mu sync.Mutex
visited map[string]bool
}
func (c *Crawler) Crawl(ctx context.Context, start string) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return c.crawl(ctx, g, start, 0) })
return g.Wait()
}
func (c *Crawler) crawl(ctx context.Context, g *errgroup.Group, url string, depth int) error {
if depth > c.maxDepth { return nil }
if !c.markVisited(url) { return nil }
sem := c.semFor(url)
if err := sem.Acquire(ctx, 1); err != nil { return err }
defer sem.Release(1)
links, err := fetch(ctx, url)
if err != nil { return err }
for _, l := range links {
l := l
g.Go(func() error { return c.crawl(ctx, g, l, depth+1) })
}
return nil
}
Discussion. Recursion through g.Go inside a goroutine that is itself in g works because errgroup's Add is goroutine-safe. The depth-3 cap and visited map prevent infinite spawning.
Task 13 (Senior) — Errgroup with retries¶
Problem. Each task may fail transiently. Retry each up to 3 times with exponential backoff. Fail fast on non-retryable errors.
Reference solution.
func processWithRetry(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
for _, x := range items {
x := x
g.Go(func() error {
backoff := 100 * time.Millisecond
for attempt := 0; attempt < 3; attempt++ {
err := process(ctx, x)
if err == nil { return nil }
if !isRetryable(err) { return err }
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
backoff *= 2
}
}
return fmt.Errorf("item %v: retries exhausted", x.ID)
})
}
return g.Wait()
}
Note: the time.After is wrapped in select so cancellation interrupts the wait.
Task 14 (Senior) — Race against deadline¶
Problem. Run a slow operation, but cap total wall time at 2 seconds. Return early with context.DeadlineExceeded if the operation is not done.
Reference solution.
func withDeadline(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return slowOp(ctx) })
return g.Wait()
}
The outer WithTimeout enforces the deadline. The inner WithContext is technically redundant for one goroutine, but kept for consistency and to make extension to more goroutines trivial.
Task 15 (Professional) — Implement errgroup yourself¶
Problem. Implement a minimal Group with Go, Wait, WithContext, SetLimit, TryGo. Match the public API and behaviour.
Reference solution. See professional.md Section 11. Hand-write it from scratch on a whiteboard.
Task 16 (Professional) — Add panic recovery¶
Problem. Wrap errgroup.Group in a new type that converts panics in worker functions to errors:
type SafeGroup struct { /* ... */ }
func (sg *SafeGroup) Go(f func() error)
func (sg *SafeGroup) Wait() error
Reference solution.
type SafeGroup struct {
g errgroup.Group
}
func NewSafeGroup() *SafeGroup { return &SafeGroup{} }
func (sg *SafeGroup) Go(f func() error) {
sg.g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in worker: %v", r)
}
}()
return f()
})
}
func (sg *SafeGroup) Wait() error { return sg.g.Wait() }
Now a panic in a worker becomes an error like any other.
Variant: Capture the stack trace.
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
err = fmt.Errorf("panic: %v\n%s", r, buf[:n])
}
}()
Task 17 (Professional) — Multi-error errgroup¶
Problem. Wrap errgroup.Group to collect all errors instead of just the first:
type MultiGroup struct { /* ... */ }
func (mg *MultiGroup) Go(f func() error)
func (mg *MultiGroup) Wait() error // returns errors.Join of all errors
Reference solution.
type MultiGroup struct {
g errgroup.Group
mu sync.Mutex
errs []error
}
func (mg *MultiGroup) Go(f func() error) {
mg.g.Go(func() error {
if err := f(); err != nil {
mg.mu.Lock()
mg.errs = append(mg.errs, err)
mg.mu.Unlock()
}
return nil // always succeed so errgroup doesn't short-circuit
})
}
func (mg *MultiGroup) Wait() error {
_ = mg.g.Wait()
mg.mu.Lock()
defer mg.mu.Unlock()
return errors.Join(mg.errs...)
}
Note we return nil from the inner closure: we manage errors ourselves and don't want errgroup to cancel a context we're not providing.
Task 18 (Professional) — Errgroup with active-count metric¶
Problem. Track the maximum number of concurrently active goroutines using expvar or atomics.
Reference solution.
type MeteredGroup struct {
g errgroup.Group
active atomic.Int64
maxSeen atomic.Int64
}
func (mg *MeteredGroup) Go(f func() error) {
mg.g.Go(func() error {
n := mg.active.Add(1)
for {
old := mg.maxSeen.Load()
if n <= old || mg.maxSeen.CompareAndSwap(old, n) {
break
}
}
defer mg.active.Add(-1)
return f()
})
}
func (mg *MeteredGroup) Wait() error { return mg.g.Wait() }
func (mg *MeteredGroup) MaxConcurrency() int64 { return mg.maxSeen.Load() }
Use in tests to verify SetLimit works:
mg := &MeteredGroup{}
mg.g.SetLimit(4)
for i := 0; i < 100; i++ {
mg.Go(func() error {
time.Sleep(10 * time.Millisecond)
return nil
})
}
_ = mg.Wait()
require.LessOrEqual(t, mg.MaxConcurrency(), int64(4))
Task 19 (Professional) — Backpressure design¶
Problem. Design and implement an event handler that:
- Accepts events from a producer at unbounded rate.
- Processes at most 8 concurrently.
- If the producer outpaces the consumer by more than 1000 buffered, sheds load and returns the oldest event to the producer.
This stress-tests your understanding of TryGo, buffered channels, and overflow handling. Sketch first, then write.
Task 20 (Professional) — Compare with conc¶
Re-implement Task 5 (parallelMap) using github.com/sourcegraph/conc/pool. Compare LoC, readability, and behaviour with the errgroup version. Argue which is preferable for this case.
Solutions checklist¶
For each task you complete, verify:
- You compiled without warnings (including
go vet). - You ran the code under
go test -race. - You threaded
ctxinto every blocking call. - You captured loop variables (or you're on Go 1.22+).
- You handle the empty input case (
len(in) == 0returns immediately with nil error). - You handle the all-success and all-failure cases.
- You documented any non-obvious decision in a comment.
Difficulty progression¶
- Tasks 1–4 introduce the basic API.
- Tasks 5–9 introduce
SetLimit,TryGo, and patterns. - Tasks 10–14 combine errgroup with semaphores, retries, and deadlines.
- Tasks 15–20 challenge you to extend errgroup or build something larger.
You should be able to solve every task on a whiteboard by the time you finish senior level. Tasks 15–20 are appropriate for technical interviews at staff level or system-design rounds.