Go's GOMAXPROCS Ignores Container CPU Limits: Why a Pod's Go Service Still Thrashes the Scheduler
You put a Go service in a pod with resources.limits.cpu: 500m, half a core, and the CPU usage graph in your dashboard sits comfortably under the limit all day. And yet p99 latency has a jagged sawtooth to it, container_cpu_cfs_throttled_seconds_total is climbing steadily, and nothing in the application logs explains it. The service isn't CPU-bound in any way you can see. It's being throttled anyway.
The cause is almost always the same thing: GOMAXPROCS has no idea the container has a CPU limit, because the two mechanisms that could tell it are looking at different pieces of the kernel.
What GOMAXPROCS actually controls
GOMAXPROCS sets the number of Ps, the scheduler contexts in Go's M:P:G model that can each run one goroutine on an OS thread at a time. It's the ceiling on how much of the program can be genuinely executing in parallel. Since Go 1.5 the default has been runtime.NumCPU(), and on Linux that function doesn't ask "how much CPU am I entitled to". It calls sched_getaffinity and counts the bits set in the returned CPU mask, i.e. how many logical CPUs this process is allowed to be scheduled onto.
That's a perfectly reasonable question to ask on a bare-metal box or a VM. On a Kubernetes node it gives you the wrong answer for most pods, because the CPU affinity mask and the CPU limit are usually enforced by two separate mechanisms that don't talk to each other.
Two ways to limit CPU, and only one shows up in the affinity mask
A container's CPU affinity mask is restricted when something actually pins it to a subset of cores, cpuset cgroups, taskset, or the kubelet's static CPU manager policy for Guaranteed-QoS pods requesting whole integer cores plus exclusive reservation. In that specific case, sched_getaffinity genuinely reflects the smaller core set, and GOMAXPROCS ends up correct almost by accident.
Everything else, which in practice means the overwhelming majority of pods with a limits.cpu of less than a whole core or a fractional value like 500m, is enforced with the CFS bandwidth controller instead: cpu.cfs_quota_us and cpu.cfs_period_us on cgroup v1, or the single cpu.max file ("quota period") on cgroup v2. A limit of half a core with the default 100ms period becomes a quota of 50000 microseconds every 100000 microseconds. The process is still allowed to run on any core in the affinity mask, including all 64 or 96 on the node, it just gets frozen once it has burned through its microsecond budget for the period.
Crucially, that budget has zero effect on the affinity mask. NumCPU() still reports the node's full core count. GOMAXPROCS gets set to something like 64 for a process that is contractually allowed 0.5 of a CPU-second every 100ms.
Why that turns into thrashing rather than just "a bit inefficient"
A GOMAXPROCS of 64 doesn't just waste a config value, it changes how the runtime behaves. The scheduler is willing to run up to 64 goroutines truly concurrently across OS threads, so under any real load it will happily spread work across far more cores than the quota allows for. The garbage collector is worse: the background mark workers are sized as a fraction of GOMAXPROCS, and GC assumes it can throw dozens of Ps at STW and concurrent marking. So instead of steadily spending your 50ms of quota across the 100ms period, the process spikes, briefly running on ten or twenty cores at once, exhausts the entire quota in a few milliseconds of wall-clock time, and then gets throttled by the CFS bandwidth controller for the rest of the period. Nothing runs at all until the next period opens.
From the outside this looks exactly like what people call "thrashing": bursty CPU usage, throttling metrics climbing while average utilisation looks low, and latency spikes that correlate with GC cycles or request bursts rather than with any sustained CPU pressure. It's not that the box is short on CPU, it's that the runtime is scheduling as if it had far more parallelism available than it's actually entitled to, then paying for the mismatch in enforced idle time.
The practical fix
The standard fix, and the one most production Go services already carry, is go.uber.org/automaxprocs. It reads the cgroup quota and period at startup, computes the equivalent core count, and calls runtime.GOMAXPROCS with it. The entire integration is a blank import:
package main
import (
_ "go.uber.org/automaxprocs"
)
func main() {
// GOMAXPROCS is already set to ceil(quota/period) by the time
// this runs, via the package's init function.
}
If you want to see what it's doing without pulling in the dependency, the logic it's replacing is roughly this on cgroup v2:
func cgroupCPULimit() (int, bool) {
data, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
if err != nil {
return 0, false
}
fields := strings.Fields(string(data))
if len(fields) != 2 || fields[0] == "max" {
return 0, false // no limit set
}
quota, err1 := strconv.ParseFloat(fields[0], 64)
period, err2 := strconv.ParseFloat(fields[1], 64)
if err1 != nil || err2 != nil || period == 0 {
return 0, false
}
procs := int(math.Ceil(quota / period))
if procs < 1 {
procs = 1
}
return procs, true
}
That's illustrative rather than something to actually ship, automaxprocs also handles cgroup v1, containerd's slightly different mount layout, and re-checking on the interval Kubernetes uses when a pod's limit is resized in place, all of which is fiddly enough that reimplementing it is the wrong rung of the ladder here.
Go 1.25 (August 2025) closed most of this gap natively: the runtime now reads the cgroup CPU quota at startup and sets GOMAXPROCS accordingly by default, with a GODEBUG setting available for anyone who deliberately wants the old NumCPU-based behaviour back. If your services are already on 1.25 or later you may not need automaxprocs at all, though it's harmless to keep alongside the native behaviour, it's a no-op once GOMAXPROCS is already sane.
The bit worth double-checking
None of this applies if you never set a CPU limit in the first place, only a request. Kubernetes requests affect scheduling and bin-packing, not runtime throttling, so a request-only pod has no CFS quota to mismatch against and GOMAXPROCS being "wrong" just means the process politely refrains from using cores it was never going to be throttled off anyway. This is one of the more common arguments for dropping CPU limits entirely and relying on requests plus node-level capacity planning: it's a legitimate operational stance, not a universal rule, and it trades away the hard ceiling that limits give you against noisy neighbours. Which trade-off is right depends on how much you trust your bin-packing and how badly a single runaway pod can hurt the node.
Either way, if you're chasing a latency sawtooth on a "low CPU usage" pod, check GOMAXPROCS against the actual limit before you look anywhere else. curl localhost:PORT/debug/pprof/... won't show it, but a one-line log of runtime.GOMAXPROCS(0) at startup will.