AlgoMaster Logo

GMP Scheduler Model

High Priority16 min readUpdated June 6, 2026

Fetching 10,000 product detail records concurrently on a 4-core machine would crush the kernel if each goroutine required its own OS thread. Go avoids this with a few worker threads and the GMP scheduler. This lesson explains the three components Go uses to run goroutines, how they fit together, and why this model lets a single program juggle millions of goroutines without melting the operating system.

The Three Letters

GMP stands for three runtime concepts that work in concert. Each one is a real data structure inside the Go runtime, and the scheduler shuffles goroutines through them.

LetterStands ForWhat It Is
GGoroutineA function call queued to run, plus its stack and state
MMachineAn OS thread the kernel knows about
PProcessorA logical scheduling context: a run queue plus the bookkeeping a thread needs to execute Gs

The names warrant a note. M doesn't mean "machine" as in your laptop; it means a single OS thread. P doesn't mean "processor" as in a CPU core, though the default number of Ps does equal the number of cores. P is a runtime abstraction that decouples "how many goroutines can run at the same time" from "how many OS threads happen to exist right now".

The split exists because of a fundamental mismatch. Goroutines are cheap. An OS thread is expensive. The Go runtime needs a middle layer that lets it multiplex many lightweight Gs onto a small pool of heavyweight Ms, while still controlling how many of them get to run in parallel. That middle layer is P.

The diagram shows four goroutines (Gs in cyan), two Ps (orange), and two Ms (teal). Each P holds a queue of runnable Gs. Each P is currently attached to one M, which is the OS thread that actually runs the Gs in P's queue. The bottom row is where real code executes; everything above it is the runtime's bookkeeping. To run any G, the runtime needs an M to do the executing and a P to schedule it. No P, no execution.

What Each Component Holds

Each of the three pieces carries different state.

A G is a goroutine, which the runtime tracks as a struct holding:

  • The function it's running and its arguments
  • A small stack (starts at 2 KB, grows as needed)
  • A program counter that says where in the function it is
  • A status (runnable, running, waiting, dead, and a few others)

When you write go fetchProduct(id), the runtime allocates a G, copies the call onto its stack, marks it runnable, and pushes it onto a queue. Done. The G is now waiting for an M to pick it up.

An M is an OS thread, which is exactly the same thing the kernel calls a thread. It has a kernel-managed stack, a thread ID, and the usual OS resources. Ms are expensive to create (system call to the kernel, memory allocation, register state) so Go tries to keep the M count low.

