~/msh
Advanced Go concurrency patterns
all writing
·
  • #go
  • #concurrency
  • #performance
  • #channels
  • #design patterns

Beyond Goroutines: Advanced Concurrency Patterns in Go

Dive into advanced Go concurrency patterns beyond basic goroutines and channels, with practical examples and performance considerations for real-world applications.

Beyond Goroutines: Advanced Concurrency Patterns in Go

One of the reasons I fell in love with Go is its elegant approach to concurrency. While most developers get comfortable with basic goroutines and channels, the true power of Go’s concurrency model reveals itself when you start applying advanced patterns to solve complex problems.

In this post, I’ll walk through some of the more sophisticated concurrency patterns I’ve used in production systems that process millions of records daily.

The Limitations of “Naïve Concurrency”

First, let’s acknowledge the elephant in the room: throwing goroutines at every problem isn’t always the answer. I learned this the hard way when a seemingly innocent line of code brought down an entire service:

// DON'T do this with unbounded input!
for _, item := range hugeListOfItems {
    go processItem(item)
}

This “fire-and-forget” approach might work in demos, but in production, it can:

  1. Exhaust system resources (each goroutine consumes memory)
  2. Overwhelm downstream services
  3. Make error handling virtually impossible
  4. Provide no backpressure mechanism

Let’s look at patterns that address these issues.

Worker Pools: Controlled Concurrency

Worker pools allow you to limit the number of concurrent operations while still processing items in parallel:

func processItems(items []Item) error {
    numWorkers := 10
    itemCh := make(chan Item)
    errCh := make(chan error, len(items))
    
    // Start worker pool
    var wg sync.WaitGroup
    wg.Add(numWorkers)
    for i := 0; i < numWorkers; i++ {
        go func() {
            defer wg.Done()
            for item := range itemCh {
                if err := processItem(item); err != nil {
                    errCh <- err
                }
            }
        }()
    }
    
    // Send items to workers
    for _, item := range items {
        itemCh <- item
    }
    close(itemCh)
    
    // Wait for workers to finish
    wg.Wait()
    close(errCh)
    
    // Collect errors
    var errs []error
    for err := range errCh {
        errs = append(errs, err)
    }
    
    if len(errs) > 0 {
        return fmt.Errorf("encountered %d errors: %v", len(errs), errs)
    }
    return nil
}

This pattern gives you control over concurrency levels, proper error collection, and ensures all work completes before moving on.

The Context Package: Managing Cancellation

The context package is crucial for controlling the lifecycle of concurrent operations. Here’s a pattern I use for operations that need timeout controls:

func processWithTimeout(item Item) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    resultCh := make(chan result, 1)
    errCh := make(chan error, 1)
    
    go func() {
        res, err := processItemWithResult(item)
        if err != nil {
            errCh <- err
            return
        }
        resultCh <- res
    }()
    
    select {
    case res := <-resultCh:
        return handleResult(res)
    case err := <-errCh:
        return err
    case <-ctx.Done():
        return fmt.Errorf("processing timed out: %w", ctx.Err())
    }
}

What I love about this pattern is its composability - you can pass the context through layers of function calls, allowing cancellation to propagate gracefully through your call stack.

The Fan-Out, Fan-In Pattern

When you need to process data through multiple stages in parallel, the fan-out, fan-in pattern shines:

func processData(data []Data) ([]Result, error) {
    // Fan-out stage
    processCh := make(chan Data)
    transformCh := make(chan IntermediateResult)
    resultCh := make(chan Result)
    
    // Step 1: Process (fan out to 5 workers)
    var processWg sync.WaitGroup
    processWg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            defer processWg.Done()
            for d := range processCh {
                ir, err := processData(d)
                if err != nil {
                    // Error handling omitted for brevity
                    continue
                }
                transformCh <- ir
            }
        }()
    }
    
    // Step 2: Transform (fan out to 3 workers)
    var transformWg sync.WaitGroup
    transformWg.Add(3)
    for i := 0; i < 3; i++ {
        go func() {
            defer transformWg.Done()
            for ir := range transformCh {
                r := transformData(ir)
                resultCh <- r
            }
        }()
    }
    
    // Close channels when previous stage completes
    go func() {
        for _, d := range data {
            processCh <- d
        }
        close(processCh)
        processWg.Wait()
        close(transformCh)
        transformWg.Wait()
        close(resultCh)
    }()
    
    // Fan-in: collect results
    var results []Result
    for r := range resultCh {
        results = append(results, r)
    }
    
    return results, nil
}

This pattern creates a pipeline of concurrent operations, where each stage can operate at different rates. I’ve used this extensively for data processing workflows where different stages have different computational requirements.

