If you’ve worked with Go long enough, you’ve probably written code like this:
go doWork()
It works.
Until it doesn’t.
At some point in production, you start asking uncomfortable questions:
- Why is this goroutine still running?
- Who is supposed to stop this?
- Why does shutdown sometimes hang?
- Why do we have “random” goroutine leaks?
This isn’t a channel problem.
It’s not a mutex problem.
It’s a lifecycle problem.
What Structured Concurrency Means (Without the Buzzwords)
Structured concurrency is simple:
If you start concurrent work, you must know when and how it ends.
That’s it.
Every goroutine should have:
- an owner
- a clear lifetime
- a cancellation signal
- a place where errors go
If any of those are missing, the code might work locally — but it’s fragile in production.
The Fire-and-Forget Anti-Pattern
A very common example:
func handler(w http.ResponseWriter, r *http.Request) {
go auditLog(r)
processRequest(r)
}
Looks innocent.
But what happens when:
- the request times out?
- the client disconnects?
- shutdown starts?
-
auditLogblocks on I/O?
You now have a goroutine:
- detached from the request
- uncancellable
- invisible
- unaccounted for
Multiply this by traffic and time.
Context Is Necessary — But Not Enough
Yes, you should pass context.Context:
go auditLog(r.Context(), r)
But context alone doesn’t:
- wait for goroutines
- propagate errors
- coordinate siblings
You still need structure.
errgroup: Practical Structured Concurrency in Go
The most useful tool for this is:
golang.org/x/sync/errgroup
Example:
func processOrder(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return reserveInventory(ctx)
})
g.Go(func() error {
return chargePayment(ctx)
})
g.Go(func() error {
return notifyUser(ctx)
})
return g.Wait()
}
What you get:
- if one fails → all are cancelled
- no orphaned goroutines
- clear ownership
- predictable shutdown
This single pattern prevents a huge class of bugs.
Worker Pools Without Leaks
Unstructured:
for job := range jobs {
go handle(job)
}
Structured:
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < workers; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
if err := handle(job); err != nil {
return err
}
}
}
})
}
return g.Wait()
Now:
- workers stop on cancel
- errors are visible
- shutdown is clean
A Simple Mental Checklist
Before starting a goroutine, ask:
- Who owns this goroutine?
- Who cancels it?
- Who waits for it?
- Where does its error go?
If you can’t answer all four — it’s a bug waiting to happen.
Takeaways
- Goroutines must have owners
- Context enables cancellation, not structure
- errgroup gives you lifecycle guarantees
-
Structured concurrency prevents:
- goroutine leaks
- deadlocks
- hanging shutdowns
Go doesn’t enforce this — production does.
Happy Coding! 🚀
Top comments (0)