Skip to content
Corentin GS

Mastering the Worker Pool Pattern in Go

№11 · · ·915 words ·5 min read
Mastering the Worker Pool Pattern in Go
In this piece
TL;DR

You 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:

  1. A pool of worker goroutines that process tasks concurrently
  2. A job queue (input channel) that holds pending tasks
  3. 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:

  1. Always close the channel when generation is complete
  2. Use buffered channels when appropriate to prevent blocking
  3. Include monitoring and logging for production environments
  4. Implement graceful shutdown mechanisms
  5. Size your worker pool based on available system resources

Pitfalls:

  1. Creating too many workers, leading to resource exhaustion
  2. Not handling worker failures or panics
  3. Forgetting to close channels properly
  4. Missing timeout mechanisms for long-running tasks
  5. 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.


If you want to experiment with the code examples, you can find them on my GitHub repository.

Next in Go Patterns

Go Pipeline Pattern: Turning Streams into Useful Data

Part 5 continues the series.