Rate Limiting with Leaky Bucket

When working with external APIs or services with rate limits, implementing a leaky bucket pattern is incredibly useful:

type RateLimiter struct {
    rate       time.Duration
    maxBuckets int
    buckets    chan struct{}
}

func NewRateLimiter(rps int, burstLimit int) *RateLimiter {
    r := &RateLimiter{
        rate:       time.Second / time.Duration(rps),
        maxBuckets: burstLimit,
        buckets:    make(chan struct{}, burstLimit),
    }
    
    // Fill the bucket initially
    for i := 0; i < burstLimit; i++ {
        r.buckets <- struct{}{}
    }
    
    // Refill the bucket at the defined rate
    go func() {
        ticker := time.NewTicker(r.rate)
        defer ticker.Stop()
        
        for range ticker.C {
            select {
            case r.buckets <- struct{}{}:
                // Bucket refilled
            default:
                // Bucket full, skip
            }
        }
    }()
    
    return r
}

func (r *RateLimiter) Wait(ctx context.Context) error {
    select {
    case <-r.buckets:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

I use this pattern when calling external APIs that enforce rate limits. It smooths out traffic spikes and prevents HTTP 429 errors, which are the bane of any integration project.

Work Stealing for Dynamic Workloads

For workloads where some items take significantly longer to process than others, a work-stealing pattern can improve throughput:

func processWithWorkStealing(items []Item) []Result {
    numWorkers := runtime.GOMAXPROCS(0)
    
    // Create queues for each worker
    queues := make([]chan Item, numWorkers)
    for i := range queues {
        queues[i] = make(chan Item, len(items)/numWorkers+1)
    }
    
    // Distribute work initially
    for i, item := range items {
        queueIdx := i % numWorkers
        queues[queueIdx] <- item
    }
    
    // Close all queues to signal no more initial work
    for _, q := range queues {
        close(q)
    }
    
    // Results channel
    resultCh := make(chan Result, len(items))
    
    var wg sync.WaitGroup
    wg.Add(numWorkers)
    
    // Start workers
    for workerID, queue := range queues {
        go func(id int, myQueue chan Item) {
            defer wg.Done()
            
            // Process own queue first
            for item := range myQueue {
                result := processItem(item)
                resultCh <- result
            }
            
            // Then try to steal work from other queues
            for i := 1; i < numWorkers; i++ {
                otherQueueIdx := (id + i) % numWorkers
                otherQueue := queues[otherQueueIdx]
                
                // Try to steal any remaining work
                for item := range otherQueue {
                    result := processItem(item)
                    resultCh <- result
                }
            }
        }(workerID, queue)
    }
    
    // Wait for all workers to finish
    go func() {
        wg.Wait()
        close(resultCh)
    }()
    
    // Collect results
    var results []Result
    for r := range resultCh {
        results = append(results, r)
    }
    
    return results
}

This pattern shines when processing heterogeneous workloads. Workers that finish their assigned tasks early can “steal” work from other queues, improving overall throughput and resource utilization.

Semaphores for Resource Control

Sometimes you need to limit concurrent access to a specific resource that isn’t about CPU utilization, like database connections or file handles. Weighted semaphores are perfect for this:

type Semaphore struct {
    permits chan struct{}
}

func NewSemaphore(maxConcurrent int) *Semaphore {
    return &Semaphore{
        permits: make(chan struct{}, maxConcurrent),
    }
}

func (s *Semaphore) Acquire(ctx context.Context) error {
    select {
    case s.permits <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (s *Semaphore) Release() {
    <-s.permits
}

// Usage example
func accessLimitedResource() {
    // Allow max 10 concurrent DB connections
    dbSemaphore := NewSemaphore(10)
    
    for _, task := range tasks {
        go func(t Task) {
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            defer cancel()
            
            // Acquire permit or timeout
            if err := dbSemaphore.Acquire(ctx); err != nil {
                log.Printf("Failed to acquire DB connection: %v", err)
                return
            }
            defer dbSemaphore.Release()
            
            // Now access the database
            result, err := queryDatabase(t)
            // ...
        }(task)
    }
}

I’ve used this pattern extensively to manage connections to external systems, preventing connection exhaustion and improving stability.

Error Groups for Parallel Operations

The errgroup package from golang.org/x/sync provides a clean way to manage errors across concurrent operations:

func fetchAllUserData(ctx context.Context, userIDs []string) (map[string]UserData, error) {
    g, ctx := errgroup.WithContext(ctx)
    
    userDataMap := sync.Map{}
    
    // Limit concurrency to avoid overwhelming the system
    sem := make(chan struct{}, 10)
    
    for _, id := range userIDs {
        id := id // Capture for goroutine
        
        sem <- struct{}{} // Acquire
        g.Go(func() error {
            defer func() { <-sem }() // Release
            
            data, err := fetchUserData(ctx, id)
            if err != nil {
                return fmt.Errorf("error fetching data for user %s: %w", id, err)
            }
            
            userDataMap.Store(id, data)
            return nil
        })
    }
    
    // Wait for all fetches to complete or for an error
    if err := g.Wait(); err != nil {
        return nil, err
    }
    
    // Convert sync.Map to regular map
    result := make(map[string]UserData)
    userDataMap.Range(func(key, value interface{}) bool {
        result[key.(string)] = value.(UserData)
        return true
    })
    
    return result, nil
}

What I love about error groups is that they propagate cancellation automatically when any goroutine returns an error, preventing unnecessary work when something has already failed.

The Power of Pipelines

Go’s channels are perfect for creating processing pipelines. Here’s a pattern I’ve used for data processing:

func processItems(ctx context.Context, items []Item) ([]Result, error) {
    // Create pipeline stages
    stage1 := validateItems(ctx, items)
    stage2 := enrichItems(ctx, stage1)
    stage3 := transformItems(ctx, stage2)
    results := collectResults(ctx, stage3)
    
    return results, nil
}

func validateItems(ctx context.Context, items []Item) <-chan Item {
    out := make(chan Item)
    
    go func() {
        defer close(out)
        for _, item := range items {
            if ctx.Err() != nil {
                return
            }
            
            if valid(item) {
                out <- item
            }
        }
    }()
    
    return out
}

func enrichItems(ctx context.Context, in <-chan Item) <-chan EnrichedItem {
    out := make(chan EnrichedItem)
    
    go func() {
        defer close(out)
        for item := range in {
            if ctx.Err() != nil {
                return
            }
            
            enriched, err := addMetadata(item)
            if err != nil {
                // Handle error or skip
                continue
            }
            
            out <- enriched
        }
    }()
    
    return out
}

// Additional stages follow similar pattern...

func collectResults(ctx context.Context, in <-chan ProcessedItem) []Result {
    var results []Result
    
    for item := range in {
        if ctx.Err() != nil {
            break
        }
        
        results = append(results, toResult(item))
    }
    
    return results
}

This pattern separates concerns cleanly, making the code more maintainable and testable. Each stage does one thing and can be tested in isolation.

Performance Considerations

When using these patterns, keep these performance considerations in mind:

  1. Channel Size: Unbuffered channels cause synchronization overhead. For performance-critical code, benchmark different buffer sizes.

  2. Goroutine Overhead: While goroutines are lightweight, they’re not free. Each goroutine consumes ~2KB of stack space initially. Create them judiciously.

  3. Work Batching: Sometimes batching work can be more efficient than processing individual items, especially when dealing with I/O or network operations.

  4. CPU-Bound vs. I/O-Bound: For CPU-bound tasks, limit concurrency to GOMAXPROCS. For I/O-bound tasks, you can typically use higher concurrency levels.

  5. Memory Pressure: Watch for high allocations in concurrent code, as this can trigger more frequent garbage collection pauses.

Debugging Concurrent Code

Debugging concurrent code can be challenging. Here are some tools I’ve found invaluable:

  1. Race Detector: Always run tests with -race to catch data races early:

    go test -race ./...
  2. pprof: For profiling concurrent performance issues:

    import _ "net/http/pprof"
    
    // In your main()
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
  3. Execution Tracing: Go’s execution tracer provides deep insights:

    f, err := os.Create("trace.out")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    
    err = trace.Start(f)
    if err != nil {
        log.Fatal(err)
    }
    defer trace.Stop()
    
    // Your code here

Conclusion

Go’s concurrency primitives might seem simple at first, but they can be combined in powerful ways to solve complex problems. The patterns I’ve shared come from real-world experiences building systems that handle substantial load reliably.

Remember, concurrency isn’t just about making things faster—it’s about designing systems that can handle multiple tasks efficiently and gracefully. The right pattern depends on your specific needs:

  • Use worker pools to control concurrency levels
  • Use context for cancellation and timeouts
  • Use fan-out, fan-in for parallel processing pipelines
  • Use rate limiters for controlled access to resources
  • Use work stealing for heterogeneous workloads
  • Use semaphores for resource control
  • Use error groups for parallel operations with clean error handling
  • Use pipelines for multi-stage processing

I hope these patterns help you write more efficient, reliable concurrent code in Go. What’s your favorite Go concurrency pattern? Let me know in the comments!


This post is based on my experience building data processing systems that handle millions of records daily. The code examples are simplified for clarity but represent real patterns I’ve used in production systems.