Go GMP and GOMAXPROCS — container CPU limits and goroutines
Contents8 sections
Put go in front of a call and it runs concurrently. So do 100,000 goroutines mean 100,000 threads? Does GOMAXPROCS=1 mean one task at a time? Separate the work from the resources that run it and the answers fall out. Add the container’s CPU time budget on top, and we can follow how a single setting turns into latency.
The runtime is a layer between your code and the kernel
The Go fundamentals series was about what your code means. This series is about how that code actually runs on a machine. Between your code and the CPU and memory sits a layer, the Go runtime, and it has two big jobs: the scheduler, which puts goroutines on threads, and the GC, which cleans up the heap.
The runtime makes decisions based on the whole machine, while the kernel limits each container. Those two views disagree in one place for CPU and one place for memory. This post takes the CPU side, the scheduler. We’ll first see why goroutines are cheap from the structure itself, then look at how that structure can drift inside a container. For a broader tour of the runtime, see How static typing and compilation affect runtime performance.
Do 100,000 goroutines mean 100,000 threads?
No. A goroutine is work to be done; a thread is a resource that does the work. With 100,000 goroutines parked and waiting, there were 4 OS threads (GOMAXPROCS=2, see the lab below). The runtime scheduler connects the two, and a resource called P sits in the middle.
A kitchen with a thousand order tickets doesn’t need a thousand cooks. Tickets are work; cooks are the resource doing it. Splitting Go the same way makes it easier to start.
| Symbol | What it is | Analogy |
|---|---|---|
| G | A goroutine: a task with its own execution state and stack | An order ticket and how far along it is |
| M | A thread scheduled by the operating system | A cook actually doing the work |
| P | Runtime state needed to run Go code, like a run queue and allocator state | A workstation set up for cooking |
A P is not a physical CPU core. The workstation analogy describes its role; a P isn’t pinned one-to-one to a real core.
To run Go code, an M needs a P, and the runtime pairs up G, M, and P. With GOMAXPROCS at 2 there are two Ps, so two sets of resources can run Go code at once, and the remaining runnable Gs wait their turn in a queue.
Why not just G and M? If all work piled into one global queue, every thread would contend for it. Go gives each P a local run queue and spreads work with work stealing, where the side running short takes work from another. There’s no need to memorize queue lengths or internal constants; it’s enough to know that P holds the state needed to divide the work.1
🧪 Try it
With one P, four tasks still progress together
Let’s check with numbers that “tasks in progress” and “Go code running on a CPU at the same time” are different counts. The program below starts four tasks as goroutines. The default mode does CPU work; -wait mode has each task wait 200ms.
package main
import ( "flag" "fmt" "runtime" "sync" "time")
func spin(seed uint64) uint64 { x := seed for i := 0; i < 40_000_000; i++ { x ^= x << 13 x ^= x >> 7 x ^= x << 17 } return x}
func main() { wait := flag.Bool("wait", false, "wait 200ms instead of computing") flag.Parse() fmt.Println("Go:", runtime.Version(), "NumCPU:", runtime.NumCPU(), "GOMAXPROCS:", runtime.GOMAXPROCS(0))
start := time.Now() results := make([]uint64, 4) var wg sync.WaitGroup for i := range results { wg.Add(1) go func(idx int) { defer wg.Done() if *wait { time.Sleep(200 * time.Millisecond) results[idx] = uint64(idx + 1) return } results[idx] = spin(uint64(idx + 1)) }(i) } wg.Wait() // each goroutine writes only its own element; reads happen after Wait
var sum uint64 for _, v := range results { sum ^= v } fmt.Println("done:", len(results), "checksum:", sum, "elapsed:", time.Since(start).Round(time.Millisecond))}$ go build -o sched sched/main.go$ GOMAXPROCS=1 ./scheddone: 4 checksum: 6185594040096594035 elapsed: 321ms$ GOMAXPROCS=2 ./scheddone: 4 checksum: 6185594040096594035 elapsed: 171ms$ GOMAXPROCS=1 ./sched -waitdone: 4 checksum: 4 elapsed: 200ms$ GOMAXPROCS=2 ./sched -waitdone: 4 checksum: 4 elapsed: 201msAll four tasks finished in every run, and the CPU-mode checksum is the same whatever P is. P sets parallelism; it doesn’t change results or whether work progresses.
CPU mode got faster with more Ps. It doesn’t have to be exactly twice as fast: usable CPUs, core performance, and whatever else is running all play a part. This was a 2-core machine, so raising P further wouldn’t have meant anything.
The line that matters most is wait mode. With a single P, wait mode still finished in about 200ms. Handled one at a time, it would have taken 800ms. A waiting task doesn’t keep holding a place to run, so the four waits overlap. That’s why “GOMAXPROCS=1 handles one request at a time” is wrong.
I reran each case three times on my Mac (darwin/arm64, 10 cores, Go 1.23.11), and the pattern held.
With cores to spare, raising P to 4 brought CPU mode down to 57ms, while wait mode took 201ms even with one P. The waiting here is a timer, not real network traffic. Look at the trend across runs rather than one number, and if the gap blurs, increase the loop count.
🧪 Try it
100,000 goroutines used 205MB of stack and 4 threads
A goroutine starts with a small stack that grows as needed, and much of the switching happens in user space. That’s why the runtime can manage a lot of tasks fairly cheaply. Let’s measure how cheaply.
package main
import ( "fmt" "os" "regexp" "runtime" "time")
func main() { var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before)
block := make(chan struct{}) for i := 0; i < 100_000; i++ { go func() { <-block }() // every one of them blocks and waits } time.Sleep(500 * time.Millisecond) runtime.ReadMemStats(&after)
b, _ := os.ReadFile("/proc/self/status") // OS thread count on Linux threads := regexp.MustCompile(`Threads:\s+(\d+)`).FindStringSubmatch(string(b))[1]
fmt.Println("goroutines :", runtime.NumGoroutine()) fmt.Printf("StackInuse : %.1f MB (%.2f KB/G)\n", float64(after.StackInuse)/1e6, float64(after.StackInuse)/100_000/1024) fmt.Println("OS threads :", threads, "/ GOMAXPROCS:", runtime.GOMAXPROCS(0)) close(block)}$ go run gor/main.gogoroutines : 100001StackInuse : 205.1 MB (2.00 KB/G)OS threads : 4 / GOMAXPROCS: 2Here each goroutine used 2KB of stack, and 100,000 goroutines ran on 4 OS threads. The thread count is roughly the number of Ps plus a few tied up in syscalls or runtime work.
macOS has no /proc, so to run it on my Mac I changed one line to count threads with the threadcreate profile from runtime/pprof. With GOMAXPROCS=2 I got 205.1MB, 2.00KB/G, and 4 threads, the same as the original; with the default of 10, there were 11 threads.
Let’s not overstate it, though. Saying the starting stack is exactly 2KB everywhere, or that an OS thread stack is always several MB, is wrong. A thread stack usually reserves address space and only uses physical memory for what it touches. Goroutines also keep stacks and referenced data alive, so they aren’t free to create without limit. 205MB is not nothing.
What makes goroutines cheap is the combination of stacks that start small and grow, switching in user space without the kernel, and the netpoller taking over network waits.
When a thread blocks, the P moves: handoff and the netpoller
A G waiting on a channel has nothing to do right now, so the runtime parks it and runs another G. The harder case is when the OS thread itself gets stuck, as with a blocking system call or a cgo call.
So GOMAXPROCS=2 doesn’t mean exactly two OS threads in total. The blocked M waits in the kernel without a P, and another M has to take that P over.
Network I/O does one better. For I/O the runtime’s netpoller supports (epoll on Linux, kqueue on macOS), the runtime registers the socket with the netpoller, parks only the G, and wakes it when the socket is ready. Meanwhile the M picks up other Gs. That’s why your code is a single conn.Read() line and yet no thread blocks.
🧪 Try it
Since Go 1.14, even loops without function calls get preempted
A fixed number of Ps brings a risk: what if a G never gives up its P? Up to Go 1.13, preemption was cooperative, so a G only yielded at preemption points such as function calls. Compute loops with few function calls could hog a P and, at worst, delay the GC’s stop-the-world. Asynchronous preemption, added in Go 1.14, eased this.
package main
import ( "fmt" "runtime" "time")
func main() { runtime.GOMAXPROCS(1) // limit the runtime to a single P start := time.Now() var n int go func() { for { // a compute loop with no function calls n++ } }() time.Sleep(10 * time.Millisecond) fmt.Println("main woke up after:", time.Since(start).Round(time.Millisecond))}$ go run preempt/main.gomain woke up after: 10ms$ GODEBUG=asyncpreemptoff=1 go run preempt/main.go(no output; stopped by a 5-second timeout)In the second run, Sleep(10ms) finishes but main has no P to come back to: the loop goroutine holds the only P and never reaches a preemption point. On my Mac (darwin/arm64, Go 1.23.11) the first run woke up after 14ms, and with asyncpreemptoff=1 nothing printed after 5 seconds.
The implementation varies by platform, and this doesn’t make Go a real-time scheduler that guarantees run intervals or strict fairness for every goroutine.
One container CPU is not a dedicated core
This is where the practical trap starts. A Linux container’s CPU limit usually works as a CPU time budget per period, set by the cgroup. That’s different from reserving one CPU for this process. Running on several CPUs at once adds those times together and drains the budget.
Total CPU use is the same in both cases. The difference is whether the work is spread evenly across the period or bunched at the start, followed by a wait. That’s why average CPU utilization alone is hard to read: the average mixes time spent running with time spent waiting after the budget ran out. Running in bursts and then waiting can show up in tail latency.
That said, don’t blame the CPU quota every time p99 spikes. GC, lock contention, and external I/O need to be checked separately.
If you’re on Kubernetes, keep CPU request and limit apart. The request affects scheduling and how resources are shared under contention; the quota-based cap described here comes from the limit. Apply this to a deployment with no limit and the reading goes wrong.
Even after Go 1.25, the limit and P don’t always match
Up to Go 1.24, the default ignored the container’s CPU quota. P was based on the visible logical CPU count, so a pod with limit: 1 on a 64-core node could end up with execution parallelism far out of line with its allowed CPU time. Go 1.25 added a default on Linux that takes the quota into account.
It’s still wrong to shorten that to “limit 1, so P is 1.” The default now considers the logical CPU count, the CPUs allowed by affinity, and the quota. A fractional quota is rounded up, and as long as the logical CPU count or affinity is 2 or more, the quota won’t push the default P below 2. So a CPU limit of 1 can still give you 2 Ps, and that alone doesn’t mean the setting failed.
Upgrading the toolchain doesn’t change every default either. These are the values to check in a deployment:
| Item | Why it matters |
|---|---|
Toolchain · go.mod version |
Compatibility defaults follow the language version, not just the runtime version |
GODEBUG |
containermaxprocs turns quota awareness on or off; updatemaxprocs does the same for automatic updates. If go.mod says go 1.24 or earlier, their compatibility default may be 0 |
Setting GOMAXPROCS |
A positive value in the env var, or calling GOMAXPROCS(n) with n > 0, turns off both the automatic default and automatic updates |
| Quota · affinity | The quota is a time budget; affinity is the set of CPUs you may run on. They are different constraints |
On a cgroup v1 node there’s one more catch. When a container shares the host’s cgroup namespace, Go 1.25 builds the wrong path to the quota files, can’t read the limit, and falls back to the logical CPU count. On cgroup v1, Docker and podman start containers that way by default, and so were the pods on the RKE2 v1.28.2 node I checked. The bug was reported as Go issue #76390 and fixed in Go 1.26. Running with a CPU limit of 1 on a 4-vCPU node, Go 1.25.8 got GOMAXPROCS 4 and Go 1.26.10 got 2. The experiment is in the requests and limits post.
So don’t guess the value; read it at run time. If you log runtime.GOMAXPROCS(0) and runtime.NumCPU() at startup, as the lab code does, you can compare the actual values every time you upgrade or edit a manifest.
On older versions you can set it explicitly or use a library such as automaxprocs. Before adding one on a new runtime, though, it’s better to check what your existing deployment settings already pin.
Where do you read CPU throttling?
Throttling is recorded on the cgroup side. On Linux cgroup v2, cpu.max holds the quota, and nr_throttled and throttled_usec in cpu.stat give the number of times and the total time the group stopped after spending its budget. The Go scheduler trace sits alongside, showing how much is piled up in the run queues.
🧪 Try it
schedtrace separates Gs running on a P from Gs in line
Running the local example with GODEBUG=schedtrace prints the run queues and scheduler state periodically. Use short intervals only in experiments; in production, match the interval to how much output you can handle and what you’re looking for. To get several lines, I raised the spin loop in sched/main.go to 400 million iterations.
$ GODEBUG=schedtrace=200 GOMAXPROCS=2 ./schedSCHED 0ms: gomaxprocs=2 idleprocs=1 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 [0 0]Go: go1.23.11 NumCPU: 10 GOMAXPROCS: 2SCHED 212ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=1 [0 1]SCHED 412ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=1 [0 1]SCHED 622ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=2 [0 0]SCHED 829ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=2 [0 0]SCHED 1036ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 [1 1]done: 4 checksum: 11855647230730954544 elapsed: 1.082sgomaxprocs is the number of Ps, idleprocs the idle Ps, threads the number of Ms, runqueue the global queue length, and the brackets at the end the local queue length per P. From 212ms on, the global and local queues add up to 2 on every line: two of the four tasks are running on a P and two are waiting in line.
You can also see threads=4 with gomaxprocs=2. As we saw, there can be more Ms than Ps. In the original environment (Go 1.24.7, linux/amd64, 2 cores) the same spot showed threads=6. Still, the scheduler trace alone can’t confirm CPU throttling.
cat /sys/fs/cgroup/cpu.maxcat /sys/fs/cgroup/cpu.statThese paths work in the common setup where the container has its own cgroup root mounted. With a different mount layout, or from the host, find the real path from /proc/self/cgroup and the mount information.
A cpu.max of 100000 100000 means 100,000μs of CPU time every 100,000μs period, and max 100000 means this file sets no quota cap. nr_throttled and throttled_usec in cpu.stat are cumulative, so look at the difference across the same load window instead of comparing one running total directly with service latency.
Each observation answers a different question.
| Observation | Tells you | Doesn’t tell you |
|---|---|---|
NumGoroutine() |
How many tasks are in progress | How many are on a CPU right now |
schedtrace |
The number of Ps and how much is queued | How much CPU the kernel actually allowed |
cpu.stat deltas |
How often and how long the group stopped in that window | Whether that caused this p99 rise |
Before and after changing P, compare throughput, p95 and p99, CPU time, and the growth in throttling together. Raising P just because the run queue is long can burn through the budget faster, and lowering it can cut CPU throughput. My local measurements ran outside a container, so they don’t reproduce throttling.
Wrapping up
| Number | What it counts | What we saw here |
|---|---|---|
| Goroutines | Work in progress | 100,000 of them: 205MB of stack, 4 OS threads |
P (GOMAXPROCS) |
How much Go code runs at once | Even with one P, four waiting tasks finished in 200ms |
| CPU quota | CPU time per period | Four threads spend a 100ms budget in 25ms (illustration) |
I knew goroutines were cheap, but only after drawing these figures did it click that the reasons all sit in one layer, P. Local queues cut contention, a fixed count keeps threads from growing without bound, and because a P can be detached and handed over, one blocked thread doesn’t stop the rest.
That fixed count is exactly what needs checking in a container, because who decides it, and based on what, has changed across Go versions. After upgrading Go or editing a manifest, the first two values I look at are GOMAXPROCS(0) and NumCPU() in the startup log.
References
- Go 1.25 Release Notes · Container-aware GOMAXPROCS: quota awareness, automatic updates, and how to turn them off
- runtime.GOMAXPROCS: how the default is chosen, the floor of 2, and rounding up
- Go issue #76390: on cgroup v1, the quota goes unread when the mount root isn’t
/; fixed in Go 1.26 - GODEBUG:
containermaxprocs,updatemaxprocs, and their defaults bygo.modversion - Go 1.14 Release Notes · Runtime: asynchronous preemption
- Linux cgroup v2 · CPU Interface Files:
cpu.maxandcpu.stat - runtime/proc.go: the G, M, P scheduler implementation and its comments
- runtime/pprof · Profile: the
threadcreateprofile used to count threads on macOS
Footnotes
-
This description models the Go runtime’s implementation. The language spec doesn’t guarantee any particular queue structure or scheduling order. ↩