GMP Scheduler Model

The GMP scheduler is the heart of the Go runtime: the mechanism that multiplexes millions of goroutines onto a handful of operating-system threads. Its name is its design — three entities, G, M, and P. A G is a goroutine: a unit of work with a stack. An M is a machine, an OS thread that actually executes instructions. A P is a processor: not a CPU, but a scheduling context — the right to run Go code, plus the per-CPU caches needed to do so. The runtime’s own design notes state the rule plainly: an M “must have an associated P to execute Go code, however it can be blocked or in a syscall w/o an associated P,” and “exactly GOMAXPROCS Ps” exist at any time (runtime/proc.go, runtime/HACKING.md). The scheduler’s job is the continuous matching of these three: find a runnable G, pair it with a P, run it on an M. This three-level design — added in Go 1.1 to replace a simpler two-level scheduler — is what makes goroutines both cheap and able to use every core.

Mental Model

The clearest way to think about GMP is a work-distribution system with a fixed number of work stations. Each work station is a P — there are exactly GOMAXPROCS of them, one per core you want to use. A worker is an M, an OS thread; a worker must stand at a work station (hold a P) to do Go work. The jobs are Gs, the goroutines. Each work station has its own small in-tray (the P’s local run queue); there is also one shared in-tray for the whole shop (the global run queue). When a worker finishes a job, it takes the next one from its station’s in-tray. When that in-tray is empty, the worker does not idle — it walks to another station and steals half the jobs from that station’s in-tray. This is work stealing, and it keeps every core busy without a central bottleneck.

graph TD
    subgraph "P0 — scheduling context"
        M0["M0 (OS thread)"]
        RQ0["local run queue\n(runnext + ring[256])"]
        G0a["G"]; G0b["G"]
    end
    subgraph "P1 — scheduling context"
        M1["M1 (OS thread)"]
        RQ1["local run queue"]
        G1a["G"]
    end
    GRQ["global run queue\n(overflow + fairness)"]
    NP["network poller\n(I/O-ready Gs)"]
    IDLE["idle M pool / idle P pool"]
    M0 --- RQ0
    M1 --- RQ1
    RQ0 -. work stealing .-> RQ1
    GRQ --> RQ0
    GRQ --> RQ1
    NP --> RQ0
    IDLE -. unparked when work appears .-> M1

Diagram: GOMAXPROCS Ps, each bound to at most one running M, each owning a local run queue; a single global run queue for overflow and fairness; a network poller feeding I/O-ready goroutines back in; and pools of idle Ms and Ps. The insight: scheduling is mostly local and lock-free — an M pulls from its own P’s queue with no locking — and the global queue and work stealing exist only to rebalance and to bound unfairness.

The reason for the P layer is the key idea. Before Go 1.1 the scheduler had only Gs and Ms with a single global run queue protected by one lock — every scheduling decision contended on that lock, and per-thread allocator caches were impossible. Inserting P gives each “logical CPU” a private, lock-free run queue and a private mcache. GOMAXPROCS then has a precise meaning: it is the number of Ps, hence the maximum number of goroutines running Go code in parallel.

Mechanical Walk-through

The three structs

All three are defined in src/runtime/runtime2.go. G is the goroutine (stack, saved registers, status — fully covered in Goroutine). M is the OS thread:

type m struct {
	g0       *g       // the M's system goroutine / fixed system stack
	curg     *g       // the user goroutine currently running on this M
	p        puintptr // the P this M is attached to (nil if none)
	spinning bool      // true if M is actively searching for work
	// ...
}

P is the scheduling context, and its most important fields are the run queue:

type p struct {
	id       int32
	status   uint32     // _Pidle / _Prunning / _Pgcstop / _Pdead
	m        muintptr   // back-link to the M holding this P
	mcache   *mcache    // this P's private allocator cache
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr // the local run queue: a 256-slot ring buffer
	runnext  guintptr      // a single "run this G next" fast slot
	gFree    gList         // recycled dead g structs
	// ...
}

The runq is a fixed 256-entry ring buffer, accessed without a lock by the owning M (it is single-producer for most operations, and the steal path uses atomics). runnext is a one-slot optimization: a goroutine readied by the currently-running goroutine goes into runnext so it runs immediately next, which the runtime comment describes as eliminating “the (potentially large) scheduling latency” for tightly-coupled goroutines (e.g. a channel send waking its receiver).

The scheduling loop — findrunnable

