Go Concurrency
Goroutine cost, the GMP scheduler, run queues, work-stealing, syscall handling, and preemption in the Go runtime.
13 questions
JuniorTheoryVery commonWhat is a goroutine and how does it differ from an OS thread?
What is a goroutine and how does it differ from an OS thread?
A goroutine is a lightweight function managed by the Go runtime, not the OS. The runtime multiplexes many goroutines onto a few OS threads, so they start with a tiny stack and switch in user space without a kernel context switch.
Common mistakes
- ✗Believing each goroutine maps 1:1 to an OS thread instead of being multiplexed onto a small pool
- ✗Assuming a goroutine switch costs a kernel context switch rather than a cheap user-space switch
- ✗Thinking goroutines cannot run in parallel, confusing concurrency with single-threaded execution
Follow-up questions
- →How does the runtime decide which OS thread runs a given goroutine?
- →Why is a goroutine's initial stack so much smaller than a thread's?
JuniorTheoryCommonWhat does GOMAXPROCS control in the Go runtime scheduler?
What does GOMAXPROCS control in the Go runtime scheduler?
GOMAXPROCS sets the number of P's — logical processors — so it caps how many goroutines run Go code in parallel at once. It defaults to the number of CPU cores and does not limit how many OS threads the runtime may create overall.
Common mistakes
- ✗Confusing GOMAXPROCS with a cap on the number of goroutines rather than parallel P's
- ✗Believing it limits total OS threads, when only the P count of code-running threads is bounded
- ✗Assuming raising GOMAXPROCS above the core count gives extra parallel speedup
Follow-up questions
- →What is the default value of GOMAXPROCS and when would you change it?
- →Why can the runtime still hold more OS threads than the GOMAXPROCS value?
JuniorTheoryCommonHow large is a goroutine's stack at creation, and how does it grow?
How large is a goroutine's stack at creation, and how does it grow?
A goroutine starts with a small stack of about 8 KB. When a function would overflow it, the runtime allocates a larger contiguous stack, copies all frames over, fixes up pointers, and frees the old one — so growth happens by copying, not by linking segments.
Common mistakes
- ✗Quoting the OS thread default of 1 MB as the goroutine's starting stack size
- ✗Believing the stack grows by chaining segments rather than copying to a new contiguous block
- ✗Assuming the stack can only grow and is never shrunk back by the runtime
Follow-up questions
- →When and how does the runtime shrink an over-large goroutine stack?
- →Why does copying the stack require the runtime to rewrite pointers into it?
MiddleTheoryCommonWhat is a context switch, and why is switching between goroutines cheaper than between OS threads?
What is a context switch, and why is switching between goroutines cheaper than between OS threads?
A context switch saves one execution's state (stack pointer, registers, instruction pointer) and loads another's. An OS-thread switch traps into the kernel, which is expensive. The Go scheduler switches goroutines in user space without a kernel trap, so it only restores the goroutine's stack and program counter — far cheaper, though not free.
Common mistakes
- ✗Thinking a goroutine switch enters the kernel like an OS-thread switch does
- ✗Believing goroutine switches are completely free rather than just cheaper
- ✗Confusing a scheduler context switch with a GC stop-the-world pause
Follow-up questions
- →When does the Go scheduler hand a goroutine off to a real OS-thread switch anyway?
- →Why does a blocking syscall force the runtime to involve the kernel scheduler?
MiddleTheoryCommonExplain the G, M, and P abstractions in the Go scheduler.
Explain the G, M, and P abstractions in the Go scheduler.
G is a goroutine — the unit of work. M is an OS thread, the only thing that can actually run code. P is a logical processor: a scheduling context holding a run queue. An M must hold a P to execute Go code, and the P count is GOMAXPROCS.
Common mistakes
- ✗Mixing up which letter is the thread and which is the goroutine — M is the OS thread, G is the goroutine
- ✗Thinking an M can run Go code without first acquiring a P
- ✗Believing P is tied to a physical CPU core rather than being a logical scheduling context
Follow-up questions
- →What happens to a P and its run queue when the M holding it blocks?
- →Why is the P count fixed at GOMAXPROCS while the M count is not?
MiddleTheoryOccasionalWhy must GOMAXPROCS match a container's CPU quota?
Why must GOMAXPROCS match a container's CPU quota?
By default the runtime sets GOMAXPROCS to the host's logical CPU count, not the container's cgroup CPU quota. On a 64-core host capped at 2 cores it runs 64 Ps, so the kernel CFS throttles the process — latency spikes and wasted context switches. Fix: set it to the quota or use automaxprocs.
Common mistakes
- ✗Assuming the Go runtime reads the cgroup CPU quota by default — pre-1.25 it does not
- ✗Thinking more
Ps than allotted cores improves throughput rather than causing CFS throttling - ✗Believing
GOMAXPROCSonly limits GC threads, not goroutine scheduling
Follow-up questions
- →How does
automaxprocsdiscover the cgroup quota at startup? - →What changed in Go 1.25 about container CPU-quota awareness?
MiddleTheoryOccasionalWhat does the Go runtime store for each goroutine?
What does the Go runtime store for each goroutine?
Each goroutine is a runtime g struct, not an OS thread. It holds its own growable stack with bounds, a gobuf saving the program counter and stack pointer so it can be paused and resumed, a status, an id, and scheduling links.
Common mistakes
- ✗Thinking a goroutine is an OS thread rather than a runtime
gstruct multiplexed onto threads - ✗Forgetting the
gobufsaves the program counter and stack pointer so the goroutine can be resumed - ✗Assuming a blocked goroutine occupies its OS thread instead of being parked in a waiting status
Follow-up questions
- →How does a logical processor P pick the next runnable
gto run on its OS thread M? - →What happens to a goroutine's status and run-queue links when it blocks on a channel?
MiddleTheoryOccasionalWhat is the network poller (netpoller) and what is its role in the scheduler?
What is the network poller (netpoller) and what is its role in the scheduler?
The netpoller is the runtime's event-based bridge to the OS readiness API (epoll, kqueue, IOCP). When a goroutine blocks on network I/O the runtime parks it and registers the fd with the netpoller, freeing the M to run others; when the fd is ready the poller marks the goroutine runnable.
Common mistakes
- ✗Thinking each blocked network connection costs an OS thread — the netpoller frees the M
- ✗Confusing network blocking (handled by the netpoller) with file or syscall blocking (M stays in kernel)
- ✗Believing the netpoller busy-polls sockets rather than waiting on an OS readiness event
Follow-up questions
- →How does file I/O blocking differ from network I/O blocking inside the runtime?
- →Which thread runs the netpoller's wait, and when is it polled?
MiddleTheoryOccasionalWhy does the scheduler keep a local run queue per logical-processor P alongside a global one?
Why does the scheduler keep a local run queue per logical-processor P alongside a global one?
A per-P local queue is owned by one P, so most operations need no lock and stay cache-local. The shared global queue needs a lock; it holds goroutines that overflow from full local queues and is polled periodically so they are not starved.
Common mistakes
- ✗Thinking the local queue holds blocked goroutines — it holds runnable ones, same as the global
- ✗Assuming every dequeue takes a global lock, missing that local queue access is lock-free
- ✗Believing the global queue is checked on every schedule rather than only periodically
Follow-up questions
- →What is the fixed capacity of a P's local run queue and what happens on overflow?
- →How often does a P poll the global run queue and why is that interval needed?
MiddleTheoryOccasionalHow does work-stealing redistribute goroutines across logical processors (Ps) in the Go scheduler?
How does work-stealing redistribute goroutines across logical processors (Ps) in the Go scheduler?
When a P's local run queue empties, its M tries the global queue, then steals from another randomly chosen P, taking about half of that victim's local queue. This keeps every P busy and balances load without a central dispatcher.
Common mistakes
- ✗Imagining a central balancer thread instead of each idle P stealing on its own
- ✗Thinking a steal takes one goroutine when it actually grabs about half the victim's queue
- ✗Believing the victim P is chosen by load metrics rather than at random
Follow-up questions
- →Why does a stealing P take half the victim's queue instead of just one goroutine?
- →What does an OS thread M do when a steal attempt finds every other P's queue empty?
SeniorTheoryRareHow does the runtime preempt a goroutine stuck in a tight loop with no function calls?
How does the runtime preempt a goroutine stuck in a tight loop with no function calls?
Since Go 1.14 the runtime uses asynchronous preemption: the sysmon thread sends the goroutine's M a signal (SIGURG). The signal handler stops the goroutine at a safe point and reschedules it, so a loop with no calls no longer monopolizes its P.
Common mistakes
- ✗Assuming the signal can stop the goroutine at any instruction, ignoring that it only suspends at a safe point with consistent stack metadata
- ✗Mixing up sysmon's role: thinking sysmon itself preempts the goroutine rather than just flagging it and sending the
SIGURGsignal - ✗Expecting async preemption to fire instantly, not after sysmon notices the goroutine has run past its ~10ms scheduling deadline
Follow-up questions
- →What is a safe point and why can the signal handler not stop a goroutine anywhere?
- →Which runtime thread sends the preemption signal and on what interval?
SeniorTheoryRareWhy do concurrent file reads spike the OS-thread count while concurrent socket reads do not?
Why do concurrent file reads spike the OS-thread count while concurrent socket reads do not?
Each blocking file read keeps its M stuck in the kernel, so the runtime must spin up or grab a fresh M to keep the handed-off P busy — N concurrent file reads pin up to N threads. A socket read parks the goroutine and registers its fd with the netpoller instead, returning the M to the scheduler, so one M serves many waiting sockets and the thread count stays flat.
Common mistakes
- ✗Assuming
GOMAXPROCScaps total OS threads — it boundsPs, while blocking file reads can push theMcount well above it - ✗Thinking the thread spike comes from opening files rather than from each blocking read pinning its
Min the kernel - ✗Expecting socket concurrency to grow threads too, missing that the netpoller lets one
Mserve many parked sockets
Follow-up questions
- →How does the runtime decide whether to reuse an idle
Mor create a new one for the handed-offP? - →What runtime knob limits how many
Ms blocking file reads can create, and what is its default?
SeniorTheoryRareWhat happens to a logical processor P when its goroutine enters a blocking syscall?
What happens to a logical processor P when its goroutine enters a blocking syscall?
The M stays blocked in the kernel with that goroutine, but it first detaches its P. The sysmon thread (or the M itself) hands the freed P to another M — idle or newly created — so the P's run queue keeps executing other goroutines.
Common mistakes
- ✗Thinking the P stays pinned to the blocked M, freezing its whole run queue
- ✗Believing the P is destroyed and recreated rather than handed off intact
- ✗Confusing a true blocking syscall with a network operation parked on the netpoller
Follow-up questions
- →What happens to the OS thread M and its goroutine when the blocking syscall finally returns?
- →How does this handoff differ from how the netpoller handles a network read?