Go’s G-M-P Scheduler
Go’s G-M-P scheduler is the runtime system that decides which goroutines run on which operating-system threads. A G is a goroutine, an M is an OS-managed machine thread, and a P is a runtime processor: a scheduling resource that allows an M to execute Go code.
The important idea is that goroutines are not permanently assigned to threads. Go can run many Gs over a smaller or changing set of Ms, using Ps to coordinate access to CPU time.
The problem it solves
An operating system schedules threads, not goroutines. If every goroutine required its own OS thread, a program handling thousands of concurrent requests would pay for thousands of thread stacks, context switches, and kernel scheduling decisions. Threads are relatively expensive, and blocking one while waiting for a network response would waste a valuable execution resource.
Go instead makes goroutines lightweight. When a goroutine cannot make progress—for example, because it is waiting for network input, a channel operation, a timer, or a lock—the runtime can stop running it and schedule another runnable goroutine.
This is M:N scheduling: many goroutines are multiplexed onto a smaller or comparable number of OS threads. The runtime, rather than application code, performs most of that multiplexing.
What G, M, and P mean
- G (goroutine): The runtime’s representation of a goroutine, including its call stack, current instruction state, and status. A G may be running, runnable, waiting, or stopped.
- M (machine): An operating-system thread. The OS actually runs an M on a CPU core.
- P (processor): A runtime token representing the resources needed to execute Go code, including a local queue of runnable goroutines. The number of Ps is controlled by
GOMAXPROCS.
An M normally needs to hold a P before it can execute Go code. GOMAXPROCS therefore limits how many goroutines can execute Go code simultaneously, not how many goroutines can exist and not necessarily how many OS threads the process may create.
For example, a program can have tens of thousands of goroutines with GOMAXPROCS set to 8. At most roughly eight of them execute Go code at once, while others wait in queues or for external events. The runtime may still create more than eight Ms when some threads are blocked in system calls or are needed for other runtime work.
From a new goroutine to execution
When code starts a goroutine with the go statement, the runtime creates a G and marks it runnable—ready to execute, but not currently executing. The scheduler places it on a run queue.
Each P has a local run queue, a queue of runnable goroutines associated with that P. Local queues make the common case fast: the M currently holding a P can find more work without taking a global lock. A P also has a scheduling context that tracks the goroutine currently running and helps the runtime switch to another one.
There is also a global run queue. It acts as overflow and coordination space when local queues need balancing. A newly runnable goroutine does not always go directly to one fixed location; the runtime uses local queues, the global queue, and other scheduler mechanisms according to the situation.
A simplified execution cycle looks like this:
- An M acquires a P.
- The scheduler selects a runnable G, usually from that P’s local queue.
- The M runs the G until it blocks, yields, finishes, or is preempted.
- The scheduler chooses another runnable G, or looks elsewhere for work.
A goroutine that waits on a channel or network operation is removed from the runnable queues. When the operation becomes ready, the runtime marks it runnable again and puts it back into scheduling circulation.
Work stealing keeps Ps busy
Local queues are efficient, but they can become unbalanced. One P might receive a burst of newly runnable goroutines while another P runs out of work. If the idle P simply waited, the program could leave CPU capacity unused.
Go addresses this with work stealing. An idle P looks for runnable goroutines on another P’s local queue and moves some of that work to itself. It can also check the global run queue and cooperate with runtime mechanisms that detect newly available work.
Stealing is deliberately less common than taking work from a local queue, because inspecting another P requires coordination. But it improves utilization: runnable goroutines do not remain stranded behind a busy P while another P has nothing to do.
The runtime also periodically checks the global queue and other sources rather than allowing every P to operate in complete isolation. The result is a compromise between cheap local scheduling and enough sharing to balance load.
Blocking, preemption, and fairness
If an M enters a blocking system call, the runtime can detach its P and let another M use that P to run Go code. This prevents one blocked thread from reducing the program’s available Go execution capacity. When the system call returns, the original M may continue with its goroutine or wait for a P to become available.
Network operations usually follow a more efficient path. Go’s network poller waits for the operating system to report readiness, allowing the M that would otherwise wait on the network to run other goroutines.
A goroutine can also run for a long time without voluntarily blocking. Go therefore supports asynchronous preemption: the runtime can interrupt a running goroutine at suitable points and schedule another one. This improves fairness and prevents one CPU-bound goroutine from monopolizing a P.
You may have encountered the consequences in a stack trace showing goroutines in states such as waiting on a channel or a semaphore, or in configuration and profiling discussions involving GOMAXPROCS. Those details describe the scheduler’s queues and states, not separate application threads that you created.
Why this changes latency and throughput
For throughput, the scheduler keeps available Ps busy, moves work away from overloaded queues, and avoids tying up execution capacity on I/O waits. With enough independent runnable work, increasing GOMAXPROCS can allow more Go code to execute in parallel on multiple CPU cores.
For latency, scheduling is part of the time a request spends waiting. A newly ready goroutine may wait behind other runnable goroutines, and it may also pay for preemption, queue coordination, or contention for a lock. A large backlog can therefore increase tail latency even when the code in each request is fast.
More parallelism is not automatically better. If GOMAXPROCS exceeds the useful CPU capacity, extra runnable goroutines can compete for cores and shared locks, causing more scheduling overhead and cache disruption. If it is too low for the workload, runnable goroutines wait unnecessarily. The scheduler provides the machinery; application behavior—blocking frequency, CPU work, lock contention, and queue depth—determines how that machinery appears in performance measurements.
In short, Gs provide cheap concurrency, Ms provide the OS execution threads, and Ps organize access to CPU time. Local run queues make scheduling fast, work stealing balances those queues, and the resulting choices directly shape both how much work a Go service completes and how long an individual operation waits.