Scheduler Model with Diagrams

Updated

September 8, 2026

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 host
regions: 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.