AlgoInsight

Free lesson · Concurrency Interview Mastery

Goroutines and the Scheduler

Medium10 min readconcurrencymultithreadinggo

The previous lessons made a remarkable claim: that you can run millions of goroutines on a handful of OS threads, spawning them as freely as function calls. This lesson explains how - the Go runtime scheduler, the GMP model it's built on, and the design choices that make goroutines cheap enough to justify everything else in the Go concurrency story. You don't need this to write Go, but understanding it is what turns goroutines from magic into something you can reason about, and the scheduler is a favorite topic for interviewers probing depth.

The core idea: M:N scheduling

Recall from Module 1 the distinction between user-level (green) threads and OS (kernel) threads. Go uses M:N scheduling: it multiplexes M goroutines onto N OS threads, where M can be millions and N is small (roughly the number of CPU cores). The Go runtime - not the operating system - decides which goroutine runs on which OS thread and when to switch, all in user space. This is what makes goroutines cheap: creating one and switching between them happens in the runtime without the expensive kernel transitions that OS thread operations require.

The OS sees only the small number of threads Go's runtime manages; it has no idea there are a million goroutines riding on them. The runtime handles all the goroutine scheduling itself, which is both faster (no kernel involvement for a context switch between goroutines on the same thread) and far more memory-efficient (goroutine stacks are tiny and grow on demand) than letting the OS schedule a million threads - which it couldn't do anyway.

The GMP model

Go's scheduler is built on three entities, abbreviated G, M, and P:

The rule that ties them together: an M must hold a P to execute Go code. A P has a local queue of runnable goroutines; an M grabs a P, then runs goroutines from that P's queue. The number of Ps therefore caps how many goroutines run truly in parallel at once - that's why GOMAXPROCS (defaulting to the number of CPU cores) effectively sets the parallelism level.

flowchart TD
    subgraph P1["P (processor, has run queue)"]
        Q1["runnable Gs: G1 G2 G3..."]
    end
    subgraph P2["P (processor, has run queue)"]
        Q2["runnable Gs: G4 G5 G6..."]
    end
    M1["M (OS thread)"] -->|holds| P1
    M2["M (OS thread)"] -->|holds| P2
    P1 -->|runs| RUN1["G1 executing on CPU"]
    P2 -->|runs| RUN2["G4 executing on CPU"]
    Q2 -.work stealing.-> P1
This diagram shows Go's GMP scheduling model. There are two Ps, each a logical processor holding a run queue of runnable goroutines - the first P has goroutines one, two, three, and the second has four, five, six. Each P is held by an M, which is an OS thread that actually executes code on a CPU. The first M holds the first P and runs goroutine one; the second M holds the second P and runs goroutine four. The number of Ps, set by GOMAXPROCS, caps how many goroutines run truly in parallel. A dotted arrow shows work stealing: if one P runs out of goroutines, it can steal work from another P's queue to stay busy.

GOMAXPROCS

GOMAXPROCS is the number of Ps - the number of OS threads that can execute Go code simultaneously. By default it equals the number of CPU cores, which is almost always what you want: it lets Go use all your cores for parallel execution. You can read or set it via runtime.GOMAXPROCS(n) or the GOMAXPROCS environment variable, but tuning it is rarely necessary and usually a mistake unless you have a specific reason (like deliberately limiting a program to a subset of cores).

Note what GOMAXPROCS does and doesn't control: it caps parallelism (how many goroutines run at the literal same instant), not concurrency (how many goroutines exist and make progress over time). You can have a million concurrent goroutines with GOMAXPROCS=4; at any instant at most four are executing, but the scheduler rotates through all of them. This is exactly the concurrency-versus-parallelism distinction from Module 1, made concrete in Go's runtime.

Work stealing

To keep all the Ps busy, Go's scheduler uses work stealing. Each P has its own local run queue, which avoids contention (goroutines are usually scheduled from the local queue without locking). But if a P empties its queue while another P still has goroutines waiting, the idle P steals half of the busy P's goroutines. This balances load across processors automatically, so no core sits idle while another is backed up. Work stealing is why Go's scheduler scales well across cores without you having to think about load distribution - it's handled for you.

