Go Concurrency Patterns for Production Systems
July 12, 2024
Why Go for backend services
Go's concurrency model — goroutines and channels — is one of the cleanest abstractions for building high-throughput backend services. It's not just about performance; it's about clarity. A well-written concurrent Go program is easier to reason about than its callback-heavy Node.js or thread-pool Java equivalent.
Worker pool pattern
The worker pool is the bread and butter of concurrent Go. Fixed number of workers, a shared work channel, and results collected via a results channel:
func WorkerPool(ctx context.Context, jobs []Job, workers int) []Result {
jobCh := make(chan Job, len(jobs))
resultCh := make(chan Result, len(jobs))
// Start workers
for i := 0; i < workers; i++ {
go worker(ctx, jobCh, resultCh)
}
// Send jobs
for _, job := range jobs {
jobCh <- job
}
close(jobCh)
// Collect results
var results []Result
for i := 0; i < len(jobs); i++ {
results = append(results, <-resultCh)
}
return results
}Fan-out, fan-in
Fan-out distributes work across multiple goroutines. Fan-in merges multiple result channels into one. This pattern is perfect for parallelizing independent work like processing webhook deliveries or batch database operations.
Graceful shutdown
A production service must handle SIGTERM gracefully. Use signal.Notify with a context cancellation to drain in-flight work:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
server := &http.Server{Addr: ":8080"}
go server.ListenAndServe()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
}Things I've learned the hard way
- Always set a context timeout on external calls
- Buffered channels are not a substitute for backpressure
- Use errgroup for coordinating goroutines that can fail
- Profile before optimizing — most bottlenecks are not where you think