gap·map

Go runtime & memory

[ middle depth ]

How the Go scheduler runs goroutines

Three letters carry the model. G is a goroutine (its stack and state), M is an OS thread (the thing the kernel actually schedules), and P is a processor: a scheduling context with a local run queue of ready goroutines. The rule that ties them together is that an M must hold a P to run Go code. The number of Ps is GOMAXPROCS, so GOMAXPROCS is the count of goroutines that can execute Go code in parallel, not a cap on how many goroutines exist.

This is M:N scheduling. Thousands of goroutines multiplex onto a handful of threads. When a goroutine blocks on a channel or a mutex, the scheduler parks it and runs the next G from the queue on the same thread, no kernel involvement.

Wrong "GOMAXPROCS is the maximum number of goroutines." It caps parallel execution slots (Ps). You can have a million goroutines with GOMAXPROCS=4; only four run Go code at any instant.

Why goroutine stacks are cheap

A new goroutine starts with a roughly 2KB stack (since Go 1.4), not the fixed 1-8MB an OS thread reserves. When the stack fills, the runtime allocates a larger contiguous block, copies the frames over, and fixes up pointers. It can also shrink the stack during GC. Small growable stacks are the reason a service can hold hundreds of thousands of goroutines, one per connection, without running out of memory.

Stack vs heap: what escape analysis decides

The compiler decides where each value lives. If it can prove a value does not outlive its function, that value stays on the stack and costs nothing to reclaim. If the value might escape the frame, it goes on the heap and the GC owns it later.

func leaks() *int {
	x := 42
	return &x // x escapes: its address outlives the frame -> heap
}

func stays() int {
	x := 42
	return x // just a value copy -> stack
}

Run go build -gcflags='-m' and the compiler prints its decisions ("moved to heap: x"). Common escapes: returning a pointer to a local, storing a value in an interface{} (anything passed to fmt.Println), capturing a variable in a closure that outlives the call, sending a pointer on a channel.

Fewer escapes mean fewer heap allocations, which means less work for the garbage collector. GOGC=100 (the default) triggers a GC once the heap has grown about 100% since the last cycle, so allocation volume, not the GC knob, is usually what you tune first.

[ senior depth ]

How work stealing and syscall handoff keep cores busy

Each P owns a local run queue (capacity 256) plus a shared global queue. An idle P does not wait for work handed down from a central dispatcher: it steals half of a random other P's local queue. Uneven producers still saturate every core, and there is no global lock on the hot path.

The interesting case is blocking. When a goroutine enters a blocking syscall, its M blocks in the kernel with it. The runtime detaches that M's P and hands it to another M (parked, or freshly spun up) so the P's remaining goroutines keep running. On return the M tries to reacquire a P; if none is free it stashes its G on the global queue and parks. Network I/O never costs a thread this way: the netpoller (epoll/kqueue) parks the goroutine on the fd and wakes it on readiness, so 50k idle connections ride a few Ms.

Wrong "One goroutine in a blocking syscall freezes the whole program." Only its M blocks. The P is handed off, and everything else runs. Preemption used to be the real gap: before Go 1.14 a tight loop with no function calls could starve the scheduler, because preemption only happened at call safepoints. Async preemption (a SIGURG signal) closed that.

How the Go garbage collector works

Concurrent tri-color mark-sweep, non-generational, and non-moving. Objects are white (unreached), gray (reached, children unscanned), or black (scanned). The mutator runs during marking, which creates a hazard: a black object could be made to point at a white object whose only other reference was just deleted, and that white object would be swept while live.

The hybrid write barrier (Go 1.8) prevents it. On every pointer store during marking, a combination of Dijkstra insertion and Yuasa deletion barriers shades the relevant pointers so the invariant holds, which also removed the stop-the-world stack re-scan. A cycle now has two brief STW pauses (mark setup and mark termination), each sub-millisecond in practice; the expensive marking runs concurrently.

The cost of non-moving and non-generational is more CPU spent scanning and no compaction, so fragmentation is handled by a size-classed allocator instead of relocating objects. For contrast, the JVM's common collectors are generational and moving, a different point on the pause-versus-throughput curve.

Tuning knobs: GOGC, GOMEMLIMIT, GOMAXPROCS

GOGC sets the trigger: target heap = live * (1 + GOGC/100), default 100. GOMEMLIMIT (Go 1.19) is a soft ceiling; the pacer runs GC harder as the heap nears it, which prevents OOM but does not guarantee staying under, and set too tight with no headroom it thrashes the GC. Since Go 1.25 GOMAXPROCS is container-aware on Linux: it reads the cgroup CPU bandwidth limit and caps itself, re-checking periodically, fixing the CFS throttling in Kubernetes pods that used to set GOMAXPROCS to the node's core count. Interviewers grade whether you reach for reducing allocations (escape analysis, pprof) before GOGC, and whether you know GOMEMLIMIT is a target, not a wall.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Go

was this useful?