Mastering the Worker Pool Pattern in Go
TL;DRYou have many independent jobs and a fixed concurrency budget. Spin up N workers that pull from a shared jobs channel and push results to another channel. Use it when unbounded goroutines would exhaust memory, DB connections, or API rate limits — and when you’d otherwise reinvent semaphore-bounded concurrency.
func worker(id int, jobs <-chan int, results chan<- int) { for job := range jobs { results <- job * 2 }}
func main() { const numJobs, numWorkers = 5, 3 jobs, results := make(chan int, numJobs), make(chan int, numJobs) for w := 1; w <= numWorkers; w++ { go worker(w, jobs, results) } for j := 1; j <= numJobs; j++ { jobs <- j } close(jobs) for a := 1; a <= numJobs; a++ { <-results }}Worker Pool Pattern in Go
Introduction
The Worker Pool pattern is a fundamental concurrency design in Go that efficiently manages a pool of worker goroutines to process tasks from a shared queue. This pattern excels at handling a large number of independent tasks concurrently while maintaining precise control over system resources and performance.
When to Use
- Processing a large number of independent tasks that can be parallelized
- Limiting the number of concurrent operations to prevent resource exhaustion
- Balancing workload across multiple processors or cores
- Managing CPU-bound or I/O-bound tasks efficiently
- Handling batch processing operations with controlled parallelism
Why to Use
- Controls resource utilization by maintaining a fixed number of workers
- Improves performance through efficient parallel processing
- Prevents system overload by limiting concurrent operations
- Enhances application scalability and throughput
- Maintains predictable resource usage patterns
How it Works
The Worker Pool pattern consists of three essential components:
- A pool of worker goroutines that process tasks concurrently
- A job queue (input channel) that holds pending tasks
- A results queue (output channel) that collects processed results
Workers continuously pull tasks from the job queue, process them independently, and send results to the results queue. This design ensures efficient task distribution and controlled concurrency.
Simple Example
func worker(id int, jobs <-chan int, results chan<- int) { for job := range jobs { fmt.Printf("Worker %d processing job %d\n", id, job) time.Sleep(time.Second) // Simulating work results <- job * 2 }}
func main() { const numJobs = 5 const numWorkers = 3
jobs := make(chan int, numJobs) results := make(chan int, numJobs)
// Start worker pool for w := 1; w <= numWorkers; w++ { go worker(w, jobs, results) }
// Send jobs to the workers for j := 1; j <= numJobs; j++ { jobs <- j } close(jobs)
// Collect and print results for a := 1; a <= numJobs; a++ { result := <-results fmt.Printf("Job result: %d\n", result) }}Real-World Example: Image Processor
Let’s consider a scenario where we need to process multiple images concurrently:
type Job struct { ID int ImageURL string Size int}
type Result struct { JobID int ImageURL string NewSize int Error error TimeSpent time.Duration}
func imageProcessor(id int, jobs <-chan Job, results chan<- Result) { for job := range jobs { startTime := time.Now()
fmt.Printf("Worker %d processing image %d from %s\n", id, job.ID, job.ImageURL)
result := Result{ JobID: job.ID, ImageURL: job.ImageURL, NewSize: job.Size, }
// Simulate image processing with realistic steps err := processImage(job) if err != nil { result.Error = err results <- result continue }
result.TimeSpent = time.Since(startTime) results <- result }}
func processImage(job Job) error { // Simulate various image processing steps time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
// Simulate potential errors if rand.Float32() < 0.1 { return fmt.Errorf("failed to process image %d: simulation error", job.ID) }
return nil}
func main() { numCPU := runtime.NumCPU() runtime.GOMAXPROCS(numCPU) numWorkers := numCPU * 2 // Use 2 workers per CPU core const numJobs = 10
jobs := make(chan Job, numJobs) results := make(chan Result, numJobs)
// Initialize worker pool for w := 1; w <= numWorkers; w++ { go imageProcessor(w, jobs, results) }
// Send image processing jobs for j := 1; j <= numJobs; j++ { jobs <- Job{ ID: j, ImageURL: fmt.Sprintf("https://example.com/image%d.jpg", j), Size: 100 * j, // Varying sizes } } close(jobs)
// Collect and handle results for a := 1; a <= numJobs; a++ { result := <-results if result.Error != nil { fmt.Printf("Error processing image %d: %v\n", result.JobID, result.Error) } else { fmt.Printf("Successfully processed image %d to size %dpx in %v\n", result.JobID, result.NewSize, result.TimeSpent) } }}Best Practices and Pitfalls
Best Practices:
- Always close the channel when generation is complete
- Use buffered channels when appropriate to prevent blocking
- Include monitoring and logging for production environments
- Implement graceful shutdown mechanisms
- Size your worker pool based on available system resources
Pitfalls:
- Creating too many workers, leading to resource exhaustion
- Not handling worker failures or panics
- Forgetting to close channels properly
- Missing timeout mechanisms for long-running tasks
- Inefficient job distribution strategies
Summary
The Worker Pool pattern is a powerful tool in Go’s concurrency toolkit, offering a balanced approach to parallel processing. By maintaining a fixed number of workers, it prevents resource exhaustion while maximizing throughput. The pattern is particularly valuable in real-world scenarios such as image processing, batch operations, and API request handling, where controlled concurrent processing is essential for optimal performance and resource utilization.
Disclaimer
This article provides an introduction to the Worker Pool pattern in Go. While the pattern is powerful, it’s important to consider the specific needs of your application when implementing it. For production use, additional error handling and optimizations may be necessary.
Testing Worker Pools
If you test a worker pool, focus on the contract: jobs go in, results come out. The number of goroutines is an implementation detail. I wrote about that in TDD Isn’t About Bugs — It’s Your Permission to Refactor.
Series Navigation
This article is part of the Go Patterns series:
- Previous: Mastering the Generator Pattern in Go
- Next: Go Pipeline Pattern: Turning Streams into Useful Data
- Series: Go Patterns
- Pillar: Go Concurrency Patterns
For more advanced concurrency patterns and best practices in Go, stay tuned for future articles! 🚀
If you want to experiment with the code examples, you can find them on my GitHub repository.
Related Posts
Go Concurrency Patterns: A Practical Reading Order
A guided reading order through the Go concurrency patterns that compound: producer-consumer, generator, worker pool, pipeline, semaphore, context, and real-world examples. The shortest path to writing concurrent Go you can reason about.
Context and Cancellation in Go: Stopping Work That Shouldn't Have Started
Go context cancellation without the fluff: timeouts, request lifecycles, errgroup fan-out, and graceful shutdown — no leaked goroutines.
Go Goroutines vs C# async/await: Who Carries the Cognitive Load?
Goroutines and async/await solve the same problem from opposite assumptions. A polyglot's take on primitives vs compiler-managed patterns.