Go Concurrency Patterns: Beyond Goroutines

14 min read
#go#concurrency#performance#systems

Go Concurrency Patterns: Beyond Goroutines

Go's concurrency model is one of its greatest strengths. While goroutines and channels are powerful primitives, they're just the foundation. This post explores battle-tested patterns for building concurrent systems that scale and remain maintainable.

The Basics Review

Before we dive into advanced patterns, let's establish our foundation. Go's concurrency model is built on two core concepts:

  • Goroutines: Lightweight threads managed by the Go runtime
  • Channels: Type-safe communication primitives between goroutines
// Basic goroutine example
go func() {
  result := expensiveComputation()
  resultChan <- result
}()

// Receive result
value := <-resultChan

Pattern 1: Worker Pool

The worker pool pattern is essential for controlling resource usage when processing unbounded work. Instead of spawning a goroutine per task (which can exhaust memory), we maintain a fixed pool of workers.

type WorkerPool struct {
  jobs    chan Job
  results chan Result
  workers int
}

func (wp *WorkerPool) Start(ctx context.Context) {
  for i := 0; i < wp.workers; i++ {
    go wp.worker(ctx)
  }
}

func (wp *WorkerPool) worker(ctx context.Context) {
  for {
    select {
    case job := <-wp.jobs:
      result := job.Process()
      wp.results <- result
    case <-ctx.Done():
      return
    }
  }
}

func (wp *WorkerPool) Submit(job Job) {
  wp.jobs <- job
}

This pattern keeps resource usage bounded. On a 4-core machine with 1000 incoming requests, you process them with exactly 4 goroutines, not 1000.

Pattern 2: Rate Limiting with Tokens

Rate limiting prevents your system from being overwhelmed by too many requests. The token bucket pattern is elegant and efficient.

type RateLimiter struct {
  tokens chan struct{}
  ticker *time.Ticker
}

func NewRateLimiter(rps int) *RateLimiter {
  rl := &RateLimiter{
    tokens: make(chan struct{}, rps),
    ticker: time.NewTicker(time.Second / time.Duration(rps)),
  }
  
  go func() {
    for range rl.ticker.C {
      select {
      case rl.tokens <- struct{}{}:
      default:
      }
    }
  }()
  
  return rl
}

func (rl *RateLimiter) Allow() bool {
  select {
  case <-rl.tokens:
    return true
  default:
    return false
  }
}

Pre-fill the token bucket with capacity equal to your desired requests-per-second. The ticker replenishes tokens at a steady rate. Callers grab a token before proceeding.

Pattern 3: Circuit Breaker

The circuit breaker pattern prevents cascading failures by stopping requests to a failing service.

type CircuitBreaker struct {
  maxFailures int
  timeout     time.Duration
  mu          sync.RWMutex
  failures    int
  lastFail    time.Time
  state       string // "closed", "open", "half-open"
}

func (cb *CircuitBreaker) Call(fn func() error) error {
  cb.mu.RLock()
  
  if cb.state == "open" {
    if time.Since(cb.lastFail) > cb.timeout {
      cb.mu.RUnlock()
      cb.mu.Lock()
      cb.state = "half-open"
      cb.mu.Unlock()
    } else {
      cb.mu.RUnlock()
      return errors.New("circuit breaker open")
    }
  }
  cb.mu.RUnlock()
  
  err := fn()
  
  cb.mu.Lock()
  defer cb.mu.Unlock()
  
  if err != nil {
    cb.failures++
    cb.lastFail = time.Now()
    if cb.failures >= cb.maxFailures {
      cb.state = "open"
    }
    return err
  }
  
  cb.failures = 0
  cb.state = "closed"
  return nil
}

Three states govern the breaker: Closed (normal operation), Open (reject requests), and Half-Open (test if service recovered).

Pattern 4: Errgroup for Graceful Shutdown

errgroup.Group
is an underused pattern for managing multiple goroutines with clean error handling and cancellation.

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

func main() {
  var g errgroup.Group
  ctx, cancel := context.WithCancel(context.Background())
  
  // Start multiple independent tasks
  g.Go(func() error {
    return runServer(ctx)
  })
  
  g.Go(func() error {
    return processBackgroundJobs(ctx)
  })
  
  g.Go(func() error {
    return monitorMetrics(ctx)
  })
  
  // Wait for signal
  sigChan := make(chan os.Signal, 1)
  signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
  <-sigChan
  
  // Graceful shutdown
  cancel()
  if err := g.Wait(); err != nil && err != context.Canceled {
    log.Fatal(err)
  }
}

This pattern ensures all goroutines are properly cleaned up on exit, and any error in one goroutine can propagate to stop the others.

Pattern 5: Context-Aware Timeout

Propagating timeouts through your call stack prevents requests from hanging indefinitely.

func fetchUserData(ctx context.Context, userID string) (User, error) {
  // Timeout for this specific operation
  ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
  defer cancel()
  
  return fetchFromDatabase(ctx, userID)
}

func fetchFromDatabase(ctx context.Context, userID string) (User, error) {
  // Check context before expensive operation
  select {
  case <-ctx.Done():
    return User{}, ctx.Err()
  default:
  }
  
  result := make(chan User)
  go func() {
    result <- queryDB(userID)
  }()
  
  select {
  case user := <-result:
    return user, nil
  case <-ctx.Done():
    return User{}, ctx.Err()
  }
}

Contexts compose. A function receiving a context with a 10-second deadline can create a sub-context with a 5-second deadline. When any deadline expires, all child contexts are canceled.

Performance Considerations

When designing concurrent systems, remember these principles:

  • Goroutines are cheap: You can safely spawn thousands, but be mindful of the total
  • Channels have overhead: Use buffered channels when appropriate to reduce contention
  • Lock contention scales badly: Prefer message passing to shared memory
  • Context cancellation is free: Always respect context cancellation for graceful shutdown

Profiling Goroutines

Use

runtime.NumGoroutine()
to track goroutine count in production:

ticker := time.NewTicker(30 * time.Second)
go func() {
  for range ticker.C {
    metrics.GoroutineCount.Set(float64(runtime.NumGoroutine()))
  }
}()

A sudden spike in goroutine count often indicates a goroutine leak. Use

pprof
to investigate.

Conclusion

These patterns solve real problems encountered in production Go systems. Worker pools prevent resource exhaustion, rate limiting protects downstream services, circuit breakers handle cascading failures gracefully, and proper context handling ensures clean shutdown.

Start simple. Use basic goroutines and channels. When you need more control, reach for these patterns. They're not abstractions for their own sake. They're practical solutions tested by thousands of Go programs running in production.


Next time you design a concurrent system, ask yourself: What limits do I need? How do I handle failures? The patterns above are your toolkit.