Go Concurrency
Go is built around one promise — concurrency should be so cheap that you stop rationing it. Launching a goroutine costs less than allocating an OS thread: a tiny starting stack, a user-space switch, no trip into the kernel. That is why idiomatic Go comfortably holds tens of thousands of goroutines where OS threads would be unthinkable.
But "cheap" is not "free" and not "magic". Behind the go statement sits the GMP scheduler: goroutines (G) run on OS threads (M) through logical processors (P), and understanding those three letters separates a candidate who can reason about performance from one who merely memorized syntax. The traps appear exactly where intuition from 1:1-threaded languages breaks down: a goroutine is not pinned to a core, the P count is fixed by GOMAXPROCS while the thread count is not, a blocking syscall carries a thread into the kernel but does not freeze its run queue, and before Go 1.14 a tight loop with no function calls could not be preempted at all. This topic takes the runtime apart layer by layer — from the anatomy of a single goroutine to asynchronous preemption.
Topic Map
- Goroutine internals — what a goroutine is, why it is lighter than an OS thread, and how the
runtimemultiplexes many goroutines onto few threads. - Goroutine stack — the ~8 KB starting size and growth by copying into a new contiguous block, not by linking segments.
- GOMAXPROCS — what this setting actually controls and why it bounds the P count, not the goroutine or OS-thread count.
- The GMP scheduler — the three abstractions G, M, and P, and the rule that an M must hold a P to run Go code.
- Run queues — why each P keeps its own lock-free local queue alongside a shared global one.
- Syscall handling — how the
runtimedetaches the P from a thread that entered a blocking syscall so the queue keeps running. - Work-stealing — how an idle P steals half a random victim's queue and balances load with no central dispatcher.
- Goroutine preemption — cooperative preemption at safe points and asynchronous preemption via the
SIGURGsignal since Go 1.14.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
| Believing each goroutine is a 1:1 OS thread | Wrong cost model; cannot explain hundreds of thousands of goroutines |
| Quoting the 1 MB OS-thread stack as the goroutine's starting stack | Overstated ~128×; misses why goroutines are cheap on memory |
| Thinking the stack grows by linking segments | Misses frame copying and pointer fix-up during growth |
Confusing GOMAXPROCS with a goroutine-count cap | False expectation that go blocks when a limit is reached |
Believing GOMAXPROCS caps the OS-thread count | Cannot explain the thread spike under many blocking syscalls |
| Mixing up which letter is the thread and which the goroutine | M is the OS thread, G is the goroutine; broken scheduler model |
| Thinking an M runs Go code without acquiring a P | Misses why parallelism is bounded by the P count |
| Believing the local queue holds blocked goroutines | It holds runnable ones, same as the global queue |
| Assuming the P stays pinned to a thread in a blocking syscall | False conclusion that one syscall freezes the whole P queue |
| Claiming a call-free tight loop is unpreemptable | True only before Go 1.14; asynchronous preemption works since |
| Imagining a central balancer thread for work-stealing | Misses that the idle P steals itself, from a random victim |
Interview Relevance
Concurrency is a mandatory topic in any serious Go interview, and the question is not the go syntax but the execution model beneath it. The interviewer checks whether you have a working mental model of the scheduler.
What interviewers check:
- How a goroutine differs from an OS thread — who manages it, where the switch happens, what the starting stack is.
- The three abstractions G, M, P and the rule "an M must hold a P to run Go code".
- What
GOMAXPROCScontrols — the P count, not goroutines and not OS threads. - Why each P keeps a local queue alongside the global one — lock-free access and cache locality.
- What happens to a P when a goroutine enters a blocking syscall — the P is detached and handed to another M.
- How work-stealing works — an idle P steals half a random P's queue on its own.
- How the
runtimepreempts a goroutine in a tight loop with no function calls — asynchronous preemption via theSIGURGsignal.
A typical wrong answer: "a goroutine is just a lightweight thread, the kernel time-slices it itself." That opens a discussion of how goroutines are multiplexed onto a thread pool by the runtime itself, not the kernel, and how preemption in Go is the runtime scheduler's job, not the OS scheduler's.