The core of the scheduler is the schedule function, which calls findrunnable to locate the next G to run. findrunnable searches sources in a deliberate order:

  1. Local run queue. Check this P’s runnext, then its runq. Lock-free, fastest.
  2. Global run queue. Periodically (every 61st scheduling tick) the scheduler checks the global queue first even if the local queue is non-empty — this guarantees fairness, so goroutines stuck in the global queue cannot starve behind a busy local queue.
  3. Network poller. Check for goroutines whose I/O has completed (see Network Poller).
  4. Work stealing. If still empty, the M becomes spinning and tries to steal runnable Gs from another P’s local queue — it picks a random victim P and takes roughly half of its runq. Stealing half (rather than one) amortizes the cost of the steal and spreads work quickly.
  5. Idle. If no work exists anywhere, the M parks itself (gives up its P, joins the idle-M pool) and the P joins the idle-P pool. The M blocks on a futex until woken.

Spinning Ms — avoiding both thrash and starvation

Step 4 introduces spinning. An M is “spinning” when it is “out of local work and did not find work in the global run queue or netpoller” but is actively searching by stealing (runtime/proc.go). Spinning is a deliberate trade: a spinning M burns a little CPU rather than parking, so that when new work appears it can grab it instantly without the latency of waking a parked thread. The runtime caps the number of spinning Ms (roughly half of the busy Ps) and uses a careful rule: “if there is at least one spinning thread …, we don’t unpark new threads when submitting work” — because a spinning thread will find that work itself. This balances responsiveness against wasted CPU and avoids thread thrash.

System calls — handoff

When a goroutine makes a blocking system call, its M must block in the kernel — but the M’s P should not sit idle. The runtime detaches the P from the M before the syscall. A fast path: if the syscall returns quickly, the M tries to reacquire its old P. A slow path: the system monitor, sysmon, runs the retake function and notices a P stuck in _Psyscall for too long, then hands the P offhandoffp gives the idle P to another M (creating one if needed) so the core stays busy. The retake comment in runtime/proc.go states the rule directly: “Retake P from syscall if it’s there for more than 1 sysmon tick (at least 20us)” — but only if there is other work to do, balancing the cost of stealing the P against leaving a core idle (golang/go retake-tuning commit). When the syscall finally returns, the original M finds it has no P, so it parks its now-_Gsyscall goroutine onto the global run queue (or its old P if free) and joins the idle-M pool. This is why a program with 100 goroutines all blocked in read() can have 100+ Ms but still only GOMAXPROCS of them running Go code. See System Calls and the Scheduler.

Preemption — stopping a long-running goroutine

A goroutine that never makes a function call (a tight numeric loop) cannot yield cooperatively, because cooperative yield points are inserted at function prologues. Since Go 1.14 the scheduler uses asynchronous preemption: sysmon notices a G that has been running longer than the time slice forcePreemptNS and sends the M a signal (SIGURG on Unix); the signal handler safely stops the goroutine at the next safe point and returns it to a run queue. The slice is a fixed runtime constant, const forcePreemptNS = 10 * 1000 * 1000 — 10 milliseconds (per runtime/proc.go, as-of Go 1.26). This guarantees no single goroutine can monopolize a P. See Goroutine Preemption.

GOMAXPROCS — sizing the P count

GOMAXPROCS is the number of Ps. Its default behavior changed in Go 1.25: on Linux the runtime “considers the CPU bandwidth limit of the cgroup containing the process,” and “if the CPU bandwidth limit is lower than the number of logical CPUs available, GOMAXPROCS will default to the lower limit”; the runtime also “periodically updates GOMAXPROCS if the number of logical CPUs available or the cgroup CPU bandwidth limit change” (Go 1.25 notes). Before Go 1.25, GOMAXPROCS defaulted to runtime.NumCPU() — the host’s logical CPU count — ignoring container limits, a frequent cause of CPU throttling in Kubernetes. Both new behaviors are disabled if GOMAXPROCS is set manually (env var or runtime.GOMAXPROCS), and can be toggled with GODEBUG=containermaxprocs=0 / updatemaxprocs=0. Go 1.25 also added runtime.SetDefaultGOMAXPROCS to re-enable the default after a manual override. Crucially, the runtime keys off the cgroup CPU bandwidth limit (the CFS quota, “CPU limit” in Kubernetes terms) and not the CPU request — the release notes are explicit that “the Go runtime does not consider the ‘CPU requests’ option” (Go 1.25 notes). This behavior is unchanged in Go 1.26 (as-of 2026-05).

Code and Configuration

Observing the scheduler

GODEBUG=schedtrace=1000 ./prog
# emits, once per second:
# SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 \
#   idlethreads=4 runqueue=0 [0 1 0 0 0 0 0 0]

