- #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:
- Exhaust system resources (each goroutine consumes memory)
- Overwhelm downstream services
- Make error handling virtually impossible
- 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:
-
Channel Size: Unbuffered channels cause synchronization overhead. For performance-critical code, benchmark different buffer sizes.
-
Goroutine Overhead: While goroutines are lightweight, they’re not free. Each goroutine consumes ~2KB of stack space initially. Create them judiciously.
-
Work Batching: Sometimes batching work can be more efficient than processing individual items, especially when dealing with I/O or network operations.
-
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. -
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:
-
Race Detector: Always run tests with
-raceto catch data races early:go test -race ./... -
pprof: For profiling concurrent performance issues:
import _ "net/http/pprof" // In your main() go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }() -
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.