A P is the runtime's scheduling unit. It carries:

  • A local run queue of up to 256 runnable Gs
  • A reference to the M currently using it (or nil if it's idle)
  • A small cache of free Gs (so spawning a goroutine doesn't always hit the allocator)
  • Per-P state for the garbage collector

The local run queue is the most important field for understanding scheduling. Most of the time, when an M needs the next G to run, it pulls from the P's local queue. That's a memory access, not a system call, and there's no lock contention with other Ms because each P has its own queue.

How They Fit Together

The rule at the heart of the GMP model is this: to run a goroutine, an M must hold a P. An M without a P is a thread sitting around with nothing to schedule. A P without an M is a queue of work with no thread to execute it. Pair them up, and the M picks Gs off the P's local queue and runs them on the actual CPU.

The number of Ps controls how many goroutines can run in parallel. If there are 4 Ps and 100 Ms, at most 4 goroutines run at the same instant, because only 4 Ms can hold a P at once. The other 96 Ms are either parked or doing blocking work. The number of Ps defaults to GOMAXPROCS, which itself defaults to the number of CPU cores. The next chapter covers that knob in detail.

The number of Ms is more dynamic. The runtime creates Ms on demand and can have many more than there are Ps. A program with 4 Ps might have 50 Ms if 46 of them are blocked in system calls. The hard ceiling on Ms is set by runtime/debug.SetMaxThreads and defaults to 10,000, which is high enough that most programs never bump into it.

Here's a concrete picture. Suppose GOMAXPROCS is 4 and we've spawned 1000 goroutines fetching product details:

  • 1000 Gs exist, each one a 2 KB stack and a tiny struct
  • 4 Ps exist, each holding roughly 250 of the Gs in its local queue
  • 4 Ms are bound to the 4 Ps, each one chewing through its P's queue

If one of those goroutines makes a blocking syscall (say, reading from a file), the M that was running it detaches from its P. Another M (either an existing parked one or a freshly spawned one) attaches to that P and keeps running the remaining 999 goroutines. The blocked goroutine sits with its M until the syscall returns. We'll come back to this in detail in a moment.

runtime.NumCPU reports how many logical cores the OS sees. runtime.GOMAXPROCS(0) returns the current value without changing it; that's the number of Ps. runtime.NumGoroutine counts every G the runtime is tracking, including the one running main. On a fresh program with no goroutines spawned, this is 1.

Local Run Queues, Global Run Queue, and Work Stealing

Each P keeps a local run queue of runnable goroutines. The queue is a fixed-size circular buffer with room for 256 Gs. The local queue is the fast path: pushing and popping from it touches no shared state, so there's no lock to fight over with other Ps. Most of the scheduling traffic happens here.

When a P's local queue fills up (a goroutine spawns a flood of children and there's no room left), the overflow goes onto the global run queue. The global queue is a single FIFO shared across all Ps, protected by a mutex. It's slower than a local queue because every read or write needs the lock, but it serves two roles: it absorbs overflow, and it gives newly spawned goroutines a place to land when no local queue has space.

Periodically (every 61 scheduler ticks), each M takes a peek at the global queue between local pops, to keep global-queue Gs from starving. Otherwise, a flood of locally spawned work could keep global Gs waiting indefinitely.

Then there's work stealing, which is where the model gets interesting. Suppose P #1 finishes its local queue and the global queue is empty. P #1's M doesn't park yet; it tries to steal work from another P first. It picks a random other P (say P #2), looks at P #2's local queue, and grabs half of P #2's Gs. Now P #1 has work to do and P #2's queue is lighter.

The diagram shows four Ps. P #2's queue went empty, so its M looks around. It checks the global queue (empty in this snapshot), then picks P #3 at random and steals half of P #3's local Gs. P #2 is back in business without the runtime having to wake up new threads or shuffle work through the global queue.

Work stealing is what keeps all four Ps busy when the workload is uneven. If you spawn 1000 goroutines from a single function, they all land on the spawning P's local queue (and overflow into the global queue). Without stealing, only one P would have any work. With stealing, the other three Ps notice their queues are empty within a few microseconds and start pulling Gs over.

Cooperative Scheduling and Preemption

A goroutine doesn't get its own kernel timer. The runtime can't pause it the way the OS pauses a thread on a clock tick. Instead, Go's scheduler relied for years on cooperative scheduling: the running goroutine voluntarily hands control back to the scheduler at certain points, and the scheduler picks the next G to run.

The cooperative yield points are baked into the compiler. When you write a function call, the compiler inserts a tiny check at the start of the callee: "have I been asked to yield?" If yes, the goroutine saves its state, the M parks it, and the scheduler picks a new G off the P's queue. Function calls happen often enough that most programs yield frequently without doing anything special.

The trouble is that not every goroutine makes function calls. A tight numeric loop can run for minutes without ever calling a function. Before Go 1.14, that loop would hog its P forever, and other goroutines on the same P would be starved.

Go 1.14 added asynchronous preemption. The runtime now sends a SIGURG signal to a running M when its G has been on-CPU for too long (roughly 10 ms). The signal handler interrupts the goroutine at a safe point, saves its register state, parks it, and lets the scheduler pick a new G. The G that got preempted goes back on the P's local queue and gets a turn later.

Both workers complete because the runtime preempts them periodically and lets the other run. runtime.Gosched() in main is a manual yield: it tells the scheduler "I have nothing urgent; pick someone else." Before async preemption, this kind of program could deadlock on a single-P machine because neither worker would ever yield.

The cooperative yields still exist; async preemption is a backstop for the cases where they don't fire often enough. In practice, you can pretend the scheduler is fully preemptive: any goroutine that runs too long will get interrupted.

Blocking Syscalls and the M-P Handoff

The most important scheduler trick happens around blocking system calls. When a goroutine calls something that blocks the OS thread (reading from a file, calling a CGO function, waiting on a non-pollable file descriptor), Go can't just let the M sit there blocked, because that M is holding a P. If the M parked with the P attached, none of the other goroutines on that P's queue could run, and one of the four Ps would effectively be dead until the syscall returned.

So the runtime does a handoff. Right before the blocking syscall, the M releases its P. The P is now free. The runtime grabs another M (either an idle one or a newly spawned one) and attaches that M to the freed-up P. That second M starts running goroutines from the P's queue while the first M is stuck in the syscall.

When the syscall finally returns, the original M needs to get its goroutine moving again. But it doesn't have a P anymore. It tries to grab an idle P; if none exists, the M parks itself and the goroutine goes back onto the global queue, where some other P will pick it up later.

The diagram shows the two states. On the left, M holds P and is about to enter a syscall with one of its goroutines. On the right, M is blocked in the kernel with the goroutine, but P has been handed off to a new M (M2) that's now running other goroutines from the same queue. The P stays useful; only one G is paused.

This handoff is why a Go program can have hundreds or thousands of Ms even on a 4-core machine. Every blocking syscall creates a moment where an M is parked but a P is free, so the runtime spawns or wakes another M to keep the P busy. Once syscalls return, the parked Ms either grab a new P or go back into a pool of idle Ms.

There's one important nuance. Network I/O doesn't follow this pattern. Reads and writes on sockets go through the netpoller, which uses non-blocking syscalls (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows). When you write conn.Read(buf) on a socket, the runtime doesn't block the M. It registers the goroutine with the netpoller, parks just the goroutine, and the M keeps running other goroutines on the same P. When the OS signals that the socket is readable, the netpoller wakes the goroutine and puts it back on a P's queue. The M never blocks.

That's why a Go web server can handle tens of thousands of open connections with a handful of Ms. The blocking-syscall handoff handles the cases where the syscall really does need to block (disk I/O, CGO calls). The netpoller handles network I/O without the handoff at all.

Why Go Can Run Millions of Goroutines

The three components explain how Go can run millions of goroutines.

A goroutine is small. Each G struct is a couple hundred bytes, plus an initial 2 KB stack that grows on demand. A million goroutines is roughly 2 GB of memory if they all expand their stacks, but most goroutines hold only a few hundred bytes of stack at a time. Compare that to an OS thread, which starts with a 1 MB or 8 MB stack reserved by the kernel before the thread runs a single instruction.

The scheduler runs in user space. When a goroutine yields, the runtime saves a few registers and picks the next G off a local queue. No kernel involvement, no context switch into ring 0, no TLB flushing. A goroutine switch is closer to a function call than to a thread context switch. Estimates put the cost at roughly 200 nanoseconds, against several microseconds for an OS thread switch.

The number of OS threads stays small because Ps cap parallelism. With GOMAXPROCS=8, only 8 Ms can hold Ps at once. New Ms get spawned only when an M is blocked in a syscall, and they get recycled when the syscall returns. A program with a million goroutines but no blocking syscalls runs with around 8 Ms.

Network I/O bypasses thread blocking entirely. The netpoller turns "read from 50,000 sockets" into "wait on epoll for events on 50,000 file descriptors", which is one syscall for one M. Goroutines parked on network reads cost nothing but the G struct.

Work stealing keeps every P busy. Even if your code spawns work unevenly (one G that fans out 100,000 children), the other Ps notice within microseconds and start stealing. You don't have to think about distributing work across cores; the runtime does it for you.

The combination of all these properties is what makes Go's concurrency model practical. A million goroutines is a real number, not a marketing claim. Production Go programs commonly have thousands of goroutines running at peak.

The exact mid-flight number depends on how fast goroutines finish, but the point is that 100,000 of them coexist briefly without trouble. After wg.Wait returns, only main is left. The runtime cleaned up the G structs of every goroutine that exited. If you bumped total to a million, the program would still work, just with more memory pressure.

Observability and Debugging

The scheduler runs invisibly under your program. There's no log line that says "P #2 just stole 14 Gs from P #3". For most programs, that's fine; the abstraction works and you never have to think about it. For pathological cases (goroutine leaks, scheduler starvation, surprising latency spikes), Go provides a way to peek inside.

The environment variable GODEBUG=schedtrace=1000 makes the runtime print one line per second describing the state of every P: how many Gs are in each local queue, how many are in the global queue, how many Ms exist, and a few other stats. A second variable, GODEBUG=scheddetail=1, prints a much fuller dump including individual Gs. We won't dig into the output here. The takeaway for now is that the scheduler isn't a black box. If you ever need to see what it's doing, the tools exist.

Putting It All Together

Walking through one example ties the pieces together. Suppose your e-commerce backend gets a request to fetch 100 product detail records, each requiring one HTTP call to an internal pricing service. You spawn 100 goroutines and wait for them with a WaitGroup. With GOMAXPROCS=4, here's what happens:

  1. Each go fetch(productID) allocates a G and pushes it onto the spawning P's local run queue (or overflows to the global queue once the local fills up).
  2. The other three Ps notice their local queues are empty and start stealing. Within microseconds, all four Ps have roughly 25 Gs to chew through.
  3. Each fetch call opens a TCP connection, which the runtime registers with the netpoller. The goroutine parks; the M stays free and immediately picks the next G off its P's queue.
  4. As responses come back, the netpoller wakes the parked goroutines and puts them back on Ps' run queues. Each one decodes its response, calls wg.Done, and exits.
  5. Only 4 Ms are active most of the time. If one goroutine triggers a true blocking syscall (writing a debug log to disk), the M holding it detaches from its P, and a fresh M attaches so the P's queue keeps moving.

No pthread_create calls, no thread pool tuning, no manual work distribution. The runtime handles it, and your code reads as if you'd written 100 sequential function calls.

That's the model. Three components (G, M, P), one rule (M needs a P to run a G), and three mechanisms (local queues with work stealing, async preemption, and the blocking-syscall handoff with a netpoller exception for network I/O) handle everything from a single goroutine to a million.

Quiz

GMP Scheduler Quiz

10 quizzes