How does Go 1.25's container-aware GOMAXPROCS default pick its value, and when is it recomputed?
answer
- two inputs, take the smaller
- fractional quotas round upward
- it does not decide once and forget
- two GODEBUG names, one for each half
- the go.mod line decides which default you get
basics
~20 sOn Linux the Go 1.25+ runtime takes the smaller of the usable logical CPUs and the cgroup CPU limit, rounds a fractional limit up, and stays at 2 or above. It re-reads the limit periodically.
solid answer
~50 sTwo inputs feed the default: the logical CPUs usable by the process (which honours its CPU affinity mask) and the cgroup CPU bandwidth limit — under cgroup v2 the quota and period in `cpu.max`. The runtime takes the minimum, rounds a fractional limit up to a whole number, and will not go below 2 on the strength of the cgroup limit. Unlike the old start-up-only decision, the value is re-read periodically, so raising or lowering a container's CPU limit while the process runs adjusts GOMAXPROCS without a restart. Two GODEBUG knobs turn the behaviour off: `containermaxprocs=0` makes the runtime ignore the cgroup limit, and `updatemaxprocs=0` stops the periodic recomputation. Like other GODEBUG defaults, the new behaviour is gated on the `go` directive in `go.mod`, so a module still declaring an older Go version keeps the old default even when built with a newer toolchain.
code
text · 8 lines# ignore the cgroup CPU limit; default from visible CPUs only
GODEBUG=containermaxprocs=0 ./service
# compute once at start-up, never re-read the limit
GODEBUG=updatemaxprocs=0 ./service
# and in go.mod, the language version that enables the new default:
# go 1.25go deeper
Remember the shape of the rule rather than the fine print: the runtime compares the CPUs it can see against the container's CPU limit and uses the smaller of the two.
Be able to walk through both inputs, the rounding and floor rules, the periodic re-read, and the two GODEBUG names that disable each half of the behaviour.
Know why a fleet can upgrade toolchains and see no change at all — the go.mod language version gates GODEBUG defaults — and make verifying that line part of your rollout checklist.
Decide whether the fleet standard is the runtime's tracking default or a pinned value, and defend the choice against workloads that deliberately over-subscribe their quota.
## The two inputs The Go runtime has always had one input for its default: how many logical CPUs the process can use, reported by `runtime.NumCPU()`. That number reflects the machine's CPUs narrowed by the process's CPU affinity mask — pinning a process to four cores does lower it. What it has never reflected is a **cgroup CPU bandwidth limit**. A bandwidth limit does not take CPUs away; it grants the cgroup a budget of CPU time per repeating period (under cgroup v2, a quota and a period written in `cpu.max`). The process still sees every CPU on the node. A container allowed half a core on a 64-CPU machine sees 64. Go 1.25 added that second input on Linux. The default becomes, in effect: > min(logical CPUs usable by the process, cgroup CPU bandwidth limit), the limit rounded **up** to a whole number, and never dropped below **2** because of the limit. The rounding-up rule matters because quotas are routinely fractional — 250m, 500m, 1500m in orchestrator units. A 1.5-CPU limit gives 2, not 1. The floor of 2 keeps a sub-core container from serialising everything onto a single P, which would make latency worse than the mild over-subscription it avoids. ## Why it is recomputed, not just read once The old default was a start-up decision, and that is fine when a machine's CPU count never changes. Container limits do change: a vertical autoscaler edits them, an operator resizes a workload, a platform migrates a service between limit tiers. So the runtime now **re-reads the limit periodically** and updates GOMAXPROCS when it has moved. Nothing in your code participates; the value simply tracks the environment. The update only applies while the value is the runtime's own default. The moment the program pins it — through the `GOMAXPROCS` environment variable or a `runtime.GOMAXPROCS(n)` call with `n >= 1` — the runtime stops updating it, because an explicit setting is a statement of intent that automatic adjustment would silently undo. ## Turning it off Two GODEBUG settings control the two halves independently: - `GODEBUG=containermaxprocs=0` — ignore the cgroup CPU limit; the default returns to the visible CPU count. - `GODEBUG=updatemaxprocs=0` — compute the default once at start-up and never revisit it. You would reach for the first only when the cgroup limit genuinely misrepresents what the workload is allowed to use — for example a deliberately over-subscribed batch tier where throttling is acceptable and throughput is the only goal. The second is for anyone who needs a value that provably never moves under them. ## The `go.mod` gate — the part people miss GODEBUG defaults in Go follow the **language version declared in `go.mod`**, not the toolchain that compiles the code. That is a deliberate compatibility rule: rebuilding an old module with a new toolchain must not change its behaviour. So a module whose `go` line still names a pre-1.25 version keeps the old, CPU-count-only default even when it is built with Go 1.25 or later. Bumping the `go` directive is the switch that turns on the new behaviour, and on a fleet that makes the `go` line — not the installed toolchain — the thing to audit. ## Platform scope The cgroup input is Linux-specific, because cgroups are. On other operating systems the default remains the visible CPU count, so a developer laptop and a production container can legitimately choose very different values for the same binary. That is another argument for logging `runtime.NumCPU()` and `runtime.GOMAXPROCS(0)` at start-up rather than reasoning about what the value ought to be. ## What this does not change GOMAXPROCS is still only the runtime's own parallelism target. It does not raise or lower the kernel-enforced quota, it does not bound OS threads, and it does not stop throttling on its own — it stops the runtime from *causing* avoidable throttling by attempting far more parallel work than the quota can pay for.
- A team upgrades to the Go 1.25 toolchain but their container still throttles exactly as before. What do you check first?The `go` directive in `go.mod`. GODEBUG defaults are gated on the declared language version, so a module still saying an older Go version keeps the CPU-count-only default even under a newer toolchain. Bumping the `go` line — or setting `GODEBUG=containermaxprocs=1` explicitly — enables it.
- Why does the runtime round a fractional CPU limit up rather than down?Rounding down would turn a 1.5-CPU container into a single P, serialising all Go execution including collector work and inflating latency. Rounding up keeps a little parallelism available; the kernel still enforces the true quota, so the cost of the extra P is bounded.
- Does the periodic update still happen if the GOMAXPROCS environment variable is set?No. An explicit setting, from the environment variable or from `runtime.GOMAXPROCS(n)`, pins the value and disables the automatic updates. `runtime.SetDefaultGOMAXPROCS()` puts the runtime back in charge and resumes them.
saying these in an interview costs you the question
- Thinks the cgroup limit removes CPUs from runtime.NumCPU's view
- Says the default is still computed once at start-up on Go 1.25+
- Assumes installing a new toolchain changes the default without touching go.mod
- Rounds a fractional CPU quota down to zero or one P
- Believes the container-aware default applies on every operating system