Scheduler Model with Diagrams
Scheduler Model with Diagrams
Overview
A teaching model of how Go runs many goroutines on few threads—enough to reason about blocking, GOMAXPROCS, and syscalls. For production-depth GMP, also read Scheduler deep dive.
Three layers
Hardware — cores run instructions in parallel
instr A instr B instr C instr D
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Core 1 │ │ Core 2 │ │ Core 3 │ │ Core 4 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
OS — many threads share cores (OS scheduler)
┌──────────┐ ┌──────────┐
│ Thread E │ │ Thread F │
└──────────┘ └──────────┘
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Thread A │ │ Thread B │ │ Thread C │ │ Thread D │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Go — many goroutines share threads (Go scheduler)
┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐
│ G15 ││ G16 ││ G17 ││ G18 ││ G19 ││ G20 │ runnable queue
└─────┘└─────┘└─────┘└─────┘└─────┘└─────┘
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ G11 │ │ G12 │ │ G13 │ │ G14 │ running
└─────┘ └─────┘ └─────┘ └─────┘
│ │ │ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Thread A │ │ Thread B │ │ Thread C │ │ Thread D │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Go does not assign threads to cores; the OS does.
Simplified scheduler loop
1. Queue all runnable Gs
2. Run up to N of them on worker threads (N ≈ GOMAXPROCS)
3. If a G blocks (channel, mutex, park) → run another
4. Preempt long runners so others don't starve
cores run threads; threads run Gs
GOMAXPROCS ≈ max Ps running Go code
block (chan/IO) → park G → run another
Starvation and preemption
If G11–G14 never block, G15+ could starve. The scheduler preempts running goroutines so others get CPU (async preemption in modern Go improves tight loops).
System calls
While a G is in a blocking syscall, its OS thread may be stuck. Runtime can detach scheduling capacity and run other Gs on additional threads, then collapse extras later.
Before: 4 threads run Go code
G11,G12 enter syscalls
Runtime: Thread E,F run G15,G16
G11,G12 return → rebalance
GOMAXPROCS
Maximum OS threads running Go code simultaneously (excluding those stuck in syscalls).
runtime.GOMAXPROCS(0) // get
runtime.GOMAXPROCS(1) // set
// Go 1.25+: runtime.SetDefaultGOMAXPROCS()Default ≈ logical CPUs (with cgroup awareness improving in recent versions).
GOMAXPROCS=1 go run .
docker run --cpus=4 ... # verify GOMAXPROCS vs hostregions: Host 16 CPUs | Pod cpus=2
flow:
[Go app]
|
v
[Q]
Wrong pairing (tiny quota, huge GOMAXPROCS) → thrashing.
Concurrency primitives vs scheduler
| Primitive | Scheduler view |
|---|---|
| channel send/recv | may park G; wake on peer |
| mutex | park on contention |
| sleep / timer | park until timer heap fires |
| syscall | may occupy M longer |
Metrics, profiles, traces
| Tool | Shows |
|---|---|
runtime.NumGoroutine |
live Gs |
| CPU pprof | on-CPU stacks |
| mutex/block profile | contention |
go tool trace |
STW, park, proc start/stop |
GODEBUG=schedtrace=1000 |
scheduler trace lines |
Runnable experiment
GOMAXPROCS=1 go run .
GOMAXPROCS=4 go run .package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func burn(d time.Duration) {
end := time.Now().Add(d)
for time.Now().Before(end) {
}
}
func main() {
fmt.Println("GOMAXPROCS", runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 4; i++ {
wg.Go(func() { burn(200 * time.Millisecond) })
}
wg.Wait()
fmt.Println("elapsed", time.Since(start))
}What to notice: With GOMAXPROCS=1, ~800ms; with 4, ~200ms for CPU-bound burns.
Try next: Open go tool trace on a server under load and find runnable Gs waiting while a syscall holds threads.
Keep going
You now have the full ground-up path: primitives → sync → atomics → tests → scheduler model. Production depth continues in part 20 Go Deep Dives and part 07 applied patterns.