Go GMP Scheduler¶
G (Goroutine): 2KB starting stack, growable. ~1µs to create. Cheap enough to create one per connection.
M (Machine): OS thread. Executes G's. Can exceed GOMAXPROCS when M's are blocked on syscalls.
P (Processor): logical processor, local run queue (256 capacity). Exactly GOMAXPROCS P's (default = CPU cores). M must hold P to run Go code. P holds mcache.
Scheduling loop: pop G from local queue → run until block/yield → repeat. Empty queue: check global queue → steal half from another P (work stealing).
Work stealing: P steals half of another P's queue when idle. Automatic load balancing without central coordinator.
Blocking syscall: M blocked on syscall detaches P → another M (created if needed) acquires P → other goroutines continue. Syscall completes → M tries to reacquire P; if none available, G goes to global queue, M goes idle.
Network I/O: goroutine parked, fd registered with epoll, M runs other goroutines, goroutine unparked when data ready. No OS thread blocked.
Preemption (Go 1.14+): SIGURG sent to M after 10ms → goroutine preempted asynchronously at safe points. Prevents CPU-bound goroutines from starving others.
Common pitfall¶
A single goroutine stuck in a tight loop with no function calls at
all (pure arithmetic, no channel ops, no allocations) was
unpreemptible before Go 1.14's async preemption — and even after,
async preemption has edge cases (certain tight assembly loops) where
it still can't inject a preemption point. In practice this means a
genuinely pathological CPU-bound loop can still starve other
goroutines on the same P longer than expected; the fix is the same as
always for CPU-bound work — hand it to a dedicated worker pool sized
to GOMAXPROCS, not an unbounded number of concurrent goroutines
competing for the same limited P's.