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
GOMAXPROCSPs.
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
sysmonbackground 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.