The scheduler: G, M and P

Go · Expert · 15 min

Understanding the scheduler explains goroutine costs, GOMAXPROCS tuning, and odd latency or CPU behaviour.

The runtime scheduler juggles three kinds of objects:

  • G — a goroutine: its stack, instruction pointer and status.
  • M — a machine, i.e. an OS thread that executes code.
  • P — a processor: the right to run Go code, plus a local run queue of runnable Gs. There are exactly GOMAXPROCS Ps.

An M must hold a P to run Go code, so GOMAXPROCS caps how many goroutines execute Go code simultaneously. It defaults to the number of CPUs; since Go 1.25 it also respects a container's cgroup CPU limit, which older versions ignored (hence the popular automaxprocs package).

Scheduling flow: go f() puts the new G in the current P's local queue (in a runnext slot, so it tends to run soon). When a P's queue empties it checks the global queue, the network poller, and then steals half of another P's queue. This keeps all cores busy without a central lock.

Blocking is handled differently by cause:

  • Channel, mutex or sleep: the G is parked and the M simply runs another G — cheap.
  • Network I/O: the G parks on the netpoller (epoll/kqueue/IOCP) and is made runnable when the socket is ready, so thousands of connections need only a few threads.
  • Blocking syscalls and cgo calls: the M is stuck, so the P is handed off to another M (the sysmon background thread retakes it if the call takes too long). This is why heavy blocking file I/O or cgo can create many threads.

Since Go 1.14 preemption is asynchronous: sysmon signals a goroutine that has run for about 10ms, so tight loops can no longer starve others or stall GC. GODEBUG=schedtrace=1000 prints scheduler state every second, and go tool trace visualises it.

Example

package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
)

func main() {
	fmt.Println("CPUs:", runtime.NumCPU(), "Ps:", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup
	var done atomic.Int64
	for range 10_000 { // 10k goroutines, a handful of threads
		wg.Add(1)
		go func() {
			defer wg.Done()
			runtime.Gosched() // yield the P to another runnable G
			done.Add(1)
		}()
	}
	fmt.Println("goroutines now:", runtime.NumGoroutine() > 1)
	wg.Wait()
	fmt.Println("finished:", done.Load()) // finished: 10000
}

Remember

  • G = goroutine, M = OS thread, P = execution slot; GOMAXPROCS Ps
  • Idle Ps steal work; network I/O parks on the netpoller, not a thread
  • Blocking syscalls/cgo pin an M; preemption is asynchronous since 1.14

Watch out for

  • Setting GOMAXPROCS far above the container's CPU quota on old Go versions, causing throttling and latency spikes.

Check yourself

  1. A goroutine blocks reading from a TCP socket. What happens to its OS thread (M)?
  2. What does GOMAXPROCS limit?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pkg.go.dev/runtime

Builds on

Unlocks

Nothing builds on this yet.