Day 19 — Goroutines & the GMP model

Updated

July 30, 2026

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.

Note

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.

Go GMP model: goroutines processors and OS threads

GMP scheduler model

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

  1. GOMAXPROCS (default: number of logical CPUs) is the max number of Gs running Go code simultaneously.
  2. An M can block in a system call; the runtime can create more Ms so other Gs keep running.
  3. Blocking on a channel or mutex parks the G, not necessarily permanently the whole process.
  4. You almost never set GOMAXPROCS in app code for “tuning performance” first—measure first (later stages).

Inspecting the environment

go env GOMAXPROCS   # often empty; runtime uses NumCPU default
import (
    "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 go statement 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 later

Channels 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 4 — Shared memory: atomics today, mutexes tomorrow

Two concurrent writers to a plain int are a data race (undefined behavior under the memory model; Day 28).

// WRONG
var count int
go func() { count++ }()
go func() { count++ }()

Safe counter without mutex (for simple integers):

import "sync/atomic"

var count atomic.Int64 // Go 1.19+ API

func inc() { count.Add(1) }
func get() int64 { return count.Load() }

Older style:

var count int64
atomic.AddInt64(&count, 1)

Day 22 covers Mutex, RWMutex, and when channels beat locks.


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
  • WaitGroup Wait 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.Sleep parks 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
  1. Print runtime.NumCPU() and runtime.GOMAXPROCS(0).
  2. Spawn one goroutine that prints a message; join with WaitGroup.
  3. 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 RACE

Fix 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 GOMAXPROCS bounds
  • Spawned and joined ≥ 10 000 goroutines successfully
  • Used atomic (or mutex) for a shared counter
  • go run -race is 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.