Goroutines and the Go Scheduler
Concept
A goroutine is not a thread — it's a small, growable-stack unit of work (starting around 2KB, vs. a few MB for an OS thread) that Go's runtime schedules onto a much smaller pool of real OS threads. The scheduler implements a G-M-P model: G is a goroutine, M is an OS thread ("machine"), and P is a logical processor — a scheduling context that holds a run queue of goroutines and must be held by an M for that M to execute Go code. GOMAXPROCS sets the number of Ps (default: number of CPUs), which caps how many goroutines can run Go code simultaneously, not how many can exist — you can have a million goroutines and eight Ps.
The scheduler is cooperative with compiler-inserted preemption points: historically a goroutine only yielded at function calls, channel ops, or explicit points, which meant a tight CPU-bound loop with no function calls could starve the scheduler; since Go 1.14, async preemption uses OS signals to interrupt long-running goroutines even without a call, closing that gap. When a goroutine blocks on a syscall, its M detaches from its P and the scheduler hands that P to another M (spinning one up if needed) so other goroutines keep running — this is why blocking I/O in Go doesn't stall the whole program the way it would with a naive thread-per-request model at high concurrency.
Tradeoffs
| Concurrency model | Benefit | Cost |
|---|---|---|
| OS threads (1:1) | Simple mental model, full OS scheduler guarantees, preemptive | Expensive per-unit (MBs of stack, kernel context-switch cost) — thousands of threads is already heavy |
| Goroutines (M:N, Go's model) | Cheap enough to spawn per-request or per-item (KBs, grows on demand); scheduler multiplexes onto few OS threads | Scheduler is Go-runtime-specific — doesn't compose with OS-level priority/affinity tools; a goroutine leak is invisible to top, only visible via runtime.NumGoroutine() or pprof |
| Green threads with manual yield (older cooperative models) | Even cheaper than M:N in theory | Requires the programmer to yield correctly everywhere — one missed yield point starves everything (this is exactly the pre-1.14 Go gap that async preemption fixed) |
Channels vs. shared memory + mutex is the second axis of tradeoff within Go itself: channels communicate ownership (data flows to whoever reads it, only one side touches it at a time by convention) and compose cleanly with select for cancellation/timeout, while a mutex-guarded shared struct is often faster for simple counters/caches but pushes correctness onto disciplined lock usage. Go's own proverb — "don't communicate by sharing memory, share memory by communicating" — is a default, not a hard rule; the standard library itself uses sync.Mutex extensively where a channel would be one more allocation and indirection for no benefit.
When to use / when not to
- Spawn a goroutine per independent unit of work with genuine concurrency value: a request handler, a fan-out to N backends, a background worker draining a queue.
- Don't spawn a goroutine per loop iteration just because you can — for CPU-bound work, more goroutines than
GOMAXPROCSjust adds scheduling overhead without more throughput; bound it with a worker pool orerrgroup.SetLimit. - Always pair a goroutine's lifetime with an explicit cancellation path (
context.Context) when it might outlive the request that spawned it — an unbounded goroutine with no way to stop it is a leak, not a fire-and-forget optimization.
Common pitfall
The goroutine leak: spawning a goroutine that blocks forever on a channel send/receive nobody will ever complete — most often a worker that writes a result to an unbuffered channel after its caller has already returned (e.g., on a timeout). The goroutine itself is small, but it never gets garbage collected because it's still runnable/blocked, not dead — thousands of these accumulate silently until runtime.NumGoroutine() or an OOM finally surfaces it. The fix is structural, not a bigger buffer: every goroutine that might outlive its caller needs a select with a ctx.Done() case, or the channel needs to be sized/drained so a late writer never blocks.
Engineering Lens
The G-M-P design is a strong instance of paying complexity cost in the runtime instead of at every call site — a Node.js-style single-threaded event loop or a Python-with-GIL model avoids the scheduler complexity Go took on, at the cost of needing separate processes or async/await ceremony to use more than one core. When reviewing Go code for a concurrency bug, the first two questions are always the same regardless of the specific symptom: does every goroutine have a way to observe cancellation, and is GOMAXPROCS actually bounding what you think it's bounding (containerized deployments before Go 1.5's GOMAXPROCS auto-detection, and even after it on some cgroup setups, have shipped with the scheduler thinking it has far more CPUs than the container's cgroup quota actually allows — visible as throttling metrics that don't match apparent goroutine counts).
Sources
- The Go scheduler — Go Wiki
- Scalable Go Scheduler Design Doc
- Asynchronous preemption — Go 1.14 release notes