The Problem with Unbounded Goroutines

One of the first things we learn in Go is that goroutines are cheap. It is tempting to simply wrap every incoming request or data point in a go func() { ... }() block and let the runtime handle the rest. While this works for low-traffic internal tools, it is a recipe for disaster in production environments.

I once worked on a system that processed image uploads by triggering a goroutine for every file. When a client uploaded a batch of 10,000 images, the system attempted to spawn 10,000 concurrent processes. We didn't crash immediately, but the memory pressure spiked, the database connection pool was exhausted instantly, and the CPU spent more time context switching than actually processing pixels. We had created a self-inflicted Denial of Service attack.

The Worker Pool Solution

The Worker Pool pattern solves this by creating a fixed number of worker goroutines that consume tasks from a shared channel. This allows you to cap resource usage regardless of the input volume. You essentially trade absolute latency for system stability and predictable throughput.

package main

import (
	"fmt"
	"sync"
	"time"
)

// Job represents a unit of work to be processed
type Job struct {
	ID    int
	Payload string
}

// Result represents the outcome of a processed job
type Result struct {
	JobID int
	Value string
	Err   error
}

// worker processes jobs from the jobs channel and sends results to the results channel
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		// Simulate a heavy workload (e.g., API call or DB write)
		time.Sleep(time.Millisecond * 100)
		
		fmt.Printf("Worker %d processing job %d\n", id, job.ID)
		results <- Result{JobID: job.ID, Value: "Processed " + job.Payload, Err: nil}
	}
}

func main() {
	const numJobs = 100
	const numWorkers = 5

	jobs := make(chan Job, numJobs)
	results := make(chan Result, numJobs)
	var wg sync.WaitGroup

	// 1. Start the workers
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	// 2. Feed the jobs into the channel
	for j := 1; j <= numJobs; j++ {
		jobs <- Job{ID: j, Payload: fmt.Sprintf("Data-%d", j)}
	}
	close(jobs) // Closing the channel tells workers to stop when jobs are exhausted

	// 3. Wait for workers to finish in a separate goroutine to avoid blocking
	go func() {
		wg.Wait()
		close(results)
	}()

	// 4. Collect results
	for res := range results {
		if res.Err != nil {
			fmt.Printf("Job %d failed: %v\n", res.JobID, res.Err)
			continue
		}
		_ = res.Value // Handle result
	}
}

Why This Approach Works

There are three critical design choices in the code above that make it production-ready:

  • Directional Channels: Notice the <-chan Job and chan-> Result signatures in the worker function. By specifying direction, I'm using the compiler to ensure the worker doesn't accidentally try to send a job or close the results channel.
  • The WaitGroup Pattern: Using a sync.WaitGroup is the only reliable way to ensure all workers have finished their final task before the program exits or moves to the next stage of the pipeline.
  • Channel Closing: Closing the jobs channel acts as a broadcast signal to all workers. The for job := range jobs loop terminates automatically once the channel is closed and empty, allowing the worker goroutines to exit gracefully.
Pro Tip: Always size your worker pool based on the bottleneck. If your task is CPU-bound, runtime.NumCPU() is a good starting point. If it's I/O-bound (like calling a slow external API), you can scale the number of workers much higher.

Handling Graceful Shutdowns

In a real microservice, you can't just let main end. You need to handle OS signals (like SIGTERM). To adapt this pattern for a long-running service, I recommend introducing a context.Context. By passing a context to the workers, you can signal them to stop processing immediately even if the jobs channel still has pending items.

Instead of just relying on the channel close, you can use a select block inside the worker loop to listen for ctx.Done(). This prevents your service from hanging during a deployment when the orchestrator is trying to shut down your pod.

Final Thoughts

The worker pool is a fundamental pattern in Go because it aligns perfectly with the philosophy of "do not communicate by sharing memory; instead, share memory by communicating." By decoupling the production of work from the consumption of work, you create a system that is resilient, observable, and easy to tune under load.