The Go Scheduler’s G-M-P Model
The Go scheduler’s G-M-P model is the runtime’s way of executing many goroutines across a smaller or changing set of OS threads. G means goroutine, M means machine (an OS thread), and P means processor: a runtime resource required for an M to execute Go code.
The model exists to make blocking, concurrent programs efficient. Without it, every concurrent task would need its own OS thread, and a task waiting for a channel, timer, network response, or lock could waste an entire thread while doing nothing.
A goroutine can instead be paused, or parked, while the runtime lets another goroutine use the available execution capacity.
The three parts
A goroutine (G) is Go’s lightweight unit of execution. It has its own stack and saved execution state, but starts with much less overhead than an OS thread. A program may have thousands or millions of goroutines.
An M is an operating-system thread. The OS schedules Ms onto CPU cores, and the Go runtime creates, reuses, and parks Ms as needed.
A P is the permission and runtime context needed for an M to run Go code. Its count is normally controlled by GOMAXPROCS; that value limits how many goroutines can execute Go code simultaneously. A P is not a CPU core, although its count is often chosen to match the useful amount of CPU parallelism.
For ordinary execution, the relationship is:
A G runs on an M, and that M must hold a P.
An M without a P cannot run Go code. A P without an M can be picked up by another available or newly created M.
Run queues keep work ready
Each P has a local run queue, a list of runnable goroutines associated with that P. “Runnable” means a goroutine is ready to execute but is not currently running. When a goroutine creates another goroutine, the new G is usually placed near the creating P’s local work, making it cheap to find.
There is also a global run queue shared by the runtime. It handles work that should not remain tied to one P, including some newly created goroutines and work moved there when local queues need balancing.
The scheduler generally looks for work in stages: the current P’s local queue, the global queue, activity returned by the network poller, and finally work owned by another P. The exact checks and frequency vary by runtime version, but the goal is consistent: find runnable goroutines without repeatedly asking every P or constantly entering global synchronization.
Parking a goroutine
A goroutine parks when it cannot make useful progress. Common examples include:
- receiving from an empty channel
- sending to a full channel
- waiting for a lock
- sleeping until a timer expires
- waiting for network input
Parking saves the goroutine’s state and marks it as waiting. The scheduler then selects another runnable G for the same P. This is the central distinction between a blocked goroutine and a blocked OS thread: the former gives the runtime a chance to keep the thread productive.
For example, in this program the receiving goroutine can be parked while the sender is elsewhere:
messages := make(chan string)
go func() {
messages <- "ready"
}()
message := <-messages
println(message)
The receive does not require the program to burn CPU while waiting. When the send and receive can pair, the runtime makes the waiting goroutine runnable again, often transferring the value directly as part of that hand-off.
Parking a G is different from parking an M. If no runnable goroutines are available, the runtime may park the OS thread too, usually using an OS-assisted sleep mechanism. That avoids consuming a CPU while the program is idle. When work appears, the runtime can wake a parked M or start another one.
Wake-ups make waiting work runnable
An event that satisfies a goroutine’s wait changes its state back to runnable. For a channel operation, that event is a matching send or receive. For a timer, it is the timer becoming ready. For network I/O, it is notification from the runtime’s netpoller, the component that waits for operating-system network readiness without dedicating one thread to each connection.
The awakened G is placed on a run queue. The runtime tries to put it where it can be found promptly, often near the P associated with the event, while avoiding unnecessary contention. A wake-up does not necessarily mean “run this goroutine immediately”; it means “this goroutine is eligible to run.” Other runnable work and scheduling fairness still matter.
The same machinery explains why a goroutine blocked in a system call does not necessarily stop all Go execution. If an M enters a blocking call, the runtime can detach its P and let another M take that P and continue running goroutines. When the original call returns, its M must obtain a P again before resuming Go code.
Work stealing balances the Ps
A P can become idle even while another P has a long local queue. To correct that imbalance, an idle P can perform work stealing: it takes a portion of runnable goroutines from another P’s local queue rather than waiting for new work to be created.
Stealing is deliberately coarse-grained. Moving every goroutine individually would add too much synchronization overhead, so the idle scheduler typically takes a batch or roughly part of a victim queue. This lets several Ps make progress while preserving the performance benefit of mostly local queues.
The runtime also uses spinning briefly before putting an M to sleep. A spinning M has no current G to run but looks for newly available work, because finding work immediately is cheaper than parking and later waking an OS thread. If the system remains idle, it stops spinning and parks.
What this looks like in practice
Runtime diagnostics often expose these states. A stack dump may show a goroutine as [chan receive], [semacquire], or waiting on a network operation; those descriptions indicate why the G is parked, not that an entire OS thread must be stuck. Scheduler tracing through GODEBUG settings can show counts of threads, Ps, and runnable goroutines.
In short, G-M-P separates what needs to run from the OS threads that execute it and from the execution capacity available at once. Parking removes waiting goroutines from active work, wake-ups return them when progress is possible, and work stealing prevents that work from being stranded on one P. Together, these mechanisms keep runnable goroutines moving without requiring one permanently dedicated OS thread per goroutine.