Why can many CPU-bound goroutines hurt performance, and how do you size a worker pool?
Question 173MediumGo 1.22 to 1.25
For CPU-bound work, only GOMAXPROCS goroutines can make progress at once. Adding more goroutines than that gives you no extra throughput. It does cost more:
- Switching and preemption every 10 ms, which thrashes the cache.
- More goroutines alive at once, so more memory held and more GC work.
- Worse tail latency, because a given task waits behind many others.
Rules of thumb:
- CPU-bound: use about
runtime.GOMAXPROCS(0)workers. Do not useNumCPU, because it ignores container limits and user settings. - I/O-bound: use many more workers. Size them by the capacity of the downstream system (connection pool size, rate limits) and apply Little's law: concurrency ≈ throughput × latency.
- Very small tasks: spawning a goroutine per 50 ns operation is pure overhead. Batch or chunk the input.
func parallelSum(xs []int) int {
n := runtime.GOMAXPROCS(0)
chunk := (len(xs) + n - 1) / n
partial := make([]int, n)
var wg sync.WaitGroup
for w := range n {
lo, hi := min(w*chunk, len(xs)), min((w+1)*chunk, len(xs))
wg.Go(func() {
for _, x := range xs[lo:hi] {
partial[w] += x // false sharing risk; sum locally then store
}
})
}
wg.Wait()
total := 0
for _, p := range partial {
total += p
}
return total
}
Follow-up: false sharing. Adjacent partial[w] entries sit on the same cache line, so the cores keep invalidating each other's caches. Sum into a local variable and write to the slice once, or pad the slice entries.
More on Goroutines & the Scheduler
- Q171What are the goroutine states and what transitions happen on a channel block?
- Q172How can you observe the scheduler? (schedtrace, go tool trace, pprof)
- Q174How do goroutines interact with cgo, and why can cgo calls be expensive?
- Q175Implement a bounded, cancellable fan-out that returns the first error and does not leak goroutines.
- Q176Is creating a goroutine per request (e.g., in net/http) a good design? What are the risks?
- Q177How are timers handled by the scheduler, and what changed with time.Timer in Go 1.23?