Skip to content
gohacks.dev
Concurrency

GOMAXPROCS and What It Really Does Inside a Container

How GOMAXPROCS behaves in Docker and Kubernetes: the Go 1.25 cgroup-aware default, the go.mod version gate, the minimum of two, and the older fix.

7 min read
Summarize in AI
Flat illustration of shipping containers stacked on a ship, with the Docker logo.

A container does not have fewer CPUs. It has a quota. A Linux container limited to two CPUs can still see all sixty-four of the host's cores, and the kernel enforces the limit by pausing the whole process when it has used its share of time in each scheduling period. A Go program that sizes its parallelism from the number of CPUs it can see therefore believes it has sixty-four, starts accordingly, and then spends its life being throttled. The symptom is latency spikes and a CPU graph pinned at exactly the limit, in a service that looks idle on every other measure.

Tested with Go 1.25.6 built for Linux and run under Docker Desktop on macOS (arm64)

This article explains what GOMAXPROCS controls, what the runtime did about containers before and after Go 1.25, and shows the behavior measured in real containers. It follows the scheduler article and belongs to the concurrency material. Everything ran on Go 1.25.6, and the container runs used Docker Desktop on an Apple M1 Max.

What GOMAXPROCS is

The scheduler runs goroutines on threads through logical processors, the Ps. There are GOMAXPROCS of them, so at most that many threads execute Go code at the same instant. The default has long been the number of logical CPUs the process can use: runtime.NumCPU(). On a laptop that is the right answer. In a container it can be badly wrong, because NumCPU reports the CPUs the process is allowed to run on (its affinity mask), and a CPU quota is not an affinity mask.

Too many Ps cost more than idle threads. Each P has its own run queue, memory cache and garbage collector work, and the garbage collector starts background workers in proportion to the Ps (how the collector works). With sixty-four Ps and a two-CPU quota, the garbage collector's background workers and the application's goroutines all compete for two CPUs worth of time, the process uses its entire quota in the first few milliseconds of each 100 ms period, and then the kernel freezes it until the next period. Requests that arrive during the freeze wait.

The behavior since Go 1.25

Go 1.25 made the default aware of cgroup CPU limits. The runtime.GOMAXPROCS documentation lays out the rule: the default is the minimum of the logical CPU count, the CPU affinity mask count, and on Linux the cgroup CPU throughput limit (quota divided by period). A fractional limit is rounded up to the next whole number, the result is never below 2 unless the machine itself has fewer than 2 CPUs, and the runtime re-reads the values periodically, at most once a second, so it follows a limit that changes while the process runs.

Here is a program that prints both numbers:

main.goGo
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("NumCPU:", runtime.NumCPU(), "GOMAXPROCS:", runtime.GOMAXPROCS(0))
}

And a script that cross-compiles it for Linux and runs it in a container under different limits. The container is a bare Alpine image, since the binary is static:

Shell
export CGO_ENABLED=0 GOOS=linux
go build -o prog .

run() { echo "[$1]"; shift; docker run --rm "$@" -v "$PWD":/w alpine:3.22 /w/prog; }

run "no limit"
run "--cpus=2" --cpus=2
run "--cpus=0.5" --cpus=0.5
run "GOMAXPROCS=3 set by hand" --cpus=2 -e GOMAXPROCS=3
run "containermaxprocs=0" --cpus=2 -e GODEBUG=containermaxprocs=0
Text
[no limit]
NumCPU: 10 GOMAXPROCS: 10
[--cpus=2]
NumCPU: 10 GOMAXPROCS: 2
[--cpus=0.5]
NumCPU: 10 GOMAXPROCS: 2
[GOMAXPROCS=3 set by hand]
NumCPU: 10 GOMAXPROCS: 3
[containermaxprocs=0]
NumCPU: 10 GOMAXPROCS: 10

Read the output against the rules. With no limit, GOMAXPROCS equals NumCPU, here ten. With --cpus=2 it drops to 2 while NumCPU still reports ten, which is the whole point: the runtime now reads the quota and not the CPU count. With --cpus=0.5, half a CPU rounds up to one and the minimum of two applies, so the answer is 2. Setting the GOMAXPROCS environment variable by hand wins and disables the automatic behavior, and GODEBUG=containermaxprocs=0 turns the cgroup logic off and restores the old default.

The go.mod gate

There is a catch that has caught people upgrading: the new behavior is tied to the language version in go.mod, not to the toolchain alone. The documentation says that containermaxprocs=0 and updatemaxprocs=0 are the defaults for language version 1.24 and below. A module whose go line says go 1.24, built with the Go 1.25 toolchain, keeps the old behavior. You can see it by lowering the line and rebuilding:

Shell
export CGO_ENABLED=0 GOOS=linux
go mod edit -go=1.24
go build -o prog124 .
echo "[go 1.24 in go.mod, --cpus=2]"
docker run --rm --cpus=2 -v "$PWD":/w alpine:3.22 /w/prog124
Text
[go 1.24 in go.mod, --cpus=2]
NumCPU: 10 GOMAXPROCS: 10

The result is GOMAXPROCS equal to the host's CPU count again, even though the compiler is 1.25.6. This is how Go keeps old programs compatible: behavior changes are gated on the go line so that upgrading the toolchain does not alter a program that has not opted in. The consequence is that updating the toolchain in your Dockerfile is not enough. Bump the go directive too (go mod edit -go=1.25), or set the GODEBUG explicitly, and verify in a container.

Before Go 1.25, and when you cannot upgrade

If you are on an older Go, or pinned to a language version below 1.25, the standard fix was a library. go.uber.org/automaxprocs reads the cgroup limit at start-up and calls runtime.GOMAXPROCS for you; you import it for its side effect and nothing else. Its source is at github.com/uber-go/automaxprocs. It does what the 1.25 runtime now does, but only once at start-up and without the periodic updates.

The alternative that needs no dependency is the environment variable, set from the container's own limit. In Kubernetes the usual way is the Downward API, which can expose a container's limits.cpu as an environment variable to the pod:

YAML
env:
  - name: GOMAXPROCS
    valueFrom:
      resourceFieldRef:
        resource: limits.cpu

That sets GOMAXPROCS to the CPU limit, rounded up to an integer by the Downward API. It works on any Go version and it freezes the value at pod start.

Limits versus requests

Kubernetes has two numbers per container, and the runtime reads only one. A CPU limit becomes a cgroup quota, the hard cap that the kernel enforces by throttling, and Go 1.25 follows it. A CPU request only affects scheduling and relative weight under contention; it is not a cap and the runtime ignores it. The Go documentation says so directly: the cgroup throughput limit usually corresponds to the "CPU limit" option and not to "CPU request". The two are set in the pod spec as described in the Kubernetes resource management documentation.

A pod with a request of 2 and no limit therefore gets GOMAXPROCS equal to the node's CPU count, and is free to burst, which is what that configuration means. Whether to set limits at all is a separate argument. Many teams set requests and omit CPU limits to avoid throttling altogether, and some set limits to protect neighbours. Whichever you choose, the runtime does the right thing for it as long as you are on 1.25 with a matching go line.

What the right number does for pool sizing

Once GOMAXPROCS reflects the real quota, the other numbers that derive from it become sensible too. A worker pool sized to runtime.GOMAXPROCS(0) for CPU-bound work gets two workers in a two-CPU container instead of sixty-four (choosing a worker pool size), and libraries that read NumCPU for their own defaults still see the host count, which is worth knowing when you tune them. Use GOMAXPROCS(0), which returns the current setting without changing it, as the source of truth for parallelism, and leave NumCPU for the places that genuinely want the machine's size. Goroutines that wait on I/O are a different matter and should not be sized from either (goroutines explained).

Should you set it by hand?

Usually not. The automatic value is a good default. Reasons to override:

  • CPU-bound services that you want to leave a core free, for the OS, a sidecar or a profiler. Setting GOMAXPROCS one below the limit is a legitimate experiment, to be measured.
  • Latency-sensitive services under a fractional limit. The minimum of two means a 500m limit still gets two Ps, and a P count above the real quota can cause throttling. If you see throttling at small limits, a lower hand-set value is worth testing.
  • Deterministic tests, where GOMAXPROCS=1 can make a race easier to reproduce.

Measure with the right counters. The cgroup exposes cpu.stat with nr_throttled and throttled_usec; a service that is being throttled shows these climbing. Go's own runtime/metrics and the scheduler trace (schedtrace) show the Go side. If throttling goes down and latency improves after you adjust GOMAXPROCS, you found the problem.

Checklist

  1. Which Go toolchain builds the image, and what does go.mod say? Both must be 1.25 for the automatic behavior.
  2. Does the pod or container have a CPU limit? Requests do not count.
  3. Print runtime.NumCPU() and runtime.GOMAXPROCS(0) at start-up, once, in the log. It is the cheapest diagnostic there is.
  4. Check for throttling in cpu.stat if latency is spiky and CPU usage sits at the limit.
  5. Remember the minimum of two.

The GOMAXPROCS documentation in the runtime package is the authoritative statement of the rules, and the Go 1.25 release notes describe the change.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.