skip to content

Why can GOMAXPROCS differ between your laptop and a CI container, and how do you test both?

level: middleimportance: should knowfreq 42%

answer

  1. how many run at once, not how many exist
  2. the default comes from the machine, not the code
  3. a quota on Linux can lower it
  4. one flag re-runs each test per value
  5. a comma-separated list on go test

basics

~20 s

GOMAXPROCS bounds how many goroutines run Go code at the same time and defaults to the machine's usable CPUs, so a ten-core laptop and a CPU-limited CI container run different amounts of real parallelism. Use go test -cpu=1,2,8 to exercise several values.

solid answer

~50 s

GOMAXPROCS is the number of logical processors the Go runtime will use, so it caps how many goroutines execute Go code simultaneously. Its default is derived from the CPUs available to the process, and on Linux recent Go also takes the container's cgroup CPU limit into account — so the same test binary can run with GOMAXPROCS around ten on a developer machine and one or two in CI. That changes interleaving completely: at one processor goroutines only alternate at scheduling points, so a spin-wait can starve and a test that assumes a helper goroutine has already run will behave differently, while at eight there is genuine simultaneity that can surface a race. To exercise both, `go test -cpu=1,2,8` re-runs each test once per value with GOMAXPROCS set accordingly, and `GOMAXPROCS=1 go test -count=100 -run TestX` pins one value for a repetition loop.

code

text · 5 lines
text
# run every selected test once per GOMAXPROCS value
go test -cpu=1,2,8 -count=20 -run TestWorkerPoolResults ./internal/scheduler

# pin one value for a long repetition loop
GOMAXPROCS=1 go test -count=200 -run TestWorkerPoolResults ./internal/scheduler

go deeper

for a junior

Recall that GOMAXPROCS is how many goroutines can run Go code at once, that it defaults from the machine's available CPUs, and that a test machine and a CI machine therefore differ.

for a middle

Explain the mechanism: Ps rather than goroutines or threads, the container-limit-aware default on Linux, and how -cpu=1,2,8 re-runs each test at each value while an environment variable pins one.

for a senior

Demonstrate the diagnosis. When a concurrency test is green locally and red in CI, match CI's parallelism before theorising, and recognise that a result that depends on the value means the test is asserting something the runtime never promised.

for a principal

Own the standard: whether CI runs the suite at more than one parallelism level, what that costs in minutes, and whether the team treats a level-dependent test as a defect to fix rather than a setting to pin.

## What GOMAXPROCS actually bounds The Go runtime multiplexes goroutines onto OS threads through a fixed number of logical processors, conventionally called Ps. **GOMAXPROCS is the number of Ps**, and therefore the maximum number of goroutines executing Go code at the same instant. It is not a cap on how many goroutines you may create, nor on how many OS threads the process may have — a goroutine that blocks in a syscall hands its P to another thread, so the thread count can comfortably exceed GOMAXPROCS. You read and set it at run time with `runtime.GOMAXPROCS`, or from the environment with the `GOMAXPROCS` variable. The default is derived from the CPUs the process may use. ## Why CI's value differs from yours A developer machine reports its physical core count and nothing constrains the process, so GOMAXPROCS is typically 8, 10 or 16. A CI job usually runs inside a container with a CPU bandwidth limit — often one or two CPUs' worth on a much larger host. Historically the Go runtime looked only at the number of logical CPUs the OS reported, so a container limited to one CPU on a 64-core machine still started with GOMAXPROCS 64: the runtime believed it had massive parallelism while the kernel throttled it, producing long scheduling delays and erratic latency. Since Go 1.25 the default on Linux also honours the cgroup CPU bandwidth limit, and the runtime re-reads that limit periodically so a changed quota is picked up. The behaviour can be turned off with `GODEBUG=containermaxprocs=0` (and the periodic update with `GODEBUG=updatemaxprocs=0`), and `runtime.SetDefaultGOMAXPROCS` recomputes the default on demand. Either way, the conclusion for a test suite is the same: **the parallelism your tests run at is a property of the machine, not of your code**, and it is different in CI. ## Why that turns into a flaky test Interleaving changes at both ends of the range. - **At GOMAXPROCS=1** only one goroutine runs Go code at a time. Goroutines still interleave — at channel operations, mutex contention, allocation, function calls and syscalls — but a goroutine that busy-waits on a variable without ever reaching a scheduling point can hold the only processor for a long time. Tests that assumed "the worker has surely started by now" fail here more often, because the main test goroutine may run straight through to its assertion before the workers get a turn. - **At GOMAXPROCS=8** there is real simultaneity, so two goroutines genuinely touch the same memory at the same moment. Races that are theoretically present but never observed at one processor start being observed, and orderings that never occurred in a serialised run become common. So the same test can be red only in CI (low parallelism, loaded box) or red only locally (high parallelism), and neither result generalises. ## Exercising both from one command `go test` has a flag for exactly this: `-cpu` takes a comma-separated list and runs **each test once per value**, setting GOMAXPROCS to that value for the run. ``` go test -cpu=1,2,8 -count=20 -run TestWorkerPoolResults ./internal/scheduler ``` That is twenty runs at each of three parallelism levels. Alternatively, pin the value from the environment when you want a long repetition loop at one setting: `GOMAXPROCS=1 go test -count=200 -run TestWorkerPoolResults ./internal/scheduler`. Two neighbouring knobs are easy to confuse with this one: - `-parallel` controls how many tests marked parallel run concurrently; it defaults to the GOMAXPROCS value but is a different thing — a test-harness concurrency limit, not a runtime one. - `-cpu` does not pin the process to particular cores; it sets a runtime parameter, and the OS still schedules the threads wherever it likes. ## The judgment part Matching CI's GOMAXPROCS locally is the cheapest way to close the "works on my machine" gap on a concurrency test, and it costs nothing to add `-cpu=1,4` to the flaky test's reproduction command. It is not a fix, though: a test whose result depends on how many processors are available is asserting something the language never promised. The value of running at several parallelism levels is diagnostic — it tells you that the test, not the machine, is making an assumption — and the repair is to synchronise explicitly so the answer is the same at one processor and at sixteen.

  • Does GOMAXPROCS=1 mean a concurrency bug cannot appear?
    No. Goroutines still interleave at channel operations, locks, allocations and syscalls, so ordering bugs, deadlocks and missed synchronisation all still occur — often more visibly, because one goroutine can run far ahead. What one processor mostly removes is simultaneous access, which is why some races surface only at higher values.
  • How is -cpu different from -parallel on go test?
    -cpu sets the runtime's GOMAXPROCS and re-runs each test once per listed value. -parallel caps how many tests marked parallel execute concurrently within the binary and defaults to the current GOMAXPROCS. One is a runtime parameter, the other a harness limit.
  • Why did CPU-limited containers used to run Go badly oversubscribed?
    The default came from the number of logical CPUs the OS reported, which is the host's count, not the quota the cgroup enforces. The runtime would happily run dozens of processors' worth of work against a one-CPU budget, producing throttling and long scheduling delays until the default became limit-aware.

saying these in an interview costs you the question

  • Says GOMAXPROCS caps the number of goroutines
  • Says GOMAXPROCS caps the number of OS threads
  • Assumes CI and laptop run the same parallelism
  • Thinks GOMAXPROCS=1 makes concurrent code deterministic
  • Confuses -cpu with -parallel
  • Believes -cpu pins the process to specific cores