Goroutines, Spawning, Scheduling, Lifecycle
Goroutines are user-space tasks multiplexed onto a small pool of OS threads by the Go runtime's M:P:G scheduler. Spawn with `go func() {...}()`, ~2KB initial stack, cheap to create, millions are practical. No public state machine; lifetime is 'until the function returns.'
What goroutines actually are
A goroutine is a lightweight task, not an OS thread. The Go runtime maintains a small pool of OS threads (M's) and multiplexes goroutines (G's) onto them via logical processors (P's). When a goroutine blocks on a channel, lock, or syscall, the runtime parks it and schedules another goroutine on the same OS thread.
Why this matters One OS thread = one process slot, one stack, one expensive context switch. One goroutine = one task struct, one tiny stack, one cheap user-space switch. Spawning 100K goroutines is normal in production Go services. Spawning 100K threads would crash any modern OS.
The M:P:G mental model
- G (goroutine), each
go func()call. Has its own stack, instruction pointer, function context. - P (processor), a logical processor. Holds a local run queue of G's. Number of P's =
GOMAXPROCS(default = NumCPU). - M (machine), an OS thread. Picks a P, runs G's from P's queue. When G blocks on a syscall, M may release P to another M.
Work-stealing: an idle P steals half the queue from a busy P. Keeps load balanced even with imbalanced workloads.
What this means in practice
- CPU-bound: GOMAXPROCS = NumCPU. Spawning more goroutines than P's just adds scheduling overhead.
- I/O-bound: spawn lots; they park on the I/O, freeing M's for other goroutines. This is where Go's concurrency model shines.
- Mixed: same as I/O-bound, let the runtime juggle.
The lifecycle truth
A goroutine has no public state. It's running, runnable, or parked. From the caller's perspective:
- Started:
go f(), runs eventually (no guarantee of immediate execution). - Running: doing work. May be parked-and-resumed many times.
- Done:
freturns. Goroutine vanishes. No way to get a return value (use a channel or sync.WaitGroup).
Important: a goroutine cannot be stopped from outside. The goroutine must voluntarily return. The cancellation pattern: pass a context.Context, have the goroutine select on ctx.Done(), and return when cancelled.
The leak that bites everyone
A goroutine blocked on a channel send/receive with no cancellation path lives forever. runtime.NumGoroutine() growing under load is the unmistakable sign. The fix: every goroutine must have an exit story, context cancellation, channel close, or natural completion. Always know how every spawned goroutine will exit.
Primitives by language
- go func() { ... }()
- runtime.GOMAXPROCS / NumGoroutine / Gosched
- sync.WaitGroup (joining)
- context.Context (cancellation)
Implementation
go func() {...}() launches a goroutine that runs concurrently with the caller. To wait for it, use sync.WaitGroup, Add before go, Done inside, Wait to block.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d running\n", id)
}(i) // pass i as arg, closure capture trap
}
wg.Wait() // block until all 5 finish
}A classic trap: launching goroutines in a loop without capturing the loop variable as an argument. All goroutines share i and most print the same final value. Always pass loop variables as args. Note: Go 1.22+ fixes the loop-var semantics, but explicit capture is the portable habit.
package main
import "sync"
func main() {
// BROKEN (pre-Go 1.22), all goroutines share i
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // probably prints 5,5,5,5,5
}()
}
// FIXED, capture as arg
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) { // id is per-goroutine
defer wg.Done()
fmt.Println(id) // 0,1,2,3,4 (in some order)
}(i)
}
wg.Wait()
}runtime.NumGoroutine() is the first diagnostic for leaks. GOMAXPROCS is the parallelism cap (default = num cores). Gosched() voluntarily yields, rarely needed, but useful in tight loops without blocking calls.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("Goroutines:", runtime.NumGoroutine()) // typically 1 (main)
go func() {
for i := 0; i < 1_000_000; i++ {
// tight loop, without channel/lock, scheduler can't preempt prior to Go 1.14
if i%100_000 == 0 {
runtime.Gosched() // explicit yield
}
}
}()
fmt.Println("After spawn:", runtime.NumGoroutine()) // 2
}Spawning a goroutine per item works for small N. For large N, bound concurrency with a buffered channel as a semaphore.
package main
import "sync"
func processAll(items []int, maxConcurrent int) {
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire, blocks if full
go func(i int) {
defer wg.Done()
defer func() { <-sem }() // release
process(i)
}(item)
}
wg.Wait()
}Key points
- •Goroutines are NOT OS threads, multiplexed onto N OS threads where N = GOMAXPROCS
- •Stack starts at ~2KB, grows on demand up to ~1GB
- •M:P:G model: M = OS thread, P = logical processor, G = goroutine
- •Work stealing: idle P's steal goroutines from busy P's run queues
- •No stop/kill, goroutines exit only when their function returns
Follow-up questions
▸What's M:P:G scheduling?
▸How are goroutines cheaper than OS threads?
▸Can a goroutine outlive main?
▸How is a goroutine stopped?
Gotchas
- !Closure capture in loops (pre-Go 1.22): always pass loop vars as args
- !wg.Add() must be BEFORE 'go ...' or it races with wg.Wait()
- !Spawning unbounded goroutines = potential OOM under load
- !tight pure-CPU loops without yields could starve the scheduler before Go 1.14