Concurrency patterns
Run once, wait for many, stop on the first error, cap the parallelism, and never leak a goroutine.
- Write an
errgroupthat 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.
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 loadscfg = loadConfig() // later calls get the same mapWaitGroup.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.
var wg sync.WaitGroupfor i := range 3 { wg.Go(func() { // Go 1.25+ fmt.Println("task", i) // own i per iteration since Go 1.22 })}wg.Wait()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.
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}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.
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}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.
package mainimport ( "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}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
rangeloop 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/goleakfails a test when goroutines are left over:defer goleak.VerifyNone(t). In a live server, the goroutine profile fromnet/http/pprof(/debug/pprof/goroutine?debug=1) shows where they are all stuck.
Try it yourself
package mainimport ( "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)}sync.Once/sync.OnceValuefor lazy setup that must run exactly once.wg.Go(f)(Go 1.25) does theAddandDonefor you.errgroup(golang.org/x/sync) returns the first error, cancels the shared context, andSetLimitcaps 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.
go, know how that goroutine will end.runtime.NumGoroutine().- 1Call a
sync.OnceValuefunction from 100 goroutines. How many times does the setup print? - 2In the errgroup example, make one fetch fail immediately while the others sleep for a second without checking
ctx. How long doesWaittake? - 3In the worker pool, delete the
close(jobs)line. What happens? - 4Fix the leaking
slowLookupwith acontext.Contextinstead of a buffer.