1. Why “just run 3 goroutines” is not enough
When you start writing concurrent code, the first emotion is excitement. “Oh, I’ll spin up 3 goroutines and everything will be fast!”—and that’s true… for the first 15 minutes. Then you discover that you need not only “in parallel”, but also in a controlled way: if one subtask fails with an error, the others must stop cleanly, rather than continuing to burn CPU and keep connections open “out of principle”.
A typical scenario in a real application (and in our training “task tracker”) looks like this: one user operation consists of several independent steps. For example, for the tasks sync command we simultaneously read tasks from a file and pull updates from the API. We immediately get three questions: how to wait for everyone, how to return an error, and how to stop the others if an error has already happened.
To see the problem, let’s start with naive code. It starts two goroutines and waits for them, but “errors kind of get lost”, and context cancellation does not make everyone stop.
package main
import (
"context"
"sync"
)
func naive(ctx context.Context) error {
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = ctx /* work A */ }()
go func() { defer wg.Done(); _ = ctx /* work B */ }()
wg.Wait()
return nil
}
Formally, this “works”, but in practice it doesn’t solve the main thing: if one part fails, the other won’t know. And even more importantly: if the operation is canceled from above (timeout/user cancel), the “sub-works” must respect that—otherwise you’ll get goroutine leaks.
2. The errgroup idea: “a goroutine group as one operation”
Now we arrive at the main idea of the lecture. In applied Go you very often need to combine several goroutines into one logical operation: “while the request is running, N subtasks run; if the request is canceled, they all wind down; if one subtask fails, we consider the whole operation failed”.
This is exactly what we call the errgroup idea: there is some “group” entity that:
- starts goroutines,
- waits for them to finish,
- returns an error (usually the first),
- and often can cancel a shared context to stop the rest.
In Go this is usually associated with the golang.org/x/sync/errgroup package, but today what matters is not the import, but the mechanics: how this thing behaves and how to assemble it “by hand”, using tools you already know.
Why does context almost always participate here? Because Context gives us a standard way to tell all goroutines: “folks, the operation is over, let’s wrap it up”. The Done() channel is the cancellation signal, and Err() explains the reason.
So it doesn’t look like magic, here’s a small diagram of what we want to get:
flowchart TD
A[Input: ctx + list of subtasks] --> B[Create a child ctx and cancel]
B --> C[Start N goroutine]
C --> D{Did someone return err?}
D -- no --> E[All finished successfully]
D -- yes --> F[Store the first error]
F --> G["Call cancel()"]
G --> H[Wait for all to finish]
H --> I[Return the error]
Note an important discipline: cancel() is called by the one who created it. This matches the context model itself: a function that receives a ctx should not “arbitrarily” cancel someone else’s context; if it needs control, it creates a child context and owns its cancel.
3. Manual errgroup: WaitGroup + error channel + cancel
Now we’ll build a minimal “manual errgroup”, without external packages. We’ll need:
- sync.WaitGroup, to wait for all goroutines;
- errCh (an error channel), to safely pass an error outward;
- context.WithCancel, to have a shared “stop signal” for all participants.
And a subtle point appears immediately: how to send an error without hanging. If the error channel is unbuffered and the “reader” is not ready yet, the send will block the goroutine—and you’ll get a deadlock instead of cancellation. In classic Go concurrency patterns, people often use a buffer of 1 and/or a non-blocking send via select { case ch <- v: default: }.
In materials and examples about timeouts and concurrency patterns in Go, a non-blocking send through select is often shown as a way not to leave a goroutine “hanging”, but in practice the buffer matters too: it guarantees that the first error actually gets through.
Let’s assemble the “first error wins” function.
package main
import (
"context"
"sync"
)
func RunFirstError(ctx context.Context, fns []func(context.Context) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, 1)
var wg sync.WaitGroup
wg.Add(len(fns))
for _, fn := range fns {
go func() {
defer wg.Done()
if err := fn(ctx); err != nil {
select {
case errCh <- err:
cancel()
default:
}
}
}()
}
wg.Wait()
close(errCh)
return <-errCh
}
Let’s unpack what’s happening here, as if we were debugging it with a flashlight.
The errCh channel has a buffer of 1. This means: the first error will definitely be able to “write itself”, even if RunFirstError hasn’t reached the read yet. Then we use select with default so that any subsequent error does not block the goroutine (because we only care about the first one).
After successfully writing the first error we call cancel(). This closes ctx.Done() and gives all other participants a chance to exit quickly—if they are written “politely”. And “politeness” here is very specific: inside long waits and loops, you need to listen to <-ctx.Done() and return ctx.Err().
4. Subtasks that stop on ctx.Done()
It’s very easy to write a goroutine that “doesn’t know how to stop”. For example, it does time.Sleep(10 * time.Second) or waits to read from a channel, and it doesn’t care about context cancellation at all. As a result, you call cancel(), and your goroutines keep living, like a cat pretending it didn’t hear you.
That’s why a subtask is usually built around select: either “the work finished”, or “the context was canceled”. This is the same principle used in timeout patterns: you have multiple events, and you pick whichever happens first.
Mini example of an interruptible “step”:
package main
import (
"context"
"time"
)
func Step(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
There is no magic here. ctx.Done() is a signal channel; when it’s closed, reading from it proceeds immediately, and we return the reason via ctx.Err(). It is Err() that explains whether the operation was canceled manually (context.Canceled) or the deadline expired (context.DeadlineExceeded).
5. Strategy: collect all errors
Sometimes “the first error” is an ideal strategy: for example, when querying multiple database replicas we only need one successful result, and the first error only signals “this branch died, but others might still make it”.
But sometimes it’s the opposite: you’re validating a large import or doing batch processing, and you want to collect all problems to show the user a complete report, rather than “the first one you ran into”. In our course we already have a tool for this: errors.Join, which combines multiple errors into one. (We won’t dive into how it works; what matters is being able to apply it.)
To collect all errors, there’s an important principle: don’t write into a shared []error from multiple goroutines directly, because that’s a data race. A beginner-friendly option is simple: let each goroutine send its error into a buffered channel, and do the collection in one place after Wait().
package main
import (
"context"
"errors"
"sync"
)
func RunAllErrors(ctx context.Context, fns []func(context.Context) error) error {
errCh := make(chan error, len(fns))
var wg sync.WaitGroup
wg.Add(len(fns))
for _, fn := range fns {
go func() {
defer wg.Done()
errCh <- fn(ctx) // may be nil — we’ll filter later
}()
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Here the strategy is different: we do not cancel the others on the first error (because we want to finish everything), and we make the channel size len(fns) so sending never blocks. This, of course, consumes memory proportional to the number of tasks, but for a “moderate number of goroutines” it’s fine and very readable.
6. Practice: embedding it into an application
Parallel data loading for a “task dashboard”
Now for the nicest part: an example that doesn’t live in a vacuum. Imagine that in our training task app (task tracker) there is a “show dashboard” operation: it wants to simultaneously get the list of tasks and statistics about them. We’ll deliberately pretend the sources are independent: tasks are read from storage, while stats are computed/fetched separately. Yes, in reality you could compute stats after reading tasks, but what matters here is the shape of the problem: “two parallel sub-operations”.
Let’s start with simple types.
package main
type Task struct {
ID int
Text string
Done bool
}
type Stats struct {
Total int
Done int
}
Next, let’s sketch a storage interface that already matches the usual Go style: ctx as the first parameter. This supports an “end-to-end route” for cancellation from top to bottom.
package main
import "context"
type TaskStorage interface {
List(ctx context.Context) ([]Task, error)
Stats(ctx context.Context) (Stats, error)
}
Now an application-level function: fetch both parts in parallel and return a single aggregated structure.
package main
type Dashboard struct {
Tasks []Task
Stats Stats
}
And now the most important part: an implementation via our RunFirstError. We want: if at least one branch fails, stop the second one and return the error.
package main
import "context"
func LoadDashboard(ctx context.Context, st TaskStorage) (Dashboard, error) {
var d Dashboard
err := RunFirstError(ctx, []func(context.Context) error{
func(ctx context.Context) error {
tasks, err := st.List(ctx)
d.Tasks = tasks
return err
},
func(ctx context.Context) error {
stats, err := st.Stats(ctx)
d.Stats = stats
return err
},
})
return d, err
}
There’s a nuance here that’s worth stating honestly: we write results (d.Tasks, d.Stats) from different goroutines into a single d struct. In practice this can be a data race if those writes happen at the same time. To make the example safe, people typically use either separate variables and write to them (and then assemble after Wait()), or protect the writes with a mutex, or use result channels.
So let’s fix it “like adults, but not scary”: we’ll create two separate variables and won’t write into d concurrently.
package main
import "context"
func LoadDashboard(ctx context.Context, st TaskStorage) (Dashboard, error) {
var tasks []Task
var stats Stats
err := RunFirstError(ctx, []func(context.Context) error{
func(ctx context.Context) error { t, e := st.List(ctx); tasks = t; return e },
func(ctx context.Context) error { s, e := st.Stats(ctx); stats = s; return e },
})
return Dashboard{Tasks: tasks, Stats: stats}, err
}
This version is easier to reason about: yes, there’s still a potential race on writing to tasks/stats (because they are shared variables), but note that each variable is written by only one goroutine. For a single write this is usually “safe enough” by intent, but formally a data race is still possible if reads and writes overlap. The strictest variant is to write results into channels and assign only after the group finishes; but that would bloat the example. In a teaching lecture it’s more important to understand the mechanics of “error/cancel/wait”.
Errors: context and wrapping
When we combine multiple sub-operations into one, errors become part of the contract: the caller wants to understand what exactly failed. In Go, the typical approach is to add context via fmt.Errorf("operation: %w", err) and preserve the ability to check via errors.Is/errors.As when appropriate.
For our subtasks this means: if st.List(ctx) returns an error, it’s useful to wrap it as “list tasks: …”, and if st.Stats(ctx) fails—wrap it as “load stats: …”. Then the top level (CLI/HTTP boundary) can log details correctly, and show the user a short message.
Mini example of wrapping inside a subtask:
package main
import (
"context"
"fmt"
)
func listWithContext(ctx context.Context, st TaskStorage) ([]Task, error) {
tasks, err := st.List(ctx)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
return tasks, nil
}
This habit is especially valuable in concurrent code: when something fails “in parallel”, without context in the error you’ll be staring at the log like it’s a note saying “I broke, guess where”.
7. Common mistakes
Mistake #1: calling WaitGroup.Add inside the goroutine.
It looks harmless: “I’m adding 1 anyway.” But Wait() may run before the goroutine has time to do Add(1), and then the wait completes prematurely. The discipline rule is simple: first Add, then start go func(){...}. This isn’t bureaucracy; it’s protection from rare and nasty timing races.
Mistake #2: the error channel is unbuffered, and the read happens “sometime later”.
If a goroutine tries to send an error into an unbuffered channel but nobody is reading at that moment, it blocks. In concurrent code this is especially dangerous: you wanted to “catch the error and cancel”, but you got a goroutine stuck on sending the error and preventing Wait() from finishing. A buffer of 1 for the “first error” is almost standard. And a non-blocking send via select with default is the typical way to avoid letting a second error hang the system.
Mistake #3: closing errCh from a worker goroutine.
Closing a channel is the responsibility of the owner who knows that there will be no more writes. If you close the channel from workers, it’s easy to get a “send on closed channel” panic because another worker is still trying to send an error. Much more reliable: do wg.Wait() in one place, then close(errCh) there as well.
Mistake #4: forgetting to call cancellation (cancel()) or calling it in the wrong place.
If you create ctx, cancel := context.WithCancel(parent), then cancel belongs to the creator. If you don’t call it (or don’t do defer cancel()), you risk holding resources longer than necessary. If, on the other hand, you call cancel() inside a deep function that is not the owner, you break the lifetime contract: the upper layer may suddenly discover a canceled context “for no reason”. In the context model this is intentionally separated: the recipient of the cancellation signal is usually not supposed to be the one who sends it.
Mistake #5: subtasks don’t check ctx.Done() and don’t return ctx.Err().
As a result, cancellation looks like “we canceled, but nothing stopped”. If a subtask does waiting, I/O, or a loop, it needs exit points. The ideal minimum is a select between “work finished” and “context canceled”, as in the Step example. And you should return exactly ctx.Err() so the reason is standard and the caller can distinguish cancellation from deadline.
GO TO FULL VERSION