all_lessons/Go/08 · Goroutines & schedulerlesson 9 / 18

Goroutines and the scheduler: concurrency cheap enough to use freely

Concurrency is hard mostly because the unit of concurrency is expensive. An OS thread costs about a megabyte of stack and a kernel trip to switch, so you ration threads, build pools, and contort your design around a scarce resource. Go's bet is that if you make the worker nearly free, ordinary engineers will write correct concurrent code — so it pays for a garbage collector's cousin, a scheduler, to multiplex hundreds of thousands of tiny tasks onto a handful of threads. This lesson is about what a goroutine actually is, what the G-M-P scheduler does on your behalf, and — because this series sells trade-off literacy, not magic — exactly what you pay.

The thesis, here
This is the bargain's second half in its purest form (Lesson 00). C++ refuses a runtime, so its concurrency primitive is the OS thread, with all its weight; you don't pay for what you don't use. Go does the opposite on purpose: it accepts a runtime scheduler so the concurrency primitive can be made small enough to spend without a budget meeting. The complexity Go is trying to remove from the reader is the whole apparatus of thread pools and worker-count tuning that exists only because threads are scarce. The price it pays to remove it — a scheduler, slightly larger goroutine stacks than strictly needed, occasional GC pressure — is the subject of §5. An engineer reads this lesson to learn the price, not to be told it's free.
Linear position
Prerequisite: Lesson 03 — pointers, the heap, escape analysis, and the GC as a deliberately accepted runtime cost. The scheduler is the other half of that runtime, and a goroutine's stack lives under the same memory machinery.
New capability: explain why launching a million goroutines is reasonable while launching a million threads is not; sketch the G-M-P model and what work-stealing and preemption buy; and answer "what did one goroutine cost?" with numbers and a mechanism.
The plan
Four moves. (1) Name the engineering pain — the OS thread is too expensive to be the everyday unit of concurrency. (2) Show what a goroutine is and the one-keyword syntax that launches it. (3) Open the runtime: the G-M-P scheduler, work-stealing, and preemption — what the runtime does so you don't have to. (4) Account for the cost honestly — stack growth, scheduler overhead, and the bugs cheap concurrency makes easier to write.

1 · The pain: the OS thread is too expensive to spend freely

A thread of execution is the obvious unit for "do these two things at once." But the only thread the operating system offers is a heavyweight object. On Linux, a thread is created with a default stack reservation of 8 MB of virtual address space (committed lazily, but still reserved), and the kernel tracks register state, signal masks, and scheduling metadata for each one. Creating a thread is a system call. Switching between two threads is also a kernel operation: it saves and restores register state and crosses the user/kernel boundary, costing on the order of 1–2 microseconds — small until you do it a million times a second, at which point context-switch overhead dominates your CPU.

Because the thread is expensive, its expense leaks into your design. You cannot have one thread per connection on a server with 100,000 connections — that is 100,000 threads and several hundred gigabytes of reserved stack. So you build a thread pool: a fixed, carefully-tuned number of worker threads pulling tasks off a queue. Now your program is full of machinery — pool sizing, work queues, callbacks, futures, the question "how many workers?" that has no good answer because it depends on the runtime mix of CPU-bound and I/O-bound work. None of that machinery is your actual problem. It exists only because the worker is scarce. This is exactly the kind of reader-facing complexity the Go bargain wants to delete — and the way to delete it is to make the worker cheap.

OS thread
Scheduled by the kernel. ~8 MB stack reservation. Created and switched via system calls (~1–2 µs/switch). Scarce — you ration them with a pool.
Goroutine
Scheduled by the Go runtime in user space. Starts with a ~2 KB stack that grows on demand. Created and switched without a kernel trip (tens of ns). Abundant — you spawn one per unit of work.
The consequence
Because the goroutine is ~4000× cheaper in stack and far cheaper to switch, "one goroutine per connection / per request / per task" becomes a reasonable default. The pool, the worker-count knob, the callback soup — all gone from the reader's view.

2 · What a goroutine is, and the one keyword that starts it

A goroutine is a function call that the runtime runs concurrently with the rest of the program, on a stack the runtime manages. You launch one by writing go before a call. That is the entire syntax — the deliberate smallness is the point: concurrency should be as cheap to write as it is to run.

func main() {
    go fetch("a.example") // runs concurrently; main does not wait
    go fetch("b.example")
    // BUG: main may return before either fetch finishes, killing the program.
    // We have launched work but said nothing about waiting for it.
}

