How Go's Scheduler Works â Understanding the G, M, P Model Visually
An interactive visual guide to Go's runtime scheduler and the G (Goroutine), M (Machine/OS Thread), P (Processor) model.
The Go runtime scheduler powers Go's concurrency model using the G-M-P architecture. This article dives deep into the runtime internals while using an interactive visualization to make the scheduling mechanics tangible.
G-M-P Model Overview
Internal Structure of G, M, P
G (Goroutine) â runtime.g struct
A goroutine is represented by the runtime.g struct defined in runtime/runtime2.go:
type g struct {
stack stack // lo, hi â current stack bounds
stackguard0 uintptr // used for preemption checks
m *m // M currently running this G
sched gobuf // saved context for context switch
atomicstatus atomic.Uint32 // G's state flag
goid uint64 // goroutine ID
// ... many more fields
}Key fields:
stackâ Each G starts with a 2KB stack (compare to 1â8MB for OS threads). It grows automatically viaruntime.morestack()as needed.sched(gobuf) â Stores PC (Program Counter), SP (Stack Pointer), and BP (Base Pointer) for context switches. Read/written every time a G is suspended or resumed.stackguard0â Normally set near the stack lower bound. Set tostackPreempt(0xfffffade) when preemption is requested.atomicstatusâ The G lifecycle:_Gidleâ_Grunnableâ_Grunningâ_Gsyscall/_Gwaitingâ_Gdead
M (Machine) â runtime.m struct
An M is a 1:1 wrapper around an OS thread (runtime/runtime2.go):
type m struct {
g0 *g // scheduling goroutine (large stack)
curg *g // currently running user G
p *p // attached P
nextp *p // P to attach on wakeup
spinning bool // looking for work to steal
// ...
}g0â A special goroutine that exists on every M. Runtime functions likeschedule(),findRunnable(), and GC code run on g0's stack.mcall()switches from a user G to g0.curgâ The user goroutine currently running on this M. Set byexecute(), cleared ongoexit()or preemption.spinningâ Indicates the M is busy-looping looking for work to steal. The runtime limits the number of spinning M's to avoid wasting CPU.
P (Processor) â runtime.p struct
A P represents "the right to execute user Go code" â a logical resource (runtime/runtime2.go):
type p struct {
status uint32 // _Pidle, _Prunning, _Psyscall, _Pgcstop, _Pdead
runqhead uint32 // local queue head index
runqtail uint32 // local queue tail index
runq [256]guintptr // local run queue (ring buffer)
runnext guintptr // next G to run (higher priority than queue)
gFree struct { ... } // free list of reusable G's
// ...
}runqâ A 256-entry fixed-size ring buffer. Lock-free access (only the owning P advances head; steal uses atomic CAS on the tail side).runnextâ A 1-slot cache for the most recently created G, ensuring it runs next for better locality.- The number of P's is set by
GOMAXPROCSand remains fixed during execution.
The schedule() Loop in Detail
All scheduling decisions happen in runtime.schedule() (runtime/proc.go). Each M runs this loop on its g0 stack:
func schedule() {
// Fairness: check global queue every 61st tick
if schedtick%61 == 0 {
G = globrunqget(P) // fetch from global queue
}
if G == nil {
G = runqget(P) // â runnext â ⥠local queue
}
if G == nil {
G = findRunnable(P) // âĒ blocking search
// â globrunqget â netpoll â steal from random P
}
execute(G) // restore gobuf â run user code
}findRunnable() search order:
- runnext â 1-slot cache
- Local queue â
runqget()from ring buffer - Global queue â
globrunqget()fetchesmin(len/GOMAXPROCS+1, len/2)items in a batch - Netpoller â
netpoll()harvests I/O-completed G's - Work Stealing â
runqsteal()steals half of a random P's queue
See the Scheduler in Action
The visualization below walks through the internal mechanics step by step. Each step references the actual runtime functions involved.
Go Scheduler: G, M, P in Action
Step through how Go's runtime scheduler maps goroutines (G) onto OS threads (M) via processors (P).
Preemption Internals
Cooperative Preemption (Go 1.1+)
The Go compiler inserts a stack check prologue at the beginning of every function:
// Function prologue (pseudocode)
MOV AX, [G.stackguard0]
CMP AX, SP
JBE morestack // stack growth or preemptionWhen sysmon's retake() detects a G running for >10ms, it sets stackguard0 to stackPreempt (0xfffffade). At the next function call, the prologue detects this value and calls morestack() â gopreempt_m(), which sets the G back to _Grunnable.
Problem: Tight loops like for {} with no function calls can't be preempted.
Asynchronous Preemption (Go 1.14+)
Go 1.14 introduced signal-based asynchronous preemption:
- sysmon calls
preemptone() - Sends
SIGURGsignal to the target M - Signal handler
doSigPreempt()saves the current PC/SP - An asyncPreempt frame is injected onto the G's stack
- G is suspended and control returns to
schedule()
// runtime/signal_unix.go â https://github.com/golang/go/blob/master/src/runtime/signal_unix.go
func doSigPreempt(gp *g, ctxt *sigctxt) {
if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), ctxt.rip())
}
}isAsyncSafePoint() only allows preemption at points where GC can accurately enumerate the root set.
sysmon â The Watchdog Daemon
sysmon is a special goroutine that runs on its own OS thread, not bound to any M-P pair (runtime/proc.go):
func sysmon() {
for {
usleep(delay) // 20Ξs to 10ms (adaptive)
// 1. Harvest netpoller results
netpoll(0)
// 2. Hand off P's stuck in long syscalls
retake(now)
// 3. Preempt long-running G's
// 4. Signal for GC STW if needed
// 5. Force GC if none in 2+ minutes
}
}retake() Decision Logic
func retake(now int64) uint32 {
for _, pp := range allp {
s := pp.status
if s == _Prunning {
// Running >10ms â preemptone()
if pd.schedwhen + 10ms < now {
preemptone(pp)
}
} else if s == _Psyscall {
// In syscall >20Ξs & there's other work â handoffp()
if runqempty(pp) && sched.nmspinning + sched.npidle > 0 {
continue // don't hand off yet
}
handoffp(pp) // detach P from M
}
}
}Netpoller â Async I/O Integration
All network I/O in Go (net.Conn.Read(), etc.) is made asynchronous through the netpoller:
- G starts network I/O â
runtime.pollWait()callsgopark()â G becomes_Gwaiting - The fd is registered with epoll/kqueue/IOCP
findRunnable()andsysmonperiodically callnetpoll()- I/O-completed G's are injected back into run queues via
injectglist()
// Internal flow of network I/O
conn.Read(buf)
â internal/poll.FD.Read()
â runtime.pollWait()
â gopark(netpollblockcommit) // G â _Gwaiting
// ... I/O completion detected via epoll_wait ...
runtime.netpoll()
â G â _Grunnable
â injectglist() into run queuesUnlike syscalls (read(2) etc.), the netpoller does not block the M. This is why net/http servers can handle thousands of connections with very few OS threads.
G Lifecycle â State Transitions
_Gidle â _Gdead â _Grunnable â _Grunning â _Gdead
â â
â â preempt â â
â â
â _Gwaiting â (chan/mutex/IO)
â â
â _Gsyscall â (syscall)_Grunnableâ Ready to run but not yet assigned to an M_Grunningâ Currently executing on an M_Gwaitingâ Blocked on channel receive, mutex, I/O, etc._Gsyscallâ In a syscall (M is also blocked)_Gdeadâ Completed. The struct is pooled ingFreefor reuse.
Context Switch Cost
Go's context switches are far lighter than OS thread switches:
| Go goroutine | OS thread | |
|---|---|---|
| What's switched | PC, SP, BP, a few registers (gobuf) | All registers + page tables + TLB flush |
| Cost | ~100â200ns | ~1â10Ξs |
| Stack size | Initial 2KB (grows dynamically) | Fixed 1â8MB |
| Creation cost | runtime.newproc â 300ns | pthread_create â 10â30Ξs |
The gobuf structure:
type gobuf struct {
sp uintptr // stack pointer
pc uintptr // program counter
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr // link register (ARM)
bp uintptr // base pointer (frame pointer)
}GOMAXPROCS vs Actual Thread Count
The number of P's is fixed by GOMAXPROCS, but actual M's (OS threads) can exceed that:
- M's blocked in syscalls release their P, and new M's are created
- Default max M count is 10,000 (
runtime/debug.SetMaxThreads) - Idle M's are not destroyed immediately â they're pooled in
midle
runtime.GOMAXPROCS(4) // P = 4
// But M count can be > 4
// e.g., 4 P's + 10 M's blocked in syscalls = 14 total M'sSummary
Go's G-M-P scheduler achieves high-efficiency concurrency through:
- M:N scheduling â Multiplexes many G's onto few M's
- Lock-free local queues â 256-entry ring buffers per P, no contention
- Work Stealing â Idle P's steal from busy P's for load balancing
- Hand-off â P's don't stall when M's block on syscalls
- Netpoller â Network I/O via epoll/kqueue without blocking M's
- Cooperative + async preemption â Function prologues + SIGURG for fair CPU time
- sysmon â Independent watchdog detecting long syscalls and runaway G's