Chapter 75 -- CPU limits: GOMAXPROCS, cgroups, and throttling
In one sentence
A container's CPU limit is a time quota per scheduling period, not a smaller number of CPUs, and a runtime that starts more busy threads than the quota covers gets throttled, which shows up as latency spikes, not high CPU.
Why this matters
--cpus=1.5 or a Kubernetes limits.cpu: 1500m becomes cgroup v2's
cpu.max = 150000 100000: 150 ms of CPU time per 100 ms period, spread over
any number of CPUs. The process still sees every CPU on the host
(nproc = 14 below). Before Go 1.25, Go set GOMAXPROCS to that host CPU count,
burned through the quota in the first part of each period, and then stalled for
the rest. Tail latency rose while average CPU looked fine. Java, Node.js
worker pools, and anything else that sizes itself from the CPU count can make
the same mistake.
How it actually works
cpu.max = 150000 100000 quota 150 ms per 100 ms period (= 1.5 CPUs)
GOMAXPROCS=8, all busy:
|########--------------------------------| 8 threads x ~19 ms = 150 ms used by t=19ms
| ^ throttled for the remaining ~81 ms of every period
GOMAXPROCS=2, all busy:
|####################################----| 2 threads share the quota evenly; little or no stall
Go 1.25 made GOMAXPROCS container-aware: on Linux it reads the cgroup CPU
limit, rounds it up, applies a minimum of 2, and re-checks
periodically in case the limit changes. Measured with Go 1.26: --cpus=0.5,
1, and 1.5 all gave GOMAXPROCS = 2, and --cpus=2.2 gave 3. The
GOMAXPROCS environment variable still overrides it.
Build it in Go (15 min) -- measure throttling
// gomaxprocs: what does a Go program think it can use, versus what the
// container's cgroup actually allows?
//
// go run ./gomaxprocs
// docker run --rm --cpus=1.5 -v "$PWD":/w -w /w golang:1.26 go run ./gomaxprocs
package main
import (
"fmt"
"os"
"runtime"
"strings"
"sync"
"time"
)
func main() {
fmt.Println("runtime.NumCPU() =", runtime.NumCPU(), " (CPUs the OS lets this process run on)")
fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0), " (OS threads running Go code at once)")
if b, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil {
f := strings.Fields(string(b)) // "<quota> <period>" or "max <period>"
fmt.Printf("cgroup cpu.max = %s", b)
if f[0] != "max" {
fmt.Printf(" = %s / %s µs per period -> the container's CPU limit\n", f[0], f[1])
}
} else {
fmt.Println("cgroup cpu.max = (not in a cgroup v2 container)")
}
// Burn CPU on every P for 2 seconds and see how much we got.
before := throttled()
var wg sync.WaitGroup
stop := time.Now().Add(2 * time.Second)
for i := 0; i < runtime.GOMAXPROCS(0); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for time.Now().Before(stop) {
}
}()
}
wg.Wait()
after := throttled()
if before != nil && after != nil {
fmt.Printf("during 2s of busy work: throttled %d times, for %.0f ms in total\n",
after[0]-before[0], float64(after[1]-before[1])/1000)
}
}
// throttled reads nr_throttled and throttled_usec from cgroup v2's cpu.stat.
func throttled() []int64 {
b, err := os.ReadFile("/sys/fs/cgroup/cpu.stat")
if err != nil {
return nil
}
var n, us int64
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) == 2 && f[0] == "nr_throttled" {
fmt.Sscan(f[1], &n)
}
if len(f) == 2 && f[0] == "throttled_usec" {
fmt.Sscan(f[1], &us)
}
}
return []int64{n, us}
}
Real output (Go 1.26, host with 14 CPUs):
$ docker run --rm -v "$PWD/bin":/b debian:bookworm /b/gomaxprocs # no limit
runtime.NumCPU() = 14
GOMAXPROCS = 14
cgroup cpu.max = max 100000
during 2s of busy work: throttled 0 times, for 0 ms in total
$ docker run --rm --cpus=1.5 -v "$PWD/bin":/b debian:bookworm /b/gomaxprocs
runtime.NumCPU() = 14 (CPUs the OS lets this process run on)
GOMAXPROCS = 2 (OS threads running Go code at once)
cgroup cpu.max = 150000 100000
during 2s of busy work: throttled 20 times, for 1011 ms in total
$ docker run --rm --cpus=1.5 -e GOMAXPROCS=8 -v "$PWD/bin":/b debian:bookworm /b/gomaxprocs
GOMAXPROCS = 8
during 2s of busy work: throttled 21 times, for 13658 ms in total
Read it:
- Go picked GOMAXPROCS = 2 for a 1.5-CPU limit (rounded up), while
NumCPU()still said 14. throttled_usecis summed across threads. With 8 busy threads, 13.7 s of thread-time was spent frozen during a 2 s run. Each thread was stopped for most of every period. Requests arriving then wait up to 80 ms for no reason visible in the application.- Some throttling remains at GOMAXPROCS = 2, because 2 threads on a 1.5-CPU quota still overrun it slightly. For latency-critical services, some teams set CPU requests and no CPU limit, letting the pod use idle CPU without quota stalls. Measure before choosing.
Exercises:
- Watch throttling on a real Kubernetes pod:
kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.statand comparenr_throttledbefore and after a load test. - Run with
--cpus=0.5. What does Go choose for GOMAXPROCS, and how much throttling do you see? (Go never chooses fewer than 2, so a half-CPU container is throttled heavily by design. Weigh that against settingGOMAXPROCS=1explicitly.) - Build the same program with Go 1.24 (
docker run golang:1.24 ...) and compare GOMAXPROCS under--cpus=1.5.
Common mistakes
- Reading "CPU usage 60% of limit" as "plenty of headroom" while
nr_throttledclimbs. Throttling happens within each 100 ms period, not on average. - Running Go older than 1.25 in containers without
automaxprocsor an explicitGOMAXPROCS. - Sizing worker pools from
runtime.NumCPU()instead ofruntime.GOMAXPROCS(0).
Check yourself
- What do the two numbers in
cpu.maxmean? - Why can a container be throttled while its average CPU is below its limit?
- What did Go 1.25 change, and how do you override it?
Further reading
- Kernel documentation: "Control Group v2", the
cpu.maxandcpu.statsections. - Go 1.25 release notes: "Container-aware GOMAXPROCS".
- Chapter 58 (scheduling and resources) of this guide.