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.
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.
| Letter | Stands For | What It Is |
|---|---|---|
| G | Goroutine | A function call queued to run, plus its stack and state |
| M | Machine | An OS thread the kernel knows about |
| P | Processor | A 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.
Each of the three pieces carries different state.
A G is a goroutine, which the runtime tracks as a struct holding:
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:
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.
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:
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.
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.
Cost: A local queue pop costs roughly a memory access. A global queue read costs a mutex acquire. A successful steal costs an atomic swap on the victim P's queue tail. The runtime tries hard to keep work in local queues so most scheduling decisions hit the fast path.
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.
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.
Cost: Spawning a new M for syscall handoff costs a clone system call (a few microseconds) plus stack allocation. A program that does heavy file I/O can accumulate hundreds of Ms. A program that's all network I/O typically stays within a few Ms total.
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.
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.
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:
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).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.wg.Done, and exits.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.
10 quizzes