Frontend Labs
⋯

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_usec is 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:

  1. Watch throttling on a real Kubernetes pod: kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.stat and compare nr_throttled before and after a load test.
  2. 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 setting GOMAXPROCS=1 explicitly.)
  3. 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_throttled climbs. Throttling happens within each 100 ms period, not on average.
  • Running Go older than 1.25 in containers without automaxprocs or an explicit GOMAXPROCS.
  • Sizing worker pools from runtime.NumCPU() instead of runtime.GOMAXPROCS(0).

Check yourself

  1. What do the two numbers in cpu.max mean?
  2. Why can a container be throttled while its average CPU is below its limit?
  3. What did Go 1.25 change, and how do you override it?

Further reading

  • Kernel documentation: "Control Group v2", the cpu.max and cpu.stat sections.
  • Go 1.25 release notes: "Container-aware GOMAXPROCS".
  • Chapter 58 (scheduling and resources) of this guide.

Generated from real-life-os-guide.md · 8 Oct 2026 16:36