Dev Skills

Concurrency patterns

Run once, wait for many, stop on the first error, cap the parallelism, and never leak a goroutine.

Topic 43 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Write an errgroup that runs several tasks and returns the first error.
  • Write a worker pool that caps how many goroutines run at once.
  • Explain how a goroutine leaks when nobody reads from its channel, and fix it.

Why patterns

Goroutines, channels, mutexes and context are the parts. Most real code combines them in the same few ways: do something once, run N things and wait, stop everything when one fails, limit how many run at a time.

Each pattern below has a ready-made tool. Use it instead of hand-rolling the channels, because the hand-rolled version is where the leaks and races hide.

sync.Once — do it exactly once

once.Do(f) runs f the first time it is called, from any goroutine. Every other caller waits until that first call has finished, then returns without running f. Use it for lazy setup: loading config, opening a shared client.

Since Go 1.21, sync.OnceValue and sync.OnceValues wrap the same idea and also return the result, which is usually what you want.

once.go
var loadConfig = sync.OnceValue(func() map[string]string {
fmt.Println("loading config") // runs once, however many callers
return map[string]string{"env": "prod"}
})
cfg := loadConfig() // first call loads
cfg = loadConfig() // later calls get the same map
If `f` panics, `Once` still counts it as done, and later `Do` calls do nothing. `OnceValue` instead re-panics with the same value on every call.

WaitGroup.Go — start and wait, without the Add/Done dance

Go 1.25 added wg.Go(f). It does the Add(1), starts the goroutine, and calls Done when f returns. You can no longer put the Add in the wrong place or forget the Done.

wggo.go
var wg sync.WaitGroup
for i := range 3 {
wg.Go(func() { // Go 1.25+
fmt.Println("task", i) // own i per iteration since Go 1.22
})
}
wg.Wait()
On Go 1.24 or older, write `wg.Add(1)` before `go func() { defer wg.Done(); ... }()` instead.

errgroup — wait for many, stop on the first error

A WaitGroup does not collect errors. errgroup does. g.Wait() waits for every goroutine and returns the first non-nil error. With errgroup.WithContext, that first error also cancels a shared context, so the other goroutines can stop early, if they check it.

g.SetLimit(n) caps how many run at once: g.Go blocks until a slot is free. That gives you a bounded worker pool in two lines.

It lives in golang.org/x/sync/errgroup, one of the x/ packages maintained by the Go team but outside the standard library. Add it with go get golang.org/x/sync. The Playground cannot fetch it, so this example is not runnable here. The next section builds the same thing by hand, and that one runs.

errgroup.go — needs golang.org/x/sync, run it in your own module
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx) // ctx is cancelled on the first error
g.SetLimit(4) // at most 4 fetches at a time
bodies := make([]string, len(urls))
for i, url := range urls {
g.Go(func() error {
body, err := fetch(ctx, url) // fetch must honour ctx
if err != nil {
return err
}
bodies[i] = body // each goroutine writes its own index: no race
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return bodies, nil
}
Cancelling the context does not kill the other goroutines. `Wait` still waits for all of them, so they must return promptly when `ctx.Done()` closes.

What errgroup does, by hand

You cannot run errgroup on the Playground, so here is a stand-in using only the standard library. It is a tiny version of the same idea: a WaitGroup to wait, a sync.Once to keep the first error, and context.WithCancelCause to cancel the others.

Use the real errgroup in your own code. This is only to show there is no magic inside it.

group.go — what errgroup does, by hand (standard library only)
type group struct {
wg sync.WaitGroup
once sync.Once
err error
cancel context.CancelCauseFunc
}
func withContext(ctx context.Context) (*group, context.Context) {
ctx, cancel := context.WithCancelCause(ctx)
return &group{cancel: cancel}, ctx
}
func (g *group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.once.Do(func() { // keep only the first error
g.err = err
g.cancel(err)
})
}
}()
}
func (g *group) Wait() error {
g.wg.Wait()
g.cancel(g.err) // release the context's resources
return g.err
}
Task 1 fails at once, the context is cancelled, and tasks 0 and 2 stop early instead of waiting a second. `Wait` returns the first error. There is no `SetLimit` here; the real package has it.

A worker pool with bounded concurrency

