Skip to content
All writing Part 04 of 11 · From 19 Goroutines to Exit 137
Engineering · 2 min read

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:1 doesn’t mean one at a time. I/O-parked goroutines are not bounded by GOMAXPROCS.

Three Layers of “Looks Limited But Isn’t”

GOMAXPROCS = 1 Running (P-bound) G₁ Parked (netpoller) G₂ G₃ ... G₁₉ ← 1 max ← no limit parallelism = 1 (caps Go code) concurrency = 19 (caps nothing)
  1. 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.
  2. 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).
  3. runtime.NumCPU() trap: it reads the node’s core count (for example, 64), not your cgroup quota (for example, 1). Pre-Go 1.25 needs go.uber.org/automaxprocs to fix this. But that only fixes CPU. It does nothing for memory OOM.

Role in This OOM

The screenshot service used automaxprocsGOMAXPROCS=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:

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.

Tags #go #concurrency #reliability
// connect

Be brave | Be wise | Be grateful

21 BreakinCode

// elsewhere
LinkedInMedium (lang: en)Life RecordYoutube
wh:~$William Hung· © 2026 Taipei · GMT+8 · Available for collaboration