Go Concurrency Patterns: Goroutines, Channels, and Worker Pools

Goroutines Aren't Threads — And That Matters

Go's concurrency model looks simple on the surface: slap go in front of a function call and it runs concurrently. But the mental model of "lightweight threads" breaks down when you hit real production problems — goroutine leaks, channel deadlocks, race conditions, and backpressure management.

A goroutine costs about 8KB of stack space (it grows as needed) compared to 1-8MB for an OS thread. You can comfortably run millions of goroutines on a single machine. The Go scheduler multiplexes them onto OS threads using an M:N scheduling model — M goroutines on N threads, where N typically equals GOMAXPROCS (default: number of CPU cores).

That cheap creation cost is both a blessing and a trap. It's too easy to spawn goroutines without thinking about when they stop.

The Fan-Out / Fan-In Pattern

This is the most common concurrency pattern in Go: split work across multiple goroutines, then collect results.

func fetchAllUsers(ids []int64) ([]User, error) {
    type result struct {
        user User
        err  error
    }

    ch := make(chan result, len(ids))

    for _, id := range ids {
        go func(id int64) {
            user, err := fetchUser(id)
            ch <- result{user, err}
        }(id)
    }

    var users []User
    for range ids {
        r := <-ch
        if r.err != nil {
            return nil, r.err
        }
        users = append(users, r.user)
    }

    return users, nil
}

This works, but there's a problem: if you're fetching 10,000 users, you've just opened 10,000 concurrent database connections. That'll crash your connection pool or get you rate-limited.

Worker Pool: Bounded Concurrency

The worker pool pattern limits how many goroutines run simultaneously:

func fetchAllUsersPooled(ids []int64, workers int) ([]User, error) {
    type result struct {
        user User
        err  error
    }

    jobs := make(chan int64, len(ids))
    results := make(chan result, len(ids))

    // Start fixed number of workers
    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for id := range jobs {
                user, err := fetchUser(id)
                results <- result{user, err}
            }
        }()
    }

    // Send all jobs
    for _, id := range ids {
        jobs <- id
    }
    close(jobs)

    // Close results channel when all workers done
    go func() {
        wg.Wait()
        close(results)
    }()

    var users []User
    for r := range results {
        if r.err != nil {
            return nil, r.err
        }
        users = append(users, r.user)
    }

    return users, nil
}

With workers=20, you'll never have more than 20 concurrent fetches. The buffered jobs channel acts as a queue — workers pull from it as they finish previous tasks.

Context Cancellation: Stopping Work Gracefully

Every long-running goroutine should accept a context.Context and respect cancellation. This is how you implement timeouts, graceful shutdown, and request-scoped cancellation.

func processWithTimeout(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    errc := make(chan error, 1)
    go func() {
        errc <- doHeavyWork(ctx, items)
    }()

    select {
    case err := <-errc:
        return err
    case <-ctx.Done():
        return fmt.Errorf("processing timed out: %w", ctx.Err())
    }
}

The select statement is Go's mechanism for waiting on multiple channel operations. Whichever case fires first wins. If the work completes before the timeout, great. If the context expires first, you return an error.

Inside doHeavyWork, you should periodically check ctx.Err() or use select on ctx.Done() in loops. A goroutine that ignores its context after cancellation is a goroutine leak.

Avoiding Goroutine Leaks

A goroutine leak happens when a goroutine blocks forever on a channel that nobody will ever send to or receive from. The goroutine sits in memory indefinitely, and the GC can't collect it because it's still technically reachable.

Common leak patterns:

// LEAK: if doWork returns before the goroutine sends, nobody reads from ch
func leakyFunction() error {
    ch := make(chan int)
    go func() {
        result := expensiveComputation()
        ch <- result  // blocks forever if nobody reads
    }()

    if err := doWork(); err != nil {
        return err  // we return without reading from ch
    }

    val := <-ch
    fmt.Println(val)
    return nil
}

// FIXED: use buffered channel so the goroutine can send even if nobody reads
func fixedFunction() error {
    ch := make(chan int, 1)  // buffer of 1
    go func() {
        result := expensiveComputation()
        ch <- result  // won't block even if nobody reads
    }()

    if err := doWork(); err != nil {
        return err  // goroutine can still send to buffered channel and exit
    }

    val := <-ch
    fmt.Println(val)
    return nil
}

Use runtime.NumGoroutine() in tests and monitoring to detect leaks. If the count grows over time without a corresponding increase in workload, you've got a leak somewhere.

The errgroup Pattern

The golang.org/x/sync/errgroup package handles the fan-out/fan-in pattern with error propagation and context cancellation built in:

import "golang.org/x/sync/errgroup"

func fetchDashboardData(ctx context.Context) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)

    var users []User
    var orders []Order
    var metrics Metrics

    g.Go(func() error {
        var err error
        users, err = fetchUsers(ctx)
        return err
    })

    g.Go(func() error {
        var err error
        orders, err = fetchOrders(ctx)
        return err
    })

    g.Go(func() error {
        var err error
        metrics, err = fetchMetrics(ctx)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }

    return &Dashboard{users, orders, metrics}, nil
}

If any goroutine returns an error, the context gets cancelled (signaling the others to stop), and g.Wait() returns the first error. It's cleaner than manual WaitGroup + error channel management.

For bounded concurrency, use g.SetLimit(n) — it acts as the worker pool automatically.

Channels as Semaphores and Rate Limiters

A buffered channel can act as a counting semaphore:

sem := make(chan struct{}, 10) // max 10 concurrent operations

for _, item := range items {
    sem <- struct{}{} // acquire (blocks when 10 are in-flight)
    go func(item Item) {
        defer func() { <-sem }() // release
        process(item)
    }(item)
}

For rate limiting, time.Ticker combined with a channel gives you a steady-rate limiter:

limiter := time.NewTicker(100 * time.Millisecond) // 10 requests/second
defer limiter.Stop()

for _, req := range requests {
    <-limiter.C // wait for next tick
    go handleRequest(req)
}

For production rate limiting with bursts, use golang.org/x/time/rate.Limiter which implements a token bucket algorithm.

The real skill in Go concurrency isn't knowing these patterns — it's recognizing which one fits your problem. Start simple (sequential), add concurrency only where you have evidence it helps, and always think about what happens when things go wrong.