Go’s G-M-P Scheduler Explained
Go’s G-M-P scheduler is the runtime mechanism that runs many lightweight goroutines on a smaller or changing set of operating-system threads. G means goroutine, M means machine thread, and P means processor: a runtime resource that allows an M to execute Go code.
The important relationship is:
A goroutine runs on an M, but an M needs a P to run Go code.
A P is not necessarily a hardware CPU core. Its count is usually controlled by GOMAXPROCS, which limits how many goroutines can execute Go code simultaneously. The scheduler moves goroutines among Ps and Ms as they become runnable, block, or finish.
The problem the scheduler solves
A goroutine might spend most of its life waiting: for a channel value, a mutex, a timer, network data, or a system call. If every unit of concurrent work required a dedicated OS thread, an application handling thousands of requests could create thousands of threads. Threads are relatively expensive, consume stack and runtime resources, and switching among them requires the operating system.
A simpler thread pool has the opposite problem. If a worker thread blocks in a system call, it cannot process another task until the call returns. Enough blocked workers can leave runnable work waiting even though the machine has capacity.
Go’s runtime instead multiplexes goroutines over OS threads. A goroutine that waits is removed from execution, allowing another goroutine to use the available scheduling capacity.
func handle() {
go fetchFromDatabase()
go writeAuditRecord()
}
The go statement makes each function call a goroutine. It does not create a permanently assigned thread. The scheduler decides when and where those goroutines run.
G, M, and P
A G is the runtime’s record for a goroutine. It includes the goroutine’s stack, its current execution state, and bookkeeping such as where it should resume. A G can be runnable, meaning it is ready to execute, or waiting for an event such as a channel operation or timer.
An M is an operating-system thread. It executes machine instructions, but it cannot execute Go code merely because it exists. It must be associated with a P.
A P is the scheduler’s execution context. It holds resources used by running Go code, including a local queue of runnable goroutines. The number of Ps is the runtime’s limit on simultaneous Go execution. With GOMAXPROCS set to 4, at most four Ps can be actively running Go code at once, even if the process has more OS threads and the host has more cores.
The usual running arrangement is therefore G + M + P: a goroutine is executing on an OS thread while that thread owns a P. The M and P are separate things. A P can be handed to a different M, and a goroutine can stop using one M and later resume on another.
How a runnable goroutine gets CPU time
When a goroutine becomes runnable, the runtime places it in a scheduler queue. Each P has a local run queue, which is the first place its M normally looks. There is also a global run queue for work that is not currently owned by one P.
An M with a P repeatedly looks for runnable work, runs a G for a while, and then returns to the scheduler. A G may yield because it performs a blocking operation, waits on synchronization, finishes, or is preempted. Preemption means the runtime temporarily stops a running goroutine so another runnable goroutine gets a chance to run; this prevents a goroutine that does not voluntarily yield from monopolizing a P indefinitely.
The scheduler’s decisions are implementation details, not a promise that a goroutine will run on a particular thread. This matters when reading logs: a goroutine ID, when available in diagnostic output, is not a thread ID, and thread-local assumptions are generally unsafe in Go code.
What happens when a goroutine blocks
Different kinds of blocking have different consequences.
If a goroutine waits on a channel, mutex, timer, or similar runtime-managed event, the runtime marks that G as waiting and runs another runnable G on the same P. When the event occurs, the waiting G becomes runnable again and is placed back into a queue.
A system call is more difficult because the OS thread itself may stop running until the operating system returns. When an M enters a blocking system call, the runtime can detach its P from that M. The P is then available for another M to run other goroutines. When the system call finishes, the original M may reacquire a P if one is available; otherwise it waits or exits according to the runtime’s scheduling decisions.
This handoff is why one blocked file or network operation does not necessarily freeze all goroutines assigned to that P. Network operations commonly use Go’s network poller, a runtime component that waits for socket readiness and wakes goroutines without dedicating one thread to every connection.
Foreign calls, including some cgo calls, can also require extra Ms because the runtime cannot always control what happens inside the call. The result is still the same goal: preserve runnable Go execution when one thread is stuck elsewhere.
Work stealing keeps Ps busy
Suppose one P’s local queue is empty while another P has many runnable goroutines. Leaving the first P idle would waste available execution capacity. Go’s scheduler uses work stealing: an idle P looks at another P’s queue and takes some of its runnable goroutines, commonly about half of the available batch.
The idle P does not need to wait for the busy P to finish. It can run the stolen goroutines on its own M, or an M can be started or awakened to own that P. The scheduler also checks the global run queue and the network poller when searching for work.
Work stealing balances bursts without requiring every newly runnable goroutine to be coordinated through one central queue. Local queues reduce contention, while stealing provides a way to recover balance when workload distribution becomes uneven.
The next time an incident mentions GOMAXPROCS, a blocked syscall, a large goroutine count, or scheduler traces showing idle and busy threads, the G-M-P model provides the map: G is the work, M is the OS thread carrying it, and P is the runtime capacity that lets that thread execute it. The scheduler continually rearranges those relationships so waiting work does not unnecessarily stop runnable work.