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.
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.
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.
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.)
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:
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.
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.
- 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.Readand 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;
GOMAXPROCScaps real parallelism at the core count by default.
- 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
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.
Interview prompts
- Why can you run a million goroutines but not a million OS threads? (§1, §4 — a goroutine starts at ~2 KB of growable stack vs a thread's ~8 MB reservation, so a million goroutines is ~2 GB of stack while a million threads would reserve terabytes; the runtime also multiplexes them onto a few threads instead of one kernel thread each.)
- Explain the G, M, and P in the scheduler and what each does. (§3 — G is a goroutine (stack + resume state), M is an OS thread that executes code, P is a scheduling context with a local run-queue; an M must hold one of GOMAXPROCS Ps to run a G, so the P count caps real parallelism.)
- What's the difference between concurrency and parallelism in Go's model? (§3 — parallelism = goroutines running at the same instant, capped by GOMAXPROCS/core count; concurrency = goroutines existing and making progress over time, bounded only by memory because blocked goroutines yield their thread rather than holding it.)
- Why was P added to what used to be a G-M scheduler? (§3 — the original G-M design used a single global run-queue under one lock, which throttled multicore scaling; per-P local queues plus work-stealing removed that global bottleneck while keeping all cores fed.)
- What happens when a goroutine makes a blocking syscall vs a blocking network read? (§3 — a blocking syscall causes the runtime to detach the P from that M so another M can keep running the remaining goroutines; network I/O instead goes through the netpoller (epoll/kqueue), parking the goroutine and waking it when the socket is ready, so few threads serve many connections.)
- What problem did asynchronous preemption (Go 1.14) solve? (§3 — before it, scheduling points were only at function calls, so a tight CPU loop with no calls could starve other goroutines and stall GC; async preemption signals such a goroutine (~10 ms slice) and forces it to yield.)
- Is a goroutine free? Name a concrete cost. (§4, §5 — no: ~2 KB starting stack that the GC must scan, O(n) stack-copy on growth boundaries, a scheduler as an uncontrolled runtime part, and easy leaks — a goroutine blocked forever on a channel is never collected; the hard part, coordination, remains.)