Reading the line: gomaxprocs=8 is the P count; idleprocs=6 Ps have no work; threads=12 Ms exist (more than 8 because some are blocked in syscalls); spinningthreads=1 one M is actively stealing; runqueue=0 the global queue is empty; [0 1 0 0 ...] is each P’s local queue depth. A persistently large runqueue or skewed per-P depths signals a scheduling imbalance. See Scheduler Tracing and GODEBUG schedtrace.

Setting and querying GOMAXPROCS

import "runtime"
 
old := runtime.GOMAXPROCS(4)  // set P count to 4, return the previous value
n   := runtime.GOMAXPROCS(0)  // query: 0 means "don't change, just report"
runtime.SetDefaultGOMAXPROCS() // Go 1.25+: revert to the container-aware default

runtime.GOMAXPROCS(0) is the safe query form. Calling GOMAXPROCS with a positive argument disables the Go 1.25 container-aware auto-default; SetDefaultGOMAXPROCS re-enables it.

Yielding explicitly

runtime.Gosched()  // park the current G, return it to the run queue, schedule another

Gosched is an explicit cooperative yield. It is rarely needed — the scheduler preempts automatically — but occasionally useful in a tight loop that holds a P without ever blocking.

Failure Modes and Misunderstandings

GOMAXPROCS is the number of threads.” No — it is the number of Ps. The runtime may have far more Ms than GOMAXPROCS (one per blocked syscall), but only GOMAXPROCS of them run Go code simultaneously.

“More goroutines means more parallelism.” Parallelism is capped at GOMAXPROCS. Beyond that, extra goroutines add concurrency (interleaving) but not parallelism.

CPU throttling in containers (pre-Go-1.25). A service in a 2-CPU container on a 64-core host got GOMAXPROCS=64, so the GC and scheduler assumed 64 cores, the kernel CFS throttled the cgroup hard, and latency spiked. Go 1.25’s container-aware default fixes the common case; on older Go, set GOMAXPROCS explicitly to the CPU limit (the automaxprocs library did this).

A goroutine starving others. Before Go 1.14, a tight loop with no function calls could pin a P forever. Asynchronous preemption fixed this; on modern Go it is no longer a failure mode, but it explains old advice to “sprinkle in runtime.Gosched().”

Thread explosion from blocking syscalls or cgo. Each goroutine blocked in a syscall or a cgo call can occupy its own M. Thousands of simultaneous blocking calls create thousands of OS threads, which can exhaust memory or hit the OS thread limit. runtime.SetMaxThreads exists as a safety valve. See cgo Performance and Pitfalls.

Mistaking concurrency for parallelism. With GOMAXPROCS=1, goroutines still interleave (concurrency) but never run truly simultaneously (no parallelism). Race conditions can still exist; the race detector still finds them.

Alternatives and When to Choose Them

The GMP model is not user-selectable — it is the Go scheduler. The genuine alternatives are other languages’ concurrency runtimes. A 1:1 thread model (C with pthread, Java pre-virtual-threads) maps each concurrent task to an OS thread: simpler, but each task costs a kernel thread, so you pool aggressively and cannot have a million. An N:1 model (old green-thread libraries) multiplexes many tasks onto one thread: cheap tasks but no parallelism and one blocking syscall stalls everything. GMP is M:N — many goroutines on many threads — and the P layer with work stealing is what makes M:N scale across cores without a global lock. Java’s Project Loom virtual threads and Rust’s async executors (Tokio) are close cousins: both are M:N user-space scheduling, but Rust requires async function coloring and an explicitly chosen executor, while Go bakes the scheduler into the runtime so go f() “just works.” Choose Go’s model when you want maximum-simplicity concurrency that still uses every core; the cost is that you cannot swap in a different scheduling policy.

Production Notes

The scheduler is well-tuned and rarely the direct cause of a production problem — but it amplifies others. The classic incident is GC mark workers contending with application goroutines: during a GC cycle the collector dedicates roughly 25% of GOMAXPROCS to mark workers, so on a P-starved service the application visibly slows during collection. The fix is reducing allocation, not touching the scheduler. The second recurring issue is scheduling latency under load — goroutines sitting _Grunnable waiting for a P. The execution tracer (The Go Execution Tracer) shows this directly as “goroutine scheduling latency”; schedtrace shows it as a growing runqueue. The cure is usually fewer, longer-lived goroutines (a worker pool) rather than more Ps. The third — now mostly historical — is the container GOMAXPROCS mismatch, which the community solved for years with Uber’s automaxprocs import and which Go 1.25 finally fixed in the runtime itself. A subtle modern note: because Go 1.25’s GOMAXPROCS is now dynamic, a service whose cgroup limit is rescaled at runtime will see its P count change underneath it — usually desirable, but worth knowing when debugging a sudden change in parallelism with no deploy.

See Also