GOMAXPROCS Caps Parallelism, Not Concurrency
cpu:1 doesn't mean one at a time. I/O-parked goroutines aren't bounded by GOMAXPROCS. Parallelism and concurrency are different ceilings.
cpu:1doesn’t mean one at a time. I/O-parked goroutines are not bounded by GOMAXPROCS.
Three Layers of “Looks Limited But Isn’t”
net/http’s implicit concurrency: every request automatically gets a goroutine, and the handler spawns another. N requests ≈ 2N goroutines with no framework-level cap.- GOMAXPROCS only caps parallelism: it limits goroutines running Go code simultaneously. I/O-parked ones don’t count; they hold 0 P/0 M (GMP Scheduler and Netpoller).
runtime.NumCPU()trap: it reads the node’s core count (for example, 64), not your cgroup quota (for example, 1). Pre-Go 1.25 needsgo.uber.org/automaxprocsto fix this. But that only fixes CPU. It does nothing for memory OOM.
Role in This OOM
The screenshot service used automaxprocs → GOMAXPROCS=1. It looked like “one at a time,” but 19 /capture goroutines were simultaneously parked on browser websockets, each holding a tab open. GOMAXPROCS gave a false sense of safety.
The real cap has to be explicit: a semaphore (Load Shedding with TryAcquire).
GOMAXPROCS is a CPU ceiling, not a resource ceiling. I/O-bound work needs a wall you build yourself.
References:
- https://pkg.go.dev/net/http#Server
- https://go.dev/blog/container-aware-gomaxprocs
- https://victoriametrics.com/blog/kubernetes-cpu-go-gomaxprocs/
- https://kanishk.io/posts/cpu-throttling-in-containerized-go-apps/
Related: see Memory Three-Layer Model for the cgroup-to-heap memory layers, Load Shedding with TryAcquire for the semaphore fix, or go back to the series overview.