Go’s Goroutine Scheduler Internals: The M:N Model, Work Stealing, and the GOMAXPROCS Myths That Won’t Die
Ask a room full of cloud-native engineers what GOMAXPROCS does, and you’ll get confident, mostly wrong answers. “It’s the goroutine limit.” “It caps concurrency.” “You should set it high for max throughput.” None of that is quite right, and the gap between what people believe and what the Go scheduler actually does has caused real, measurable production incidents, the kind where a service running on a 32-core node gets aggressively CPU-throttled because it was only ever allocated two cores in the first place.
Let’s actually open up the scheduler and settle this properly.
What is Go’s M:N scheduling model?
Go uses an M:N scheduler, meaning M goroutines are multiplexed onto N operating system threads, which in turn run on at most GOMAXPROCS logical processors. Unlike a 1:1 threading model where every concurrent unit maps to a dedicated OS thread, Go’s runtime manages goroutine scheduling itself, letting it create and destroy goroutines far more cheaply than OS threads while still fully utilizing available CPU cores.
That single design decision, an M:N model instead of 1:1, is why you can spin up hundreds of thousands of goroutines without your program falling over the way it would if each one were a full OS thread.
The GMP model: goroutines, machines, and processors
Go’s scheduler documentation and its own source code use three letters that show up constantly once you start reading scheduler internals: G, M, and P. A goroutine is a G, a lightweight unit of work the scheduler tracks. An OS thread is an M, short for machine, the actual kernel-scheduled entity that executes code. A P is a logical processor, a scheduling context that holds a local run queue of goroutines and must be attached to an M before any code actually runs.
Here’s the part that trips people up: at any given moment, there are at most GOMAXPROCS number of Ps, and only one M can be actively running per P. More Ms can exist and get created as needed, particularly when goroutines block on system calls, but the number of Ps is the real concurrency ceiling. Each P maintains its own local run queue of runnable goroutines, and there’s also a single global run queue shared across all Ps for overflow and fairness.
Below is the shape of this relationship laid out directly, showing how goroutines flow through Ps onto Ms and, ultimately, actual CPU cores.
This structure matters because it explains why creating a goroutine is closer to a memory allocation than a thread spawn. The scheduler is doing all the work of deciding which P picks it up and when, and that decision-making is where work stealing enters the picture.
How work stealing actually keeps cores busy
Go has run a work-stealing scheduler since version 1.1, a contribution from Dmitry Vyukov that fundamentally changed how the runtime distributes load. The core idea is simple to state and genuinely elegant in practice: when a P’s local run queue runs dry, rather than sitting idle, it looks at other Ps and steals a chunk of goroutines from a busy one’s queue rather than waiting for work to be pushed to it.
This is a meaningfully different strategy from work-sharing, where a processor generating new work tries to proactively migrate it elsewhere. Work stealing only moves goroutines when a processor is actually idle and actively looking, which means migration overhead stays near zero whenever the system is well-balanced, and only kicks in exactly when it’s needed. The scheduling loop also periodically checks the global run queue, roughly one in every sixty-one scheduling rounds according to the runtime’s own source, specifically to prevent goroutines sitting in the global queue from starving indefinitely behind a continuous stream of locally-queued work.
The GOMAXPROCS default itself has a real history worth knowing. For years, up through Go 1.4, it defaulted to 1, meaning a Go program ran essentially single-threaded unless you explicitly called runtime.GOMAXPROCS() yourself. That changed in Go 1.5’s release notes, which set the default to the number of cores available on the host, a change tracked internally under the shorthand gomaxprocs=numcpu. That single default flip in 2015 is part of why so much Go folklore about manually tuning GOMAXPROCS upward is genuinely obsolete advice today, the runtime has already been doing the sensible thing by default for a decade.
Where the myth actually causes damage: containers
Here’s the part that trips up cloud-native teams specifically, and it’s not really about goroutines at all, it’s about a mismatch between what the Go runtime can see and what Kubernetes actually allows. GOMAXPROCS defaulting to NumCPU was a great decision for bare-metal and VM deployments, but inside a container, NumCPU reports the host’s core count, not the CPU quota Kubernetes assigned to that specific pod. A pod capped at two CPU cores running on a 32-core node will still set GOMAXPROCS to 32 by default, because the Go runtime, prior to recent versions, had no visibility into cgroup CPU limits at all.
The consequence isn’t just wasted potential, it’s active harm. When GOMAXPROCS exceeds the actual CPU quota, the Linux kernel’s CFS scheduler throttles the container once it exhausts its quota within a scheduling period, and Go’s own concurrent garbage collector gets hit especially hard, because GC coordination signals can stall waiting for a thread that’s currently sitting in a throttled state. Uber ran exactly this experiment on their internal load balancer under a 200 percent CPU quota, equivalent to two full cores, and published the results:
| GOMAXPROCS | RPS | P50 latency (ms) | P99.9 latency (ms) |
|---|---|---|---|
| 1 | 28,893 | 1.46 | 19.70 |
| 2 (matches quota) | 44,715 | 0.84 | 26.38 |
| 3 | 44,213 | 0.66 | 30.07 |
| 4 | 41,071 | 0.57 | 42.94 |
| 8 | 33,112 | 0.43 | 64.32 |
| Default (24) | 22,191 | 0.45 | 76.19 |
The pattern here directly contradicts the “just set it higher for more throughput” myth. Throughput peaks right at GOMAXPROCS = 2, exactly matching the quota, then steadily degrades as the value climbs past it, and tail latency at the default, uncorrected value is nearly four times worse than at the correctly tuned setting. This is precisely the throttling behavior the uber-go/automaxprocs documentation captured directly from cgroup statistics, showing tens of thousands of throttled periods before the fix and zero after.
Fixing it: automaxprocs and what changed in Go 1.25
For years, the standard fix was a small, purpose-built library. Importing go.uber.org/automaxprocs blank-style triggers an init() function that reads the container’s cgroup CPU quota, whether v1 or v2, computes the effective core allotment, and calls runtime.GOMAXPROCS() with the correct value automatically:
package main
import (
_ "go.uber.org/automaxprocs"
)
func main() {
// GOMAXPROCS is now automatically set to match
// the container's actual CPU quota, not the host's core count.
}
That single blank import has quietly fixed CPU throttling incidents across a huge number of Kubernetes-deployed Go services, precisely because it closes the gap between what NumCPU reports and what the container is actually entitled to use.
The bigger shift, though, landed natively in the language itself. As of Go 1.25, released in August 2025, the runtime reads cgroup limits directly on Linux by default, without any external dependency, and it goes a step further than the original automaxprocs behavior by actively watching for quota changes at runtime, which matters when Kubernetes adjusts resource limits on a live, running pod rather than only at startup. Teams still running Go 1.24 or earlier genuinely need automaxprocs or an equivalent fix. Teams on Go 1.25 and later largely don’t, though it’s worth verifying your specific deployment still behaves as expected rather than assuming the upgrade alone solved everything.
The myths, stated plainly and corrected
Given everything above, here’s where the common cloud-native beliefs about GOMAXPROCS actually stand. The claim that raising GOMAXPROCS always increases throughput is false, Uber’s own data shows throughput peaking exactly at the CPU quota and degrading past it. The claim that GOMAXPROCS limits how many goroutines you can run is also false, it limits the number of logical processors actively executing code, not the number of goroutines that can exist or be queued, which can run into the millions independent of this setting. And the belief that the Go runtime automatically handles container CPU limits correctly was false for a long time, and only became genuinely true starting with Go 1.25, not with the earlier Go 1.5 change that many engineers mistakenly conflate with container-awareness.
Frequently Asked Questions (FAQ)
Should I still use automaxprocs if I’m already on Go 1.25?
Generally no, since the runtime handles this natively now, but it’s worth explicitly testing rather than assuming, particularly if you’re running in a less common container environment or an older Kubernetes CPU manager policy that might interact with cgroup detection differently than expected.
Does setting GOMAXPROCS higher than my CPU count ever make sense?
Rarely, and mostly only for workloads that spend significant time blocked on I/O rather than doing CPU-bound work, where extra logical processors can help hide blocking latency. For CPU-bound workloads, which most latency-sensitive services are, matching GOMAXPROCS to your actual available cores is almost always the better default.
What’s the actual difference between a goroutine and an OS thread in terms of cost?
A goroutine starts with a tiny stack, around 2KB, that grows dynamically as needed, while an OS thread typically reserves a fixed, much larger stack, often a megabyte or more by default on Linux. That difference alone is why spinning up hundreds of thousands of goroutines is routine in Go while doing the same with OS threads would exhaust memory long before it exhausted CPU.
Can work stealing cause unpredictable latency for latency-sensitive goroutines?
It can introduce some scheduling jitter, since a goroutine’s local queue position isn’t a strict FIFO guarantee once stealing is involved, but the effect is generally small compared to the latency impact of CPU throttling from a misconfigured GOMAXPROCS, which is a far more common and more severe real-world cause of tail latency spikes in containerized Go services.



