Building a Robust Worker Pool in Go: Channels, Goroutines, and Safe Shutdown
Learn how to implement a worker‑pool pattern in Go, control goroutine count, avoid leaks, and verify correctness with race detection and runtime metrics.
24 Nov 2025, 12:41 UTC

Problem: Uncontrolled Goroutine Spawning
When a Go service receives a burst of requests, naïvely launching a goroutine per request can exhaust memory and overwhelm the scheduler. The result is a hard‑to‑debug “goroutine leak” and unpredictable latency spikes.
What you want instead is a bounded, reusable pool that keeps the number of active goroutines within a safe limit, while still allowing the producer to feed tasks at any rate.
The Worker Pool Pattern in a Nutshell
A worker pool decouples the producer of work from the consumers (workers). Two channels are the core of the pattern:
jobs– a buffered channel where the producer sends tasks.results– a channel where workers publish outcomes.
We also use sync.WaitGroup to block the main goroutine until every worker finishes. Closing the jobs channel signals workers to stop, and closing results signals the result collector to exit.
Implementing the Pattern
Below is a minimal, fully‑typed example. Replace the placeholder Task and Result types with your domain objects.
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
type Task struct {
ID int
Data string
}
type Result struct {
TaskID int
OK bool
}
func worker(id int, jobs <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs { // exits when jobs is closed
// Simulate work
time.Sleep(100 * time.Millisecond)
results <- Result{TaskID: job.ID, OK: true}
}
}
func main() {
const workerCount = 5
const jobBuffer = 10
jobs := make(chan Task, jobBuffer)
results := make(chan Result, workerCount)
var wg sync.WaitGroup
// Start workers
for i := 0; i < workerCount; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// Produce tasks
go func() {
for i := 1; i <= 20; i++ {
jobs <- Task{ID: i, Data: fmt.Sprintf("payload %d", i)}
}
close(jobs) // signal no more jobs
}()
// Collect results in a separate goroutine
go func() {
for res := range results {
fmt.Printf("Task %d completed: %v\n", res.TaskID, res.OK)
}
}()
// Wait for all workers to finish
wg.Wait()
close(results) // signal collector to exit
// Verify no goroutine leaks
fmt.Printf("Active goroutines after shutdown: %d\n", runtime.NumGoroutine())
}
Key points to note:
- The
jobschannel is buffered. If the producer is faster than the workers, tasks queue up but the producer never blocks until the buffer is full. - Workers range over
jobs. When the channel is closed, the range loop exits, and thedefer wg.Done()call releases the WaitGroup counter. - The result collector ranges over
results. Closingresultsafterwg.Wait()guarantees no results are lost. - We use
runtime.NumGoroutine()at the end to double‑check that no stray goroutine remains. A value > 1 (the main goroutine) indicates a leak.
Verifying Correctness
Two quick checks:
- Race detection: run the program with
go run -race .. The worker pool should not report any data races because each goroutine only touches its own local variables or channel endpoints. - Goroutine count: after shutdown,
runtime.NumGoroutine()should equal 1 (only the main goroutine). If you see more, you likely forgot to close a channel or exit a goroutine.
Adjust jobBuffer to see what happens when the producer blocks. With an unbuffered channel (buffer size 0), the producer will wait for a worker to pick up each job, which can be desirable for back‑pressure but may reduce throughput.
Trade‑offs & Limitations
- Over‑provisioning workers: Creating more workers than logical CPUs can lead to context‑switch overhead and cache thrashing. A common rule of thumb is
runtime.NumCPU()workers, but if tasks are I/O‑bound, a higher number may be justified. - Deadlocks with unbuffered channels: If you use an unbuffered
jobschannel and the producer is blocked waiting for a worker, but all workers are blocked waiting for results, a deadlock can occur. Always ensure the result channel can accommodate at least one unprocessed result. - Channel closure race: Closing a channel that is still being written to triggers a panic. In the example, we close
jobsonly after the producer goroutine finishes sending all tasks, guaranteeing no concurrent writes. - Result ordering: The pattern does not preserve the order of results. If order matters, add a sequence number to the
Resultand sort after collection.
Actionable Takeaway
Implementing a worker pool in Go is straightforward once you understand the role of each channel and the importance of graceful shutdown. Follow these steps:
- Decide the worker count based on
runtime.NumCPU()and whether tasks are CPU‑bound or I/O‑bound. - Create a buffered
jobschannel large enough to absorb bursty traffic but small enough to avoid excessive memory usage. - Spawn workers, passing the same
jobschannel and a sharedresultschannel. - Use a
sync.WaitGroupto block the main goroutine until all workers finish. - Close channels in the correct order: first
jobs(to stop workers), thenresults(to stop the collector). - Run
go run -raceand checkruntime.NumGoroutine()to confirm no leaks.
With these guidelines, you can build a scalable, leak‑free worker pool that adapts to your workload without sacrificing performance.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.