Day 19 — Goroutines & the GMP model
Day 19 — Goroutines & the GMP model
Stage III · ~3h (theory-heavy)
Goal: Build a correct mental model of goroutines, the G/M/P scheduler, and how to start and join concurrent work without races or leaks.
Today is concurrency foundation, not channel mastery. You will spawn thousands of goroutines, join them correctly, and measure GOMAXPROCS. Channels arrive Day 20.
Why this day exists
Most concurrency bugs start as model bugs:
- Treating a goroutine like a free OS thread
- Forgetting to join (WaitGroup / channel / context)
- Assuming “cheap” means “unbounded is fine”
- Using shared variables without synchronization
If the model is solid, Days 20–30 become design choices—not superstition.
Theory 1 — What a goroutine is (and is not)
A goroutine is a lightweight unit of concurrent execution managed by the Go runtime.
| Property | Reality |
|---|---|
| Stack | Starts small (~2 KiB order of magnitude); grows/shrinks |
| Creation cost | Far cheaper than pthread_create |
| Scheduling | Cooperative with preemption; runtime multiplexes onto OS threads |
| Identity | No stable “thread ID” API for application logic |
| Lifecycle | Runs until its function returns (or process exits) |
go is not “run in parallel”
go f() // schedule f; do not wait for it- Concurrency = structure of independent tasks
- Parallelism = those tasks running at the same wall-clock time on multiple CPUs
You get parallelism only when the scheduler has multiple runnable Gs and multiple Ps (see Theory 2).
Worked micro-example
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("hello from a goroutine")
}()
wg.Wait() // join: main must not exit early
}Without wg.Wait(), main can return and kill the process before the child prints.
Theory 2 — GMP lite (G, M, P)
Go’s scheduler is often described with three letters:
| Letter | Name | Role |
|---|---|---|
| G | Goroutine | The runnable unit of Go code |
| M | Machine | An OS thread that executes Gs |
| P | Processor | A logical processor; holds a run queue; needed to run Go code |
Rough picture:
Gs (many) → run queues on Ps → Ms (OS threads) execute them
↑
GOMAXPROCS ≈ number of Ps
Rules of thumb
GOMAXPROCS(default: number of logical CPUs) is the max number of Gs running Go code simultaneously.
- An M can block in a system call; the runtime can create more Ms so other Gs keep running.
- Blocking on a channel or mutex parks the G, not necessarily permanently the whole process.
- You almost never set
GOMAXPROCSin app code for “tuning performance” first—measure first (later stages).
Inspecting the environment
go env GOMAXPROCS # often empty; runtime uses NumCPU defaultimport (
"fmt"
"runtime"
)
func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 = query
}What GMP is not
- Not a promise every
gostatement gets a dedicated core
- Not a real-time scheduler
- Not a reason to spawn millions of long-lived blocked goroutines without design
Theory 3 — Starting work: patterns that scale
Pattern A — fire-and-join with WaitGroup
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
work(id)
}(i)
}
wg.Wait()Critical: pass i as an argument (or use a local copy). Capturing the loop variable by reference is a classic footgun in older mental models—and still a readability hazard.
Pattern B — result channel (preview)
ch := make(chan int, n)
for i := 0; i < n; i++ {
go func(v int) { ch <- v * v }(i)
}
// must receive n times or close-contract laterChannels are Day 20; today prefer WaitGroup + shared counter with atomics for the lab.
Pattern C — do not use bare go in library APIs without a join story
Exporting a function that starts a goroutine and returns, with no cancel or Wait, is how services leak.
Theory 5 — Goroutine leaks (conceptual)
A leak is a goroutine that never becomes runnable again and is never collected because it is still “alive” (blocked forever on a channel, lock, or select with no exit).
Classic causes:
- Send on a channel with no receiver
- Receive with no sender and no close
WaitGroupWait with mismatched Add/Done
- Early return after spawning workers that still send
Go 1.26 adds experimental goroutine leak profiling (GOEXPERIMENT=goroutineleakprofile)—useful later; today, design for finite lifetime.
Process exit is not a join
When main returns, the process exits. Pending goroutines are not gracefully finished—they are torn down. Always join work you care about.
Theory 6 — Preemption, blocking, and “fairness”
- Long-running tight loops without function calls can delay scheduling (modern Go has async preemption; still write interruptable work for cancellation—Day 24).
- Network/file blocking is fine; the runtime parks the G.
runtime.Gosched()yields voluntarily—rarely needed in app code.
time.Sleepparks the G; do not use sleep as a synchronization mechanism.
Worked example — 10k workers, atomic join
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
func main() {
const n = 10_000
var (
wg sync.WaitGroup
count atomic.Int64
)
start := time.Now()
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
// simulate tiny work
count.Add(1)
}()
}
wg.Wait()
elapsed := time.Since(start)
fmt.Printf("GOMAXPROCS=%d NumCPU=%d\n", runtime.GOMAXPROCS(0), runtime.NumCPU())
fmt.Printf("count=%d elapsed=%s\n", count.Load(), elapsed)
}Expected: count == 10000. Under -race, clean.
Lab 1 — Environment + first join
Suggested workspace: ~/lab/90daysofx/01-go/day19
mkdir -p ~/lab/90daysofx/01-go/day19
cd ~/lab/90daysofx/01-go/day19
go mod init example.com/day19- Print
runtime.NumCPU()andruntime.GOMAXPROCS(0).
- Spawn one goroutine that prints a message; join with
WaitGroup.
- Deliberately remove
Wait()once and observe flaky/missing output.
go run .Lab 2 — 10k goroutines + atomic counter + -race
Implement the worked example (or your variant). Run:
go run -race .
GOMAXPROCS=1 go run -race .
GOMAXPROCS=4 go run -race .Record in your journal:
| Setting | Elapsed (approx) | count |
|---|---|---|
| default | ||
| 1 | ||
| 4 |
Theory check: with tiny work, wall time may not scale linearly—startup and scheduling overhead dominate.
Lab 3 — Break then fix a race
// race.go — intentionally wrong
package main
import (
"fmt"
"sync"
)
func main() {
var (
wg sync.WaitGroup
n int
)
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++ // DATA RACE
}()
}
wg.Wait()
fmt.Println(n)
}go run -race .
# expect: WARNING: DATA RACEFix with atomic.Int64 (or mutex). Re-run until race-clean. Note the wrong final value sometimes appears even without -race—nondeterminism is a symptom.
Lab 4 — Loop variable discipline
Write two versions of a printer:
// buggy style (do not ship)
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // may print unexpected values depending on version/habits
}()
}// correct
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(id)
}(i)
}Go 1.22+ changed per-iteration loop variable semantics; still pass by value for clarity in concurrent closures.
Common gotchas
| Gotcha | Fix |
|---|---|
main exits before work finishes |
WaitGroup / channel drain / errgroup |
Unbounded go in a hot request path |
Bound concurrency (pool, semaphore—Day 23) |
Shared int counters |
atomic or Mutex |
Ignoring -race until production |
go test -race / go run -race habit |
| “More GOMAXPROCS always faster” | Measure; I/O-bound work may not care |
| Detached goroutine with no cancel | Context (Day 24) + join design |
Checkpoint
- Can explain G, M, P in one short paragraph each
- Can state what
GOMAXPROCSbounds
- Spawned and joined ≥ 10 000 goroutines successfully
- Used
atomic(or mutex) for a shared counter
go run -raceis clean on your solution
- Deliberately saw a data race report and fixed it
- Documented NumCPU vs GOMAXPROCS in your notes
Commit
git add .
git commit -m "day19: goroutines, GMP lite, WaitGroup + atomic + race"Write three personal gotchas before continuing.
Tomorrow
Day 20 — Channels: ownership, send/receive, close, range, buffered vs unbuffered, and nil-channel tricks—without turning everything into a channel.