Skip to content

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.

Your codego func() · make() · chan · MutexCPUthis postRuntimescheduler (GMP)KernelCPU quota (time budget)Linked byGOMAXPROCSMemorynext postRuntimeGC, paced by GOGCKernelmemory limit (absolute)Linked byGOMEMLIMITThe runtime sees the whole machine;the kernel limits each container
The blue lane is the CPU side covered here; the gray memory lane is the next post

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.

GOMAXPROCS sets the number of PsAn M must hold a P to run Go codeP0held by M0, running a GGGGlocal queueP1held by M1, running a GGGGlocal queueP2 · empty queuesteals Gs from another P (work stealing)Global queuewhere local queues overflow tonetpollerholds Gs waiting on the networkvia epoll or kqueue, without an MA simplified snapshot. GOMAXPROCS doesn’tcap the total number of Gs or Ms
Each P has a local queue, and P2, with an empty queue, takes work from another P

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.

sched/main.go · full program
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))
}
Output · Go 1.24.7 · linux/amd64, 2 cores (version line trimmed)
$ go build -o sched sched/main.go
$ GOMAXPROCS=1 ./sched
done: 4 checksum: 6185594040096594035 elapsed: 321ms
$ GOMAXPROCS=2 ./sched
done: 4 checksum: 6185594040096594035 elapsed: 171ms
$ GOMAXPROCS=1 ./sched -wait
done: 4 checksum: 4 elapsed: 200ms
$ GOMAXPROCS=2 ./sched -wait
done: 4 checksum: 4 elapsed: 201ms

All 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.

Time for four tasks to finish (median of three runs)
  • CPU · P=1213ms
  • CPU · P=2108ms
  • CPU · P=457ms
  • Wait · P=1201ms

darwin/arm64, 10 cores · Go 1.23.11 · CPU mode spins 40 million times; wait mode waits 200ms per task

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.

gor/main.go · full program
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)
}
Output · Go 1.24.7 · linux/amd64, 2 cores
$ go run gor/main.go
goroutines : 100001
StackInuse : 205.1 MB (2.00 KB/G)
OS threads : 4 / GOMAXPROCS: 2

Here 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.

Before · G1 enters a blocking syscall M0 + P0G1 is in the kernel inside read()M0 can’t return until it finishesG2 and G3 in P0’s queue stall tooAfter · P0 is handed to another MM0 (no P)waits in the syscall with G1M1 + P0takes P0 and keeps running G2, G3If no M is idle, the runtime starts one,so the thread count can briefly grow.Short syscalls may return with no handoff.
P0 moves from M0 to M1, so G2 and G3 keep running without waiting for G1

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.

preempt/main.go · full program
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))
}
Output · the second run was killed after 5 seconds
$ go run preempt/main.go
main 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.

Quota 100ms per 100ms period (1 CPU)Simplified: plenty of runnable work and CPUsFour threads running at onceM0M1M2M3budget spent · waiting025ms100ms4 × 25ms spends the whole 100ms budgetthe remaining 75ms waits for the next periodOne thread runningM00100msSame total CPU time, different timingThis shows the rule. Real timing dependson kernel scheduling and other load
Four threads spend the same 100ms in 25ms; one thread spreads it across the period

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.

Output · Go 1.23.11 · darwin/arm64, 10 cores (400 million spins)
$ GODEBUG=schedtrace=200 GOMAXPROCS=2 ./sched
SCHED 0ms: gomaxprocs=2 idleprocs=1 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 [0 0]
Go: go1.23.11 NumCPU: 10 GOMAXPROCS: 2
SCHED 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.082s

gomaxprocs 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.

Reading cgroup v2 · inside a Linux container (not on local macOS)
cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/cpu.stat

These 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

Footnotes

  1. This description models the Go runtime’s implementation. The language spec doesn’t guarantee any particular queue structure or scheduling order. ↩

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›