The Scheduler (GMP Model)

advanced go runtime

The Go scheduler is the part of the runtime that decides which goroutine runs on which OS thread, and when. It’s what lets us launch a million goroutines without melting our laptop.

In simple language: OS threads are expensive and few. Goroutines are cheap and many. The scheduler’s job is to squeeze all those goroutines through the small number of threads efficiently.

The three players: G, M, P

The model is named after three things:

  • G — Goroutine. The thing we create with go func(). It’s a lightweight task with its own tiny stack. Millions can exist.
  • M — Machine (OS thread). A real operating-system thread. The only thing that can actually execute code on a CPU. These are limited and pricey.
  • P — Processor (a logical context). A scheduling “slot.” A P holds a local run queue of goroutines waiting to run. The number of Ps equals GOMAXPROCS (default = number of CPU cores).

The key rule: to run a goroutine (G), an OS thread (M) must hold a processor (P). No P, no running.

In simple language: think of a P as a desk, an M as a worker, and Gs as a stack of tasks on the desk. A worker needs a desk to do tasks. The number of desks (Ps) is fixed at your CPU count, so you only ever do that many tasks truly in parallel.

P0 (desk)
M0 (thread) → running G3
local run queue: [G7] [G9] [G12]
P1 (desk)
M1 (thread) → running G5
local run queue: [ ] (empty → will STEAL)
GLOBAL run queue (shared overflow): [G20] [G21] ...
P1 is idle → it STEALS half of P0's queue → keeps every CPU busy

Work stealing — keeping CPUs busy

Each P has its own local queue, which is fast (no locking with others). But what if one P empties its queue while another is swamped?

The idle P steals work. It grabs roughly half the goroutines from a busy P’s queue (or pulls from the shared global queue).

In simple language: a worker who finishes early walks over and takes half the tasks off a busier desk, so no CPU sits idle while work is waiting. This is work stealing, and it’s why Go balances load so well automatically.

The clever part: blocking syscalls

Here’s where the GMP model really shines. Say a goroutine makes a blocking syscall — like reading a file. The OS thread (M) running it is now stuck, frozen, waiting on the kernel.

If that froze the whole P, all the other goroutines in its queue would be stuck too. Bad.

So the runtime does a handoff: it detaches the P from the blocked M and hands it to another M (a fresh or parked thread). That new M picks up the P and keeps running the queued goroutines.

In simple language: if a worker gets stuck on a phone call (the syscall), we don’t let the desk sit idle. We grab another worker, hand them the desk, and they keep plowing through the tasks. When the original call finishes, that worker rejoins later.

This is why thousands of goroutines doing blocking I/O don’t grind your program to a halt.

Cooperative + preemptive scheduling

Originally Go was purely cooperative — a goroutine only gave up the CPU at “safe points” like function calls, channel ops, or select. Problem: a tight loop with no function calls could hog a P forever and starve everyone.

Since Go 1.14, the scheduler adds asynchronous preemption. The runtime can interrupt a long-running goroutine (using a signal) and force it to yield, even mid-loop.

In simple language: before 1.14, goroutines had to volunteer to step aside. Now the runtime can tap one on the shoulder and say “your turn’s up.” This prevents one greedy goroutine from freezing everything (and also lets the GC pause goroutines reliably).

Why goroutines are so cheap

// Spinning up 100,000 goroutines is totally normal in Go.
for i := 0; i < 100000; i++ {
	go func(n int) {
		// each one starts with just ~8KB of stack
		doSomething(n)
	}(i)
}

Goroutines are cheap because:

  • Tiny stacks (~8KB) that grow on demand — vs ~1–2MB fixed per OS thread.
  • Scheduled in user space by the Go runtime — switching between goroutines doesn’t need a slow kernel context switch.
  • Multiplexed — millions of Gs ride on just GOMAXPROCS threads.

In simple language: creating an OS thread is like hiring a full-time employee. Creating a goroutine is like writing a sticky note. That’s the difference in cost.

Interview soundbite

Go’s scheduler uses the GMP model: G is a goroutine, M is an OS thread, and P is a logical processor (count = GOMAXPROCS) that holds a local run queue — and an M needs a P to run any goroutine. Idle Ps do work stealing to balance load, and when a goroutine makes a blocking syscall the runtime hands its P off to another M so the other goroutines keep running. Scheduling is mostly cooperative but, since Go 1.14, also asynchronously preemptive, and because goroutines have tiny growable stacks and switch in user space, we can run millions of them cheaply.