Mastering Concurrency in Go: Patterns and Pitfalls
Go was born in the cloud era, designed specifically to handle concurrent workloads efficiently. Its primitives—Goroutines and Channels—make concurrency accessible, but mastering them requires understanding patterns and avoiding race conditions.
Goroutines: Lightweight Threads
Unlike OS threads which consume ~1MB stack size, Goroutines start with just ~2KB. This means you can spin up hundreds of thousands of concurrent routines on a single machine.
func main() {
go processData("A") // Runs concurrently
processData("B") // Runs in main thread
}
Channels: Typesafe Communication
"Don't communicate by sharing memory; share memory by communicating."
Channels allow safe data exchange between Goroutines.
Unbuffered vs Buffered
- Unbuffered:
make(chan int). The sender blocks until the receiver is ready. Guarantees synchronization. - Buffered:
make(chan int, 100). The sender only blocks when the buffer is full. Good for bursting workloads.
Pattern: The Worker Pool
Spawning a goroutine for every HTTP request is fine, but for CPU-intensive tasks, you might want to limit parallelism to avoid resource exhaustion.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d started job %d
", id, j)
time.Sleep(time.Second) // Simulate work
results <- j * 2
fmt.Printf("Worker %d finished job %d
", id, j)
}
}func main() { jobs := make(chan int, 100) results := make(chan int, 100)
// Start 3 workers for w := 1; w <= 3; w++ { go worker(w, jobs, results) }
// Send 5 jobs for j := 1; j <= 5; j++ { jobs <- j } close(jobs)
// Collect results
for a := 1; a <= 5; a++ {
<-results
}
}
`
Pattern: Graceful Shutdown with Context
When you need to cancel long-running operations (e.g. user cancelled request), use context.
func operation(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
fmt.Println("Done")
case <-ctx.Done():
fmt.Println("Cancelled:", ctx.Err())
}
}
Avoiding Race Conditions
Whenever multiple goroutines access the same variable, you risk a race condition.
- Use
sync.Mutex: Lock access to the critical section. - Use
atomicpackage: For simple counters/flags. - Use
-raceflag: Run tests withgo test -raceto detect races automatically.
Concurrency is hard, but Go's primitives make the complex patterns manageable and readable.