The bug above is the first thing cheap concurrency teaches you: go f() answers "start this" but says nothing about "wait for this" or "where does the result go?" Those are separate questions, answered by channels (Lesson 09) or a sync.WaitGroup (Lesson 11). Here is the minimal correct shape using a wait group — count the launched work, and block until it is done:

func main() {
    var wg sync.WaitGroup
    for _, host := range []string{"a.example", "b.example"} {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fetch(host) // Go 1.22+: each iteration's `host` is a fresh variable
        }()
    }
    wg.Wait() // blocks until both Done() calls; the program stays alive
}

One sharp edge worth naming now: before Go 1.22, the loop variable host was a single variable reused each iteration, so all goroutines often captured the same final value — a classic bug. Go 1.22 (2024) changed the loop semantics so each iteration gets a fresh variable, fixing it. If you read older code, assume the trap is there; the fix was to shadow it explicitly (host := host).

3 · The runtime: the G-M-P scheduler

For goroutines to be cheap, something must multiplex many of them onto few OS threads — the runtime cannot afford one kernel thread per goroutine, or we'd be back to the original cost. That something is the Go scheduler, and its design is named after its three entities. (The modern design dates to Go 1.1, 2013; an earlier naïve G-M version had a global lock that throttled multicore scaling — the P was added precisely to fix that.)

G a goroutine: a stack + the bookkeeping to resume it (PC, status) M a "machine": an actual OS thread that executes code P a "processor": a scheduling context + a local run-queue of Gs. There are GOMAXPROCS of them (default = number of CPU cores). To run Go code, an M must hold a P. So at most GOMAXPROCS goroutines run in parallel, but thousands more wait in run-queues. P0 [G G G G] P1 [G G] <- per-P local run queues | | M0 (thread) M1 (thread) <- M needs a P to run a G | | CPU core 0 CPU core 1 global run queue: [G G ...] <- overflow / fairness

The split is the whole trick. Parallelism — how many goroutines run literally at the same instant — is capped by GOMAXPROCS (the number of Ps, defaulting to your core count), because a goroutine can only execute on an M that holds a P. Concurrency — how many goroutines exist and are making progress over time — is bounded only by memory. A goroutine that blocks does not block its thread's CPU: the runtime parks it and runs the next runnable G on the same P.

Three runtime behaviors do the heavy lifting:

Cheap context switch
Switching goroutines means saving ~3 registers (PC, SP, and the goroutine pointer) and picking the next G off a P's queue — all in user space, no kernel trip. It costs on the order of tens of nanoseconds, vs ~1–2 µs for a thread switch.
Work-stealing
When a P's local run-queue empties, its M doesn't idle: it steals half the runnable goroutines from another P's queue (and checks the global queue). This keeps all cores fed without a single global lock — the bottleneck the original G-M design had.
Blocking syscalls handled
If a goroutine makes a blocking syscall (e.g. a file read), the runtime detaches the P from that M so another M can grab the P and keep the remaining goroutines running. Network I/O is even better: it goes through the runtime's netpoller (epoll/kqueue), which parks the goroutine and wakes it when the socket is ready — so 100k network goroutines need only a few threads.
Preemption
A goroutine that never blocks (a tight CPU loop) used to be able to starve others, because scheduling points were only at function calls. Since Go 1.14 (2020) the runtime uses asynchronous preemption: it signals a long-running goroutine (~10 ms time slice) and forces it to yield, so a busy loop can no longer hang the scheduler.

Notice what the reader of a Go program never writes: no pool size, no event loop, no "register this callback for when the socket is readable." You write straight-line blocking code — n, err := conn.Read(buf) — and the runtime turns the block into a yield. That is the complexity the scheduler absorbs so it doesn't land in your code. It is the same move as the GC in Lesson 03: accept a bounded runtime cost to remove a large, error-prone burden from every engineer.

4 · The growable stack: why a million goroutines fits