Why goroutines are cheap

Now the payoff - the specific design choices that make goroutines lightweight enough to spawn by the million:

Together these make goroutine creation and switching nearly free compared to threads, which is the foundation of "just start a goroutine" being idiomatic rather than reckless.

Blocking, syscalls, and preemption

The scheduler has to handle goroutines that block, and it does so cleverly. When a goroutine makes a blocking system call (like certain file I/O), the M running it can block in the kernel - but the runtime detaches the P from that M and hands the P to another M, so the other goroutines on that P keep running. The blocked syscall doesn't stall the whole program. For network I/O, Go uses an integrated poller so that a goroutine waiting on the network is parked and its M/P freed to run others, then woken when the network is ready - which is why a Go server can have a goroutine blocked per connection and still scale.

Important: Since Go 1.14, the scheduler also does asynchronous preemption: it can interrupt a long-running goroutine that isn't yielding (say, a tight CPU loop with no function calls) so it doesn't starve other goroutines. Before 1.14, scheduling was cooperative - a goroutine yielded only at certain points (function calls, channel operations, allocations), and a tight loop with none of those could hog a P indefinitely. Async preemption fixed that, making the scheduler fairer. Knowing the model became preemptive in 1.14 is a sign you understand the scheduler's evolution, a detail interviewers probing depth appreciate.

How Go's scheduler compares to the alternatives

Placing Go's scheduler next to the other models in this module clarifies what's distinctive about it. The traditional approach - one OS thread per task, scheduled by the kernel - is what Java historically used (before virtual threads) and what raw threads in C++ and C# give you. There, the OS scheduler decides everything, every context switch crosses into the kernel, and each thread carries a large fixed stack, which is why you can only afford thousands of them. Go's user-space M:N scheduler is a deliberate move away from this: by scheduling goroutines itself in user space, Go avoids the kernel-transition cost on most switches and uses tiny growable stacks, which is the entire reason it can run orders of magnitude more concurrent units.

Interestingly, the industry has been converging on Go's approach. Java's virtual threads (Project Loom, which you met in the Java section) are essentially the same idea - lightweight, runtime-scheduled threads multiplexed onto a small pool of OS threads - arriving in Java roughly a decade after Go shipped goroutines. The pattern is the same across both: a user-space runtime that lets you write simple blocking-style code at massive concurrency without paying the OS-thread cost. Seeing that Go's goroutine model and Java's virtual threads are solving the identical problem with the identical strategy is exactly the kind of cross-language synthesis that reads as senior, and it positions goroutines not as a Go quirk but as an early, influential instance of a now-mainstream idea.

Where Go's scheduler is distinctive in the details is its integration: the GMP model, work stealing, the network poller that parks goroutines blocked on I/O, and async preemption are all tuned together in the runtime, and they're transparent to you as a programmer. You don't configure or manage any of it; you write goroutines and channels, and the scheduler makes them efficient. That tight, invisible integration - concurrency primitives and scheduler co-designed as one system - is part of why Go's concurrency story feels so coherent compared to languages where threading was added on top of a runtime not built for it.

What this means for writing Go

The practical payoff of understanding the scheduler is a handful of correct instincts. First, because the scheduler efficiently parks goroutines blocked on I/O and runs others, you should write straightforward blocking I/O code in goroutines and trust the runtime - you don't need async/await-style contortions, because a goroutine blocked on a network read doesn't tie up an OS thread; the runtime frees the M/P to run other goroutines and wakes the blocked one when its I/O is ready. This is why "a goroutine per connection, each blocking on reads" scales in Go where it wouldn't with OS threads.

Second, because GOMAXPROCS caps true parallelism at the core count by default, you get parallelism across cores automatically for CPU-bound work spread across goroutines, without configuration. Third, knowing about async preemption tells you that even a goroutine in a tight loop won't permanently starve others on modern Go, though writing goroutines that yield naturally (through channel ops or function calls) is still good practice. And fourth, understanding that the scheduler is cooperative-plus-preemptive and runs in user space explains the performance characteristics you'll observe: goroutine creation and switching are cheap, so designs with many goroutines are fine, while the actual parallelism is still bounded by your cores. None of this requires you to manage the scheduler - it requires you to trust it and write the simple, idiomatic code it's designed to run well. That trust, grounded in understanding rather than hope, is what the scheduler knowledge buys you.