Without errgroup, the classic shape is a fixed number of workers reading from one jobs channel. The producer closes jobs when it is done, each worker's range loop then ends, and a separate goroutine closes results once every worker has returned.

pool.go
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
for range 3 { // 3 workers: at most 3 jobs run at once
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs { // ends when jobs is closed and empty
results <- n * n
}
}()
}
go func() { // the producer: sends, then closes
for n := 1; n <= 5; n++ {
jobs <- n
}
close(jobs)
}()
go func() { // close results only after every worker is done
wg.Wait()
close(results)
}()
sum := 0
for r := range results {
sum += r
}
fmt.Println("sum of squares 1..5:", sum) // always 55
}
Results arrive in any order, so the example sums them. If order matters, send the index with each job and write into a slice.

Goroutine leaks

A goroutine leaks when it blocks forever on something nobody will ever do: a send nobody receives, a receive on a channel nobody closes, a wait on a context nobody cancels. The garbage collector never frees a blocked goroutine, so its stack and everything it references stay in memory. In a server that leaks one per request, memory climbs until the process dies.

The usual cause is an early return. The caller gives up (timeout, error) and stops listening, while the goroutine it started is still trying to send.

  • Give every goroutine a way out. Before you write go, know what makes it return.
  • Select on `ctx.Done()` next to every blocking send or receive in long-running goroutines.
  • Close the channel a range loop reads from, from the sending side, when the work is done.
  • Buffer the result channel with room for every send when the caller might leave early: make(chan T, 1) for one result. The send then never blocks, and the goroutine exits.
  • Detect leaks in tests. runtime.NumGoroutine() before and after gives a rough count. go.uber.org/goleak fails a test when goroutines are left over: defer goleak.VerifyNone(t). In a live server, the goroutine profile from net/http/pprof (/debug/pprof/goroutine?debug=1) shows where they are all stuck.

Try it yourself

press Run — this executes on the Go Playground
package main
import (
"fmt"
"runtime"
"time"
)
// slowLookup gives up after 10ms, but the work takes 50ms.
func slowLookup(buffered bool) (int, bool) {
var ch chan int
if buffered {
ch = make(chan int, 1) // room for the one send: it never blocks
} else {
ch = make(chan int) // the send waits for a receiver that has left
}
go func() {
time.Sleep(50 * time.Millisecond)
ch <- 42
}()
select {
case v := <-ch:
return v, true
case <-time.After(10 * time.Millisecond):
return 0, false // we stop listening here
}
}
func main() {
before := runtime.NumGoroutine()
for range 3 {
slowLookup(false)
}
time.Sleep(100 * time.Millisecond) // let the workers finish if they can
fmt.Println("unbuffered, leaked:", runtime.NumGoroutine()-before)
before = runtime.NumGoroutine()
for range 3 {
slowLookup(true)
}
time.Sleep(100 * time.Millisecond)
fmt.Println("buffered, leaked:", runtime.NumGoroutine()-before)
}
The unbuffered version leaks one goroutine per call, stuck on `ch <- 42` forever. A buffer of one fixes it. Passing a context and selecting on `ctx.Done()` around the send fixes it too.
The short version
  • sync.Once / sync.OnceValue for lazy setup that must run exactly once.
  • wg.Go(f) (Go 1.25) does the Add and Done for you.
  • errgroup (golang.org/x/sync) returns the first error, cancels the shared context, and SetLimit caps parallelism.
  • A worker pool is N goroutines ranging over one jobs channel. The producer closes jobs; a waiter closes results.
  • A blocked goroutine is never collected. Every goroutine needs a way out: a context, a close, or a buffer.
Remember: Before you type go, know how that goroutine will end.
Build this before moving on
Write a function that fetches 20 fake items with at most 4 in flight, returns the first error, and leaves no goroutines behind. Check the last part with runtime.NumGoroutine().
Then try these in your own editor
  1. 1Call a sync.OnceValue function from 100 goroutines. How many times does the setup print?
  2. 2In the errgroup example, make one fetch fail immediately while the others sleep for a second without checking ctx. How long does Wait take?
  3. 3In the worker pool, delete the close(jobs) line. What happens?
  4. 4Fix the leaking slowLookup with a context.Context instead of a buffer.
Check yourselfTake the Concurrency test