Why Does Your Go Worker Pool Leak Goroutines? Common Pitfalls and Fixes
This article dissects the frequent bugs that cause Go worker‑pool implementations to panic, deadlock, or leak goroutines, explains the four core principles for correct channel and WaitGroup usage, showcases five typical error patterns with concrete code, and provides a complete, production‑ready solution.
Problem Statement
Interviewers often ask you to implement a worker pool where N workers read tasks from a jobs channel, write results to a results channel, and exit gracefully after all tasks are done. Many candidates stumble not because they cannot write a for range loop, but because they misunderstand who should close which channel, where to place WaitGroup calls, and how to collect results without deadlock.
Four Core Principles ("Four Sentences")
Principle 1: The sender (the main goroutine) closes the jobs channel after all tasks have been sent.
Principle 2: Workers use a for range loop; the loop exits automatically when the channel is closed and fully drained.
Principle 3: WaitGroup is added before launching each worker and each worker calls Done via defer. The main goroutine waits after all workers are started.
Principle 4: The receiver (main) reads from results only after WaitGroup.Wait() returns, preventing deadlock.
Five Common Pitfalls (with concrete examples)
Pitfall 1: Closing Too Early – Panic
jobs := make(chan int, 5)
results := make(chan int, 5)
go func() {
for job := range jobs {
fmt.Printf("processing job %d
", job)
time.Sleep(100 * time.Millisecond)
results <- job * 2
}
}()
jobs <- 1
jobs <- 2
jobs <- 3
close(jobs) // close before sending all tasks
jobs <- 4 // panic: send on closed channel
jobs <- 5Issue: Sending on a closed channel triggers a panic. The channel must stay open until every task is dispatched.
Pitfall 2: Never Closing jobs – Goroutine Leak
jobs := make(chan int, 5)
go func() {
for job := range jobs { // blocks forever because channel never closes
fmt.Printf("processing job %d
", job)
time.Sleep(100 * time.Millisecond)
}
fmt.Println("worker exited")
}()
jobs <- 1
jobs <- 2
jobs <- 3
fmt.Println("main: done")Issue: Without closing jobs, workers block on the for range loop, causing goroutine leakage and eventual OOM.
Pitfall 3: Unbuffered results without Receiver – Deadlock
jobs := make(chan int, 5)
results := make(chan int) // unbuffered
go func() {
for job := range jobs {
fmt.Printf("processing job %d
", job)
time.Sleep(100 * time.Millisecond)
results <- job * 2 // blocks if main hasn't started receiving
}
}()
jobs <- 1
jobs <- 2
jobs <- 3
close(jobs)
fmt.Println("main: all jobs sent")
for i := 0; i < 3; i++ {
fmt.Println("result:", <-results)
}Issue: With an unbuffered results, workers block on send until the main goroutine receives; if the main goroutine starts receiving too late, workers deadlock.
Pitfall 4: Adding WaitGroup Inside Goroutine – Race
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
go func(id int) {
wg.Add(1) // wrong: may run after wg.Wait()
defer wg.Done()
// work ...
}(i)
}
wg.Wait()Issue: Adding to the WaitGroup after the goroutine starts can let Wait() return before the Add executes, leading to a missed Done and a hanging program.
Pitfall 5: Closing Channels Multiple Times – Panic
go func() {
for job := range jobs {
results <- job * 2
}
close(results) // wrong: each worker may close, causing panic
}()
// or main also closes results later → panic: close of closed channelIssue: Only the sender should close a channel, and it must be closed exactly once.
Correct, Complete Worker‑Pool Implementation
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const (
numJobs = 10
numWorkers = 3
)
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
// launch workers
for i := 1; i <= numWorkers; i++ {
wg.Add(1) // main adds before launching
go func(id int) {
defer wg.Done()
for job := range jobs {
fmt.Printf("worker %d processing job %d
", id, job)
time.Sleep(100 * time.Millisecond)
results <- job * 2
}
fmt.Printf("worker %d: no more jobs, exiting
", id)
}(i)
}
// send tasks
for i := 1; i <= numJobs; i++ {
jobs <- i
}
close(jobs) // sender closes jobs
wg.Wait() // wait for all workers to finish
close(results) // now safe to close results
// collect results
for r := range results {
fmt.Println("result:", r)
}
fmt.Println("main: all done")
}This version respects all four principles, avoids the five pitfalls, and reliably exits without leaks or deadlocks.
Responsibility Summary
wg.Add(1)– main (before go) wg.Done() – each worker (via defer) close(jobs) – main after sending all tasks wg.Wait() – main, waits for workers close(results) – main after
Wait()Immediate Actions for Readers
Run the correct code on your machine several times to see orderly shutdown and correct results.
Search your existing Go projects for make(chan and verify that every jobs channel is closed by the sender and that results is closed only once after all workers finish.
Rewrite any old coordination code using the "responsibility" checklist to spot hidden bugs.
Next up, the series will dive into five concrete ways a close operation can panic, clarifying the exact semantics of channel closing.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
