Go makes concurrency cheap to start. Put go in front of a function call and you have a goroutine with a few kilobytes of stack, managed by the runtime scheduler. That low cost is also where trouble starts. Goroutines are easy to create, so they are easy to leak, easy to over-create, and easy to forget during shutdown.
This article covers the patterns that hold up in real services. The main idea behind all of them is that every goroutine you start needs a clear owner and a clear way to end.
The three questions to ask before writing go
Before launching a goroutine, answer three things:
- Who waits for it? Something should know when it finishes, whether that's a
sync.WaitGroup, anerrgroup.Group, or a result channel. - How does it stop early? If the request is cancelled or the process is shutting down, the goroutine needs a signal it actually checks.
- How many can exist at once? "One per incoming item" has no upper bound, and anything without a bound will eventually cause a problem.
If you can't answer all three, the goroutine is probably a future leak.
Cancellation belongs in context.Context
Pass a context.Context as the first argument to anything that blocks or does I/O. It is the standard way to carry deadlines and cancellation across API boundaries.
func fetchPrice(ctx context.Context, client *http.Client, sku string) (Price, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, priceURL(sku), nil)
if err != nil {
return Price{}, err
}
resp, err := client.Do(req)
if err != nil {
return Price{}, fmt.Errorf("fetch price %s: %w", sku, err)
}
defer resp.Body.Close()
var p Price
return p, json.NewDecoder(resp.Body).Decode(&p)
}When a goroutine loops, it should check ctx.Done() in the same select it uses for its work:
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
process(job)
}
}A loop that blocks on a channel receive without also watching ctx.Done() will keep running after nobody needs its result.
Bounded parallelism with a worker pool
Suppose you need to enrich 50,000 records by calling an external API. Starting 50,000 goroutines will exhaust your connection pool, trigger rate limits, and make latency unpredictable. A fixed pool of workers reading from a shared channel keeps the load steady:
func enrichAll(ctx context.Context, records []Record, workers int) error {
g, ctx := errgroup.WithContext(ctx)
jobs := make(chan Record)
// Producer: feeds work and stops early if a worker fails.
g.Go(func() error {
defer close(jobs)
for _, r := range records {
select {
case jobs <- r:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
for i := 0; i < workers; i++ {
g.Go(func() error {
for r := range jobs {
if err := enrich(ctx, r); err != nil {
return err // cancels ctx for everyone else
}
}
return nil
})
}
return g.Wait()
}errgroup from golang.org/x/sync handles three things here. It waits for every goroutine, returns the first error, and cancels the shared context so the other workers stop quickly. If you only need a concurrency cap and not a separate producer, g.SetLimit(n) is even simpler.
Choosing the worker count
- CPU-bound work: start near
runtime.GOMAXPROCS(0). More workers than cores only adds scheduling overhead. - I/O-bound work: the limit is the downstream system, not your CPU. Size the pool to what the database or API can handle, which often means matching the HTTP client's
MaxConnsPerHostor your DB pool size.
Measure it. The right number is whatever keeps p99 latency flat while throughput rises.
Channels: ownership rules that prevent panics
Most channel bugs come from unclear ownership. Two rules cover nearly all of them:
- Only the sender closes a channel. Closing it from the receiver side causes a panic when the sender writes again.
- If there are several senders, none of them closes the channel directly. Use a
WaitGroupand one coordinating goroutine that closes it after every sender has finished.
var wg sync.WaitGroup
out := make(chan Result)
for _, src := range sources {
wg.Add(1)
go func(s Source) {
defer wg.Done()
for r := range s.Stream(ctx) {
select {
case out <- r:
case <-ctx.Done():
return
}
}
}(src)
}
go func() { wg.Wait(); close(out) }()Buffered channels are for absorbing short bursts, not for fixing a slow consumer. If you find yourself raising the buffer size to stop a stall, the consumer is the bottleneck and the buffer only delays the problem.
Detecting goroutine leaks
A leaked goroutine is usually stuck forever on a send or receive that will never complete. In a long-running service they add up quietly until memory or file descriptors run out.
- Export
runtime.NumGoroutine()as a metric. A slow upward trend under steady traffic almost always means a leak. - Use the
/debug/pprof/goroutine?debug=2endpoint to see every goroutine's stack. Leaks show up as hundreds of identical stacks parked in the same function. - In tests,
go.uber.org/goleakcan fail a test that leaves goroutines behind:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}Protecting shared state
Channels aren't the answer to every coordination problem. A counter or cache map touched by many goroutines is often clearer behind a sync.Mutex than behind a goroutine that owns it through channels. Use channels to pass ownership of data and mutexes to guard access to shared data.
For simple numeric counters, sync/atomic types like atomic.Int64 avoid lock overhead. For read-heavy maps, sync.RWMutex lets readers run in parallel. Always run your test suite with -race. The race detector finds real bugs at almost no cost in CI.
Graceful shutdown
A production service should finish in-flight work before exiting. The usual setup is a root context cancelled by OS signals:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)In Kubernetes, make the shutdown timeout shorter than terminationGracePeriodSeconds so the process exits before it is killed.
Checklist
- Every goroutine has an owner that waits for it.
- Every blocking operation respects a
context. - Every source of concurrency has an upper bound.
- Only senders close channels.
-raceruns in CI, and goroutine count is tracked in production.
Following these rules keeps Go concurrency predictable and easy to reason about.
