Go Concurrency Patterns: Beyond Goroutines
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.