Stack growth, and the deliberate absence of goroutine identity

Two further design details round out the picture and come up when interviewers push on why goroutines work the way they do. The first is growable stacks, which deserves more than the passing mention it got. A goroutine starts with a stack of just a few kilobytes, but unlike an OS thread's fixed stack, it isn't stuck there: when a goroutine's stack is about to overflow, the runtime allocates a larger stack, copies the existing stack's contents over, and adjusts pointers, then continues. The stack can also shrink when the goroutine's needs decrease. This is the mechanism that lets a goroutine cost only kilobytes initially while still being able to handle deep recursion or large local data when needed - you get small-by-default with grow-when-necessary, which is what makes a million goroutines affordable without artificially limiting any individual one. An OS thread can't do this; it commits a large stack up front because the kernel can't relocate a thread's stack underneath it, which is precisely why threads are heavy and goroutines are light.

The second detail is something Go deliberately doesn't give you: a goroutine identity. There's no goroutine ID you can obtain, no way to name a goroutine or refer to it by handle, no equivalent of a thread object you hold and join on. This is an intentional design choice, not an oversight. The Go team omitted goroutine IDs to discourage patterns that tie behavior to specific goroutines - thread-local storage, goroutine-keyed maps, "do this only on goroutine X" logic - which they consider sources of fragile, hard-to-reason-about code. Without an ID, you're pushed toward coordinating goroutines through channels and explicit synchronization rather than through identity, which keeps the concurrency model cleaner. The practical consequence is that when you need a goroutine to stop or to report completion, you don't reach for its handle (there isn't one) - you use a channel, a context, or a WaitGroup. The absence of goroutine identity is thus tightly connected to the "communicate, don't share" philosophy: you coordinate goroutines by passing messages, not by holding references to them. Recognizing that this omission is purposeful, and what it steers you toward, is a sign you understand Go's design rather than just its syntax.

The interview angle

Interview: Likely questions: (1) "How does Go schedule goroutines?" - M:N scheduling in user space: the runtime multiplexes millions of goroutines onto a few OS threads, deciding placement and switching itself without the kernel. (2) "Explain the GMP model." - G is a goroutine, M is an OS thread, P is a scheduling context with a run queue; an M must hold a P to run Go code, and the number of Ps (GOMAXPROCS) sets the parallelism. (3) "What does GOMAXPROCS control?" - The number of Ps / OS threads running Go code simultaneously - i.e., parallelism - defaulting to core count; it doesn't limit how many goroutines exist (concurrency). (4) "Why are goroutines cheap?" - Tiny growable stacks (KB vs MB), user-space scheduling (no kernel context switch), and no per-goroutine OS resources. (5) "What's work stealing / async preemption?" - Idle Ps steal goroutines from busy Ps to balance load; since 1.14 the scheduler can preempt non-yielding goroutines. Explaining GMP clearly is a strong depth signal.

Quick reference

// M:N scheduling - runtime multiplexes many goroutines onto few OS threads (user space)
// GMP:  G = goroutine (millions)   M = OS thread   P = scheduling context (run queue)
//       an M must hold a P to run Go code

runtime.GOMAXPROCS(n)    // number of Ps = max goroutines running in PARALLEL
                         // defaults to NumCPU; rarely needs changing
                         // caps parallelism, NOT concurrency (goroutine count)

// WHY GOROUTINES ARE CHEAP:
//   tiny growable stacks (~KB, vs ~MB OS thread) | user-space switch (no kernel) | no per-G OS resources

// scheduler features: work stealing (idle P steals from busy P);
//                     async preemption (since Go 1.14) interrupts non-yielding goroutines
// blocking syscall -> P detaches to another M, rest of goroutines keep running

Next, the feature at the heart of Go's "share by communicating" philosophy - channels: buffered vs unbuffered, direction, closing, range, and the select statement that multiplexes over them.

Back to Concurrency Interview Mastery