The idea: many goroutines, a few threads

A Go program can have a million goroutines, but the machine has a handful of cores and an OS thread costs megabytes of stack and a kernel context switch. So the Go runtime runs its own scheduler inside the process. It multiplexes many goroutines onto a few OS threads, and it switches between goroutines in user space, without asking the kernel. This is called M:N scheduling.

Two Ps, each with a runnext slot and a local run queue, each attached to an OS thread M that runs one goroutine G on a CPU core. Above them one global run queue and the netpoller. A third thread M2 is blocked in a system call without a P
GOMAXPROCS Ps run goroutines on OS threads; a thread stuck in a system call gives its P away.

The canvas shows the shared parts on top (global run queue, netpoller, sysmon, a channel), the processors P0 and P1 with their queues and the code of the goroutine they run, the OS threads, and every goroutine's state. Pick a program (it loads at once), then press Tick (one millisecond) or Run to End; Restart runs it again.

G, M and P

what it ishow many
G goroutinea function's stack (starts at 2 KB, grows) and saved registersas many as you start
M machinean OS thread; only an M can execute codegrows when threads block, up to 10 000
P processora license to run Go code, with a local run queue and cachesGOMAXPROCS, by default the number of cores

An M must hold a P to run Go code. So at most GOMAXPROCS goroutines run at once, while there can be more Ms: threads blocked in system calls do not hold a P.

Run queues and the go statement

Each P has a local run queue (a ring of 256 goroutines) and one runnext slot. One global run queue is shared by all Ps and needs a lock. go f() creates a G and puts it in the current P's runnext, pushing whatever was there to the tail of the local queue. The newest goroutine therefore runs next, which suits the common pattern "start a goroutine, then wait for it" (Demo 1: G1 starts G2, G3, G4; G4 runs first, then G2, G3). If the local queue is full, half of it moves to the global queue.

schedule() and work stealing

When a goroutine stops (it returns, blocks or is preempted), the P calls schedule(), which looks for the next goroutine in this order:

The order schedule looks in: every 61st call the global run queue first, then runnext, the local run queue, the global run queue, the netpoller, and last stealing half of another P's local run queue
Local work first; the global queue every 61st time for fairness; stealing only when everything else is empty.

Taking from its own queue needs no lock, so that is the fast path. The 61 check makes sure a goroutine in the global queue is not starved by a P whose local queue never empties (Demo 7). Work stealing keeps all Ps busy: an idle P takes half of a random other P's local queue (Demo 2: P1 steals from P0). The same idea is in Java's ForkJoinPool.

Blocking system calls: handing off the P

A system call such as read() on a file blocks the whole OS thread in the kernel. The runtime cannot stop that. What it can do is not waste the P. On entersyscall the P stays in state _Psyscall. The background thread sysmon notices a P stuck there (after 20 µs in real Go, one tick here) and hands it off to another M, a spare one or a new thread. Other goroutines keep running (Demo 3).

Before: M0 holds P0 and is blocked in read with G1, while G2 and G3 wait in P0's local run queue. After handoff: M0 is still blocked with G1 but has no P; M1 holds P0 and runs G2. When read returns, G1 goes to the global run queue and M0 parks
The blocked thread keeps its goroutine but loses its P, so the goroutines behind it keep running.

When the call returns, the goroutine needs a P again: its old P if nobody took it (fast path), else any idle P, else it goes to the global run queue and the thread parks. This is why a Go program may have many more threads than GOMAXPROCS: one per goroutine blocked in a system call.

The netpoller: network I/O without blocking a thread

Sockets are different. Go sets them non-blocking and registers them with epoll (kqueue on macOS, IOCP on Windows). conn.Read() with no data parks the goroutine on the netpoller and the M runs something else. When data arrives, the scheduler or sysmon calls netpoll, which returns the ready goroutines to the run queues (Demo 4). Your code looks blocking, but no thread waits: this is how one Go server handles 100 000 connections.

Channels

Channel operations never involve the OS. A receiver with nothing to receive parks in the channel's recvq. A sender that finds a waiting receiver copies the value straight to it and puts it in its own P's runnext, so the receiver runs right after (Demo 5). Parking and waking a goroutine is a few hundred nanoseconds, compared with microseconds for a thread context switch.

Preemption and sysmon

Goroutines are preempted at function calls when the scheduler asks them to. A tight loop without calls would never stop, so since Go 1.14 sysmon sends the thread a signal (SIGURG) when a goroutine has run more than 10 ms. The handler stops the goroutine at a safe point and puts it on the global run queue (Demo 6). sysmon runs on its own thread without a P, waking every 20 µs to 10 ms.

Compared with Java threads and the OS scheduler

Go goroutineJava platform thread
scheduled bythe Go runtime, in user spacethe OS kernel
stack2 KB, growsabout 1 MB, fixed
switchabout 100–200 ns1–10 µs, with a kernel trap
blocking network readparks on the netpoller; the thread keeps workingblocks the thread

See how a Java thread reaches a CPU core for the one-to-one model, and CPU scheduling for how the kernel then shares the cores between the Ms. Java 21's virtual threads copy the Go idea.

What a goroutine is in memory, and what gopark, mcall and gogo do to the registers during a switch, is on Inside a Goroutine. Goroutines are stackful coroutines; see stackful vs stackless coroutines for how they compare with the state-machine coroutines of Python, JavaScript and Kotlin.

See also OS threads vs goroutines, side by side: the same tasks run as kernel threads and as goroutines, comparing stack memory, switch cost and blocking calls.

What the page leaves out

Real time (a tick is 1 ms of work here; real switches take nanoseconds), more than two Ps, spinning-thread limits and how Ms are woken (wakep), the batch taken from the global queue, random steal order and stealing runnext, timers, garbage collection workers, the cooperative preemption check in function prologues, locked threads (LockOSThread), cgo calls, and buffered channels.