Concurrency Patterns and Pitfalls: Worker Pools, errgroup and Race Detection
Module 3: Concurrency | Lesson: m03-l06 | Level: Intermediate After this lesson you will be able to: master the Worker Pool and Pipeline patterns, use errgroup to handle concurrent errors safely, and use the race detector and pprof to track down data races and leaks.
Learning Objectives
- Implement a channel-based Worker Pool and understand its back-pressure behavior.
- Use
golang.org/x/sync/errgroupfor concurrency control and error collection. - Use
go run -raceto detect data races, and pprof to diagnose goroutine leaks. - Recognize and fix common deadlock and data-race problems.
Core Concepts
2.1 Worker Pool and Pipeline
What: a Worker Pool is a concurrency pattern that limits concurrency by pre-creating a fixed number of goroutines (workers) that consume a task queue. A Pipeline chains worker pools together, feeding one stage's output into the next stage's input.
Why: in Go, creating a goroutine is cheap, but creating them without bound eventually exhausts resources (memory, file descriptors). A Worker Pool limits the degree of concurrency and protects backend resources such as database connections.
How:
1. Define a task struct.
2. Create a task channel jobs and a result channel results.
3. Start N workers that read tasks from jobs, process them, and write to results.
4. The main goroutine closes the channels and collects results.
2.2 errgroup and Concurrent Error Collection
What: errgroup is a utility in the golang.org/x/sync package that coordinates a group of goroutines. It provides a Go method to submit tasks and a Wait method to wait for all of them.
Why: the standard sync.WaitGroup only counts; it does not handle errors. In concurrent scenarios, when one goroutine fails you usually want to cancel the rest. errgroup combines with context.Context to implement "first error triggers cancellation".
How:
1. Call g, ctx := errgroup.WithContext(ctx).
2. Submit tasks with g.Go(func() error { ... }).
3. Call g.Wait() to block until all tasks finish; if any task returns a non-nil error, that error is returned.
2.3 Race Detection and Leak Diagnosis
What: - Race detector: a runtime checker built into the Go toolchain that finds unsynchronized concurrent reads and writes of the same memory address. - Leak: a goroutine that stays resident in memory for too long because it never exits properly. - Deadlock: two or more goroutines wait on each other to release resources, so none of them can make progress.
Why: data races are among the most subtle bugs in Go and can corrupt data. Leaks cause memory to grow until OOM. Deadlocks hang the program forever.
How:
- Race detection: use go run -race main.go or go test -race. Note: -race has existed since Go 1.1 (no special version needed); it relies on CGO, so a working C compiler must be available on the machine.
- Leak diagnosis: use pprof's goroutine profile. -race only detects data races, not goroutine leaks; leaks are diagnosed with pprof's goroutine/heap profiles or runtime.NumGoroutine(). The two tools are complementary, not interchangeable.
- Deadlock diagnosis: use go run -race or pprof's goroutine profile to inspect stacks, or analyze them with go tool pprof.
Example Code
Example 1: The Worker Pool Pattern
This example processes tasks with a fixed number of workers and collects the results.
package main
import (
"fmt"
"sync"
)
func main() {
const numWorkers = 3
jobs := make(chan int, 10)
results := make(chan int, 10)
// start the worker pool
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := range jobs {
// simulate processing
results <- j * 2
}
}(w)
}
// send tasks
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// wait for all workers, then close results
go func() {
wg.Wait()
close(results)
}()
// collect results
var resultSlice []int
for r := range results {
resultSlice = append(resultSlice, r)
}
// sort for stable output so the result is easy to verify
for i := 0; i < len(resultSlice); i++ {
for j := i + 1; j < len(resultSlice); j++ {
if resultSlice[i] > resultSlice[j] {
resultSlice[i], resultSlice[j] = resultSlice[j], resultSlice[i]
}
}
}
fmt.Println("Results:", resultSlice)
}
Expected output:
Results: [2 4 6 8 10]
Example 2: errgroup Concurrent Error Handling
This example runs tasks concurrently with errgroup and cancels the rest when one fails.
package main
import (
"context"
"errors"
"fmt"
"sync"
"golang.org/x/sync/errgroup"
)
func main() {
// use errgroup.WithContext directly; no outer WithCancel needed
g, ctx := errgroup.WithContext(context.Background())
var mu sync.Mutex
results := make([]string, 0, 3)
// Task 1: succeeds
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
mu.Lock()
results = append(results, "task1-ok")
mu.Unlock()
return nil
}
})
// Task 2: fails
g.Go(func() error {
return errors.New("task2-failed")
})
// Task 3: checks the context
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
mu.Lock()
results = append(results, "task3-ok")
mu.Unlock()
return nil
}
})
if err := g.Wait(); err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("All tasks succeeded")
}
}
Expected output:
Error: task2-failed
Common Mistakes & Pitfalls
-
Wrong assumption: the race detector needs Go 1.22+ - Why:
-racehas existed since Go 1.1 and is version-independent. - Correct approach: just usego run -race. Note that it relies on CGO; if the environment has no C compiler (such as a minimal Docker image), it reports an error. -
Wrong assumption: -race detects goroutine leaks - Why:
-raceonly detects memory-access races; it does not monitor goroutine lifetimes. - Correct approach: diagnose leaks with pprof's goroutine profile orruntime.NumGoroutine(). -
Bad code: not closing channels in a Worker Pool - Why: if the
resultschannel is not closed, the main goroutine'srange resultsblocks forever. - Correct approach: aftersync.WaitGroupwaits for all workers, have the main goroutine (or a dedicated goroutine) close the results channel. -
Bad code: running
go test -race ./...in a directory with multiple main packages - Why: course example directories usually contain severalpackage mainfiles;./...tries to compile them all and fails with amain redeclarederror. - Correct approach: the course examples are single-file programs; run them one file at a time withgo run exampleN.go, and do not usego test ./...orgo build ./...over the whole directory. -
Bad code: deadlocks - Why: multiple goroutines wait on each other to release resources, so none can continue. - Correct approach: ensure a consistent resource-acquisition order, or use
contexttimeouts for cancellation.
Best Practices
- Limit concurrency when it matters: use a Worker Pool / semaphore only when the workload is unbounded or concurrent tasks contend for external resources such as database connections and file handles. Lightweight, bounded tasks can run concurrently directly.
- Use errgroup to manage context: for concurrent tasks, prefer
errgroup.WithContextover managingcontext.CancelFuncby hand; it automatically handles "first error cancels all". - Enable the race detector in CI/CD: run
go test -raceon the test suite in your CI pipeline to catch concurrency bugs early. - Avoid goroutine leaks: make sure every goroutine has a clear exit condition, usually via
contextcancellation or channel closing. - Avoid deadlocks: ensure a consistent resource-acquisition order, or use
contexttimeouts for cancellation.
Exercises
- Concept question: briefly describe the difference between
sync.WaitGroupanderrgroup.Groupwhen handling concurrent errors. - Coding exercise: write a function
FetchData(urls []string) ([]string, error)that useserrgroupto request several URLs concurrently (simulate the requests by returning the URL's content); if any request fails, return an error and cancel the others. - Advanced/debugging exercise: the following code has a data race. Find the problem and fix it. ```go package main
import ( "fmt" "sync" )
func main() { var counter int var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
} ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
sync.WaitGroup only waits for a group of goroutines to finish; it does not handle errors. If a goroutine panics or returns an error, WaitGroup cannot see it. errgroup.Group wraps WaitGroup and context.Context: when a task submitted via Go returns a non-nil error, Wait returns that error and automatically cancels the associated context, cancelling the other tasks.
Question 2
package main
import (
"context"
"fmt"
"sync"
"golang.org/x/sync/errgroup"
)
// FetchData simulates concurrent requests
func FetchData(urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(context.Background())
var mu sync.Mutex
results := make([]string, 0, len(urls))
for _, url := range urls {
u := url // capture the loop variable
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
// simulate the request
result := fmt.Sprintf("data-from-%s", u)
mu.Lock()
results = append(results, result)
mu.Unlock()
return nil
}
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
func main() {
urls := []string{"url1", "url2", "url3"}
results, err := FetchData(urls)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Results:", results)
}
Question 3
Problem analysis: multiple goroutines read and write the counter variable concurrently, causing a data race.
Fix: protect the shared variable with sync.Mutex or the atomic package.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
}
Lesson Summary
- A Worker Pool limits concurrency to protect resources; a Pipeline stages processing.
errgroupis the best-practice tool for concurrent errors and context cancellation.-racedetects data races; pprof diagnoses leaks; the two are complementary.- Avoid
./...build commands in directories containing multiplepackage mainfiles. - Avoid deadlocks by keeping resource-acquisition order consistent.
Next lesson preview: starting Module 4 — packages and project structure: modules, the internal directory and dependency management.