Why Most Go Concurrency Tutorials Fail You
Tutorials show you goroutines and channels. Production shows you goroutine leaks at 3 AM. Here are the patterns that actually matter when you're running high-throughput services.
1. Always Use Context for Cancellation
Never launch a goroutine without a cancellation path.
func processStream(ctx context.Context, stream <-chan Event) error { for { select { case <-ctx.Done(): return ctx.Err() case event, ok := <-stream: if !ok { return nil } if err := handle(event); err != nil { return fmt.Errorf("handle event: %w", err) } } } }
2. Worker Pool — The Right Way
func workerPool(ctx context.Context, jobs <-chan Job, concurrency int) error { g, ctx := errgroup.WithContext(ctx) for range concurrency { g.Go(func() error { for { select { case <-ctx.Done(): return ctx.Err() case job, ok := <-jobs: if !ok { return nil } if err := job.Process(ctx); err != nil { return err } } } }) } return g.Wait() }
3. Semaphore for Resource Limiting
type Semaphore chan struct{} func NewSemaphore(n int) Semaphore { return make(chan struct{}, n) } func (s Semaphore) Acquire(ctx context.Context) error { select { case s <- struct{}{}: return nil case <-ctx.Done(): return ctx.Err() } } func (s Semaphore) Release() { <-s }
Key Takeaways
- Every goroutine needs an exit condition
- errgroup > sync.WaitGroup for error propagation
- Bounded pools > unbounded goroutines
- Test for leaks with
go.uber.org/goleak