Problem Statement
In a Principal Engineer interview at Google, you're asked: "Explain how Go can run millions of goroutines on a few OS threads. What is the GMP model and how does work stealing improve performance?"
The GMP Model
- G (Goroutine): User-space thread, starts at 2KB stack, grows dynamically
- M (Machine): OS thread, executes goroutines
- P (Processor): Logical processor, holds the run queue
Visual Representation
┌─────────────────────────────────────────────────────────┐
│ Go Runtime │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ G │ │ G │ │ G │ │ G │ Global Run Q │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │
│ ┌──▼───────▼───────▼───────▼──┐ │
│ │ P (Processor) │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ Local Run Queue │
│ │ │ G │ │ G │ │ G │ │ G │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ │ │
│ └──────────────┬──────────────┘ │
│ │ │
│ ┌──────────────▼──────────────┐ │
│ │ M (OS Thread) │ │
│ │ Executing Goroutine │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘Key Scheduler Events
| Event | What Happens |
|---|---|
| Goroutine blocks (I/O, channel) | M releases P, P picks another G |
| Goroutine syscall | M blocks with G, new M takes P |
| P run queue empty | Work stealing from other Ps |
| Goroutine runs too long (>10ms) | Preempted, put back in queue |
Debugging with GODEBUG
# See scheduler decisions in real-time
GODEBUG=schedtrace=1000 ./myapp
# Output every 1000ms:
# SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=10
# spinningthreads=1 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]Work Stealing Algorithm
// Simplified pseudocode of work stealing
func findRunnable() *g {
// 1. Check local run queue
if g := runqget(_p_); g != nil {
return g
}
// 2. Check global run queue
if g := globrunqget(_p_); g != nil {
return g
}
// 3. Steal from other Ps (random selection)
for i := 0; i < 4; i++ {
victim := randomP()
if g := runqsteal(_p_, victim); g != nil {
return g
}
}
return nil // No work found
}GOMAXPROCS
import "runtime"
func main() {
// Set number of Ps (defaults to NumCPU)
runtime.GOMAXPROCS(4)
// Check current value
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}Follow-up Questions
- What happens when a goroutine makes a blocking syscall?
- How does Go 1.14+ achieve asynchronous preemption?
- Why doesn't increasing GOMAXPROCS beyond NumCPU help CPU-bound work?