A goroutine starts with a stack of about 2 KB (the runtime's _StackMin), not the 8 MB of an OS thread. That single number is what makes a million goroutines a reasonable thing to say: a million × 2 KB is roughly 2 GB of stack if they all stayed minimal — large but bounded — whereas a million threads at 8 MB would reserve 8 terabytes, which the machine cannot do.

The catch a fixed 2 KB stack would create is obvious: real call chains need more. Go solves it with contiguous, growable stacks. On a function's entry, a tiny prologue checks whether the stack has room; if not, the runtime allocates a new, larger stack (doubling), copies the existing frames over, fixes up pointers into the stack, and resumes. The goroutine never sees it. The stack also shrinks during GC if it's mostly unused. So 2 KB is a starting point, not a ceiling — a goroutine that recurses deeply gets the space it needs, and one that does little keeps a tiny footprint.

Cost, named
Stack growth is not free: the copy is O(stack size) and happens when you cross a growth boundary. A function called in a hot loop that repeatedly trips the boundary can cause repeated copies. It's rarely a problem in practice, but it's the honest reason the number is "~2 KB starting" and not "2 KB, period." And every goroutine you launch is memory the GC must scan for pointers — a million live goroutines is a million stacks to mark. Cheap is not zero.

5 · What it cost, and who pays

Answering question five of the five (Lesson 00) honestly: a goroutine is cheap, not free, and cheap concurrency moves the difficulty rather than removing it.

What the bargain buys
  • One goroutine per task as the default. The thread pool, worker-count tuning, and callback machinery vanish from the reader's view — the complexity §1 named is gone.
  • Blocking code that scales. You write conn.Read and the runtime turns the block into a yield via the netpoller; 100k connections need a handful of threads.
  • Cores used without a global lock. Work-stealing and per-P queues scale across cores; GOMAXPROCS caps real parallelism at the core count by default.
What it costs (named honestly)
  • A scheduler in your runtime. Like the GC, it's a moving part you don't control fully; pathological cases (a syscall-heavy load spinning up many Ms) exist and need profiling (Lesson 15).
  • Stack memory and GC scan. ~2 KB each, growable; a million live goroutines is real memory the GC must scan.
  • Leaks are easy. A goroutine blocked forever on a channel with no sender is never collected — it leaks until the process dies. Cheap to start means easy to forget to stop (Lesson 10's context).
  • The hard part is still hard. go f() makes starting trivial; coordinating — who waits, who owns the data, who handles the error — is the actual work, deferred to Lessons 09–11.

The deepest cost is the last one, and it's why this is a movement and not a single lesson. go answers only "start." The moment you have two goroutines you must answer question two of the five — who owns this data, and how does it move? — because the alternative is a data race: two goroutines touching the same memory with no ordering, producing undefined behavior the compiler won't catch for you. Go's answer to that question is the subject of the next three lessons, and it starts with a slogan that inverts how most languages share state.

Checkpoint exercise

Try it
Write a program that launches 100,000 goroutines, each of which sleeps for one second and then sends its index on a shared channel; have main receive all 100,000 and print the count. Run it and watch the resident memory with /usr/bin/time -v (or Activity Monitor) — note that it stays in the tens of MB, not the hundreds of GB a thread-per-task version would need. Then make it leak on purpose: remove the receiver loop so the senders block forever on the unbuffered channel, and observe with runtime.NumGoroutine() that 100,000 goroutines are stuck and never freed. You've now felt both halves of §5: the worker is nearly free to spawn, and a worker with no one to talk to never dies on its own.

Where this points next

We can now create concurrency for almost nothing — but §2's first bug and §5's leak both point at the same hole: starting work is trivial, and everything that matters is coordination. Two goroutines that share a variable race; two that need to hand off a result need a channel. Lesson 09 fills exactly that gap with Go's central concurrency idea — don't communicate by sharing memory; share memory by communicating — and shows the channel as a typed conduit that transfers ownership of a value (reaching back to the value semantics of Lesson 02) with a happens-before guarantee the race detector can rely on. Goroutines are the workers; channels are how they talk.

Takeaway
Concurrency is hard largely because the OS thread — ~8 MB of stack, a kernel trip to create and ~1–2 µs to switch — is too expensive to spend freely, forcing thread pools and worker-count tuning into every design. Go's bet, the bargain's second half, is to accept a runtime scheduler so the worker can be made cheap: a goroutine starts with a ~2 KB growable stack and switches in tens of nanoseconds, so "one goroutine per task" is a reasonable default and a million of them fits. The G-M-P scheduler does the work you'd otherwise hand-code — Gs (goroutines) run on Ms (OS threads) only while holding one of GOMAXPROCS Ps (scheduling contexts); work-stealing keeps cores fed without a global lock, the netpoller turns blocking I/O into a yield, and asynchronous preemption (Go 1.14) stops a busy loop from starving others. The transferable habit is to separate parallelism (capped by cores) from concurrency (capped by memory) and to remember the bargain's price: cheap to start is not free to run, and the genuinely hard part — who owns the data and how it moves — is the coordination the next lessons handle.

Interview prompts