Container-Aware GOMAXPROCS (New in Go 1.25)
The simple world before container runtimes
Once upon a time, there was a new programming language named Go with built-in concurrency. It lived in a simple world: Docker and Kubernetes did not exist back then (obviously, as both are written in Go); and so, a CPU was a CPU.
A Go app knew how many CPU cores it could use by calling runtime.GOMAXPROCS(0)
, which returns the number of logical CPU cores available to the process. This number was gathered once at the start of the process and never changed throughout the process's lifetime. The app could use this information to manage workload; for example, by spawning an appropriate number of goroutines based on the CPU count.
A Go app also could artificially restrict the number of CPUs available by calling runtime.GOMAXPROCS(n)
where n
> 0.
My CPU count isn't your CPU count
All was well until containers entered the scene—especially, container runtimes like Kubernetes. At this point, things turned tricky. Container runtimes isolate processes to achieve a replicable behavior, enhanced security, and control of resource usage (among other goals).
One of the isolation techniques is based on Linux's Control Groups, or cgroups. A cgroup controls how much of a given resource a process can use. CPUs are such resources. Container runtimes use cgroups to impose a limit of CPU bandwidth available to a containerized process.
When containerized Go apps were CPU-limited this way, a tiny problem surfaced: GOMAXPROCS(0)
led the app to believe it has much more CPU power available than it actually had.
Imagine you have:
- A server with 16 CPU cores
- A container that's limited to use only 2 cores worth of CPU time
- A Go app running inside that container
The app would assume it has 16 CPUs available and try to use this amount to manage its workload, but actually, all the workload would run on 2 cores worth of CPU time.
You guess what happens: poor performance and resource contention. Time was ripe for fixing this problem.
Count smarter, be happier
With Go 1.25, the behavior of GOMAXPROCS()
dramatically changes (and so does the size of the function documentation). Well, maybe not dramatically, but using adjectives like this is a great way of maintaining the reader's attention. (You still here?)
So, the Go 1.25 runtime understands cgroups, and inside a container, GOMAXPROCS()
defaults to the CPU limit the container runtime imposes. So if Kubernetes generously grants your app 4.5 CPU cores worth of CPU time, the app will assume 4 available CPU cores (rounded down, because fractional cores are kind of a BS from the perspective of scheduling goroutines to CPU cores).
Moreover, GOMAXPROCS()
isn't updated only at process startup time but periodically adjusted to any changes to the CPU bandwidth. Say, if Kubernetes increases or decreases CPU bandwidth, the Go runtime can follow along, as can the app if it queries GOMAXPROCS()
for the current amount of CPU cores.
Worth noting that Go ignores Kubernetes’ “CPU requests” setting, so this configuration…
resources:
limits:
cpu: "2" # This is what Go 1.25 respects
requests:
cpu: "1" # Go ignores this
…would have GOMAXPROCS(0)
return 2
, based on the limits
configuration only.
See also the Go 1.25 release notes on GOMAXPROCS.
Happy navigating, dear κυβερνήτης!