Go Goroutines & Scheduling
Goroutines & the GMP Scheduler
What: A goroutine is a runtime-managed, cheaply-created unit of execution (~2 KB initial stack, grows/shrinks dynamically) multiplexed onto OS threads by Go's scheduler.
Syntax/Signature: go f(args) — arguments are evaluated at the go statement, the call runs later.
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // Go 1.22+: i is per-iteration, prints 0,1,2 in some order
}()
}
wg.Wait()
}
How it works under the hood: The GMP model — G (goroutine), M (OS thread), P (logical processor, count = GOMAXPROCS, defaults to CPU count). Each P owns a local run queue; Ms must acquire a P to run Gs. Idle Ps work-steal half of another P's queue; there's also a global queue checked periodically. Blocking syscalls detach M from P (a new/parked M takes the P); network I/O parks the G on the netpoller without burning a thread. Since Go 1.14 goroutines are asynchronously preemptible — sysmon sends a signal (SIGURG on Unix) to threads running a G longer than ~10 ms, so tight loops without function calls no longer starve the scheduler. Stacks are contiguous: on overflow the runtime allocates a bigger stack and copies (morestack).
Interview angle: "Explain how Go schedules goroutines" / "What happens when a goroutine makes a blocking syscall?" / "Why are goroutines cheaper than threads?" (stack size, user-space scheduling, no kernel context switch for handoffs).
Gotchas / common wrong answers:
- "Goroutines map 1:1 to threads" — wrong, it's M:N.
- Saying preemption only happens at function calls — true pre-1.14, wrong for 8+ years.
mainreturning kills all goroutines immediately — no panic, no cleanup, deferred functions in other goroutines do not run.
Trade-offs / when NOT to use: Don't spawn a goroutine per item for CPU-bound work with unbounded input (scheduler overhead + memory); bound with a worker pool (see [[Go Concurrency Patterns]]). A goroutine with no termination path is a leak, not "free" (see [[Go Goroutine Leaks]]).
Go 1.22 Loop-Variable Semantics
What: Since Go 1.22, for loop variables are per-iteration, not per-loop. The infamous "all goroutines print the last value" bug class is dead — in modules declaring go >= 1.22.
Syntax/Signature: for i, v := range s { go func() { use(i, v) }() } — now correct without shadowing.
// Pre-1.22 (or module with go < 1.22 in go.mod): likely prints "c c c".
// Go 1.22+ module: prints a, b, c (some order).
for _, s := range []string{"a", "b", "c"} {
go func() { fmt.Println(s) }()
}
// The old fixes you must still recognize in legacy code:
// for _, s := range xs { s := s; go func(){...}() } // shadow
// for _, s := range xs { go func(s string){...}(s) } // parameter
How it works under the hood: The compiler now allocates a fresh instance of the loop variable each iteration when it's captured or has its address taken (escape analysis; no cost otherwise). Gating is per-module via the go directive in go.mod — a 1.22 toolchain compiling a go 1.21 module keeps old semantics, so "which Go version" is not a complete answer; "which language version does the module declare" is.
Interview angle: Interviewers still open with the classic snippet. The Senior answer names both eras: "per-loop before 1.22 → data race / last-value capture; per-iteration since 1.22, gated on the module's go directive," then names the legacy fixes.
Gotchas / common wrong answers:
- Answering only the pre-1.22 behavior (outdated) or only "it's fixed now" (incomplete).
- Believing the fix removes the data race of concurrent writes to shared state — it only fixes variable capture; sharing is still sharing.
- Forgetting the same change applies to 3-clause
for i := 0; ...loops too, not justrange.
Trade-offs / when NOT to use: Per-iteration capture can increase heap allocations in tight capturing loops — usually noise, occasionally visible in profiles.
Interview Drills
- This code sometimes prints fewer than 10 lines or panics. Why, and fix it.
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
go func() { wg.Add(1); defer wg.Done(); fmt.Println(i) }()
}
wg.Wait()
wg.Add(1) runs inside the goroutine, racing wg.Wait(): Wait can observe counter 0 and return before some goroutines even start (missing output), and Add-after-Wait-returned on a reused/zero-counter group is documented misuse that can panic. Fix: wg.Add(1) in the loop body before go (or wg.Add(10) before the loop). Note i itself is fine on Go 1.22+ per-iteration semantics.
-
Explain what happens when a goroutine makes a blocking syscall. — The M running it detaches from its P; the runtime hands that P to a new or parked M so other Gs keep running. The blocked M eventually resumes and either reacquires a P or parks. Network I/O is handled differently — the G parks on the netpoller without ever burning a thread at all.
-
Why is "which Go version are you running" not a complete answer for whether loop-var capture is safe? — The semantics are gated per-module by the
godirective ingo.mod, not the toolchain version — a Go 1.22+ toolchain compiling a module that still declaresgo 1.21preserves the old per-loop (not per-iteration) semantics for that module.
Cross-links
[[Go Channels]] · [[Go Concurrency Patterns]] · [[Go Goroutine Leaks]] · [[Python Concurrency]]