Irbisa · Cheatsheet

Runtime, Scheduler & GC

G-M-P, preemption, escape analysis, GOGC, GOMEMLIMIT, pprof

Senior Developer · 25 questions

  1. 01

    Who frees memory in Go, and can a Go program still leak?

    The garbage collector frees memory: Go has no free and no manual deallocation (the builtin delete only removes a map key), and the compiler decides whether a value lives on the stack or the heap.

  2. 02

    How does the Go scheduler work: what are G, M and P, and why does P exist?

    The runtime runs many goroutines (G) on a few OS threads (M).

  3. 03

    With GOMAXPROCS(1) this program hung forever on Go 1.13 and prints done today. Is the Go scheduler cooperative or preemptive, and what changed?

    func main() {	runtime.GOMAXPROCS(1) 	go func() {		for {			// no function calls, no allocations		}	}() 	time.Sleep(10 * time.Millisecond)	fmt.Println("done")}

    It has been preemptive since Go 1.14.

  4. 04

    What does sysmon do in the Go runtime, and why does it run without a P?

    sysmon is a runtime thread that loops without a P and watches the scheduler from outside: it takes Ps away from threads stuck in system calls, preempts goroutines that ran too long, and keeps the network poller and the GC moving.

  5. 05

    This recursion has no base case. What does the program print, how large did the goroutine's stack get, and why does recover not catch it?

    func depth(n int) int {	return depth(n+1) + 1} func main() {	defer func() {		if r := recover(); r != nil {			fmt.Println("recovered:", r)		}	}()	fmt.Println(depth(0))}

    It prints runtime: goroutine stack exceeds 1000000000-byte limit and fatal error: stack overflow, then exits with status 2.

  6. 06

    What does GOMAXPROCS control, and how does a container CPU limit interact with it before and after Go 1.25?

    GOMAXPROCS is the number of Ps: how many threads may run Go code at the same time.

  7. 07

    How do you find the function that burns CPU in a Go service with pprof: enabling it, taking a profile, reading top and the flame graph, and what does it cost?

    Import net/http/pprof on a private port, take go tool pprof -http=:8081 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30' under real load, and read top and the flame graph by cumulative time.

    import (	"log"	"net/http"	_ "net/http/pprof" // registers /debug/pprof/* on http.DefaultServeMux)…
  8. 08

    What is escape analysis? Which of these functions put a value on the heap, and how do you check?

    type Point struct{ X, Y int } var global *Point func local() int {	p := &Point{1, 2}	return p.X} func returned() *Point {	p := Point{1, 2}	return &p} func stored()       { global = &Point{3, 4} }func printed(n int) { fmt.Println(n) }

    Escape analysis is the compiler deciding, for each value, whether it can outlive the function that creates it: if not, it stays on the stack and costs nothing to free; if it can, it goes to the heap.

  9. 09

    How does Go's garbage collector work: tri-colour marking, the write barrier, and when does the world stop?

    Go uses a concurrent, non-moving, tri-colour mark-and-sweep collector: it marks reachable objects while your goroutines keep running, stops the world only twice per cycle, usually for well under a millisecond, and sweeps lazily as memory is reused.

  10. 10

    What does GOGC control, why does a service with a 100 MB live heap use about 200 MB, and what happens when you raise it?

    GOGC sets how far the heap may grow past the live data before the next cycle: the goal is roughly live heap + (live heap + stacks + globals) × GOGC/100.

  11. 11

    What do GODEBUG=gctrace=1 and schedtrace=1000 print, and how do you read a gctrace line?

    gctrace=1 prints one line per GC cycle to stderr (pause and mark times, heap sizes and GC's share of CPU); schedtrace=1000 prints the scheduler's state every second.

    $ GODEBUG=gctrace=1,schedtrace=1000 ./service 2> runtime.log gc 42 @12.513s 4%: 0.031+3.1+0.052 ms clock, 0.25+0.40/6.1/9.0+0.42 ms cpu, 118->121->62 MB, 124 MB goal, 0 MB stacks, 0 MB globals, 8 P SCHED 13004ms: gomaxprocs=8 idleprocs=0 threads=14 spinningthreads=0 needspinning=0 idlethreads=4 runqueue=37 [12 9 30 4 18 22 7 15]
  12. 12

    Find the bug: the service keeps only a 256-byte preview per upload, yet the live heap grows by megabytes with every file.

    var mu sync.Mutexvar previews = map[string][]byte{} // first bytes, shown in the file list func saveUpload(id string, r io.Reader) error {	data, err := io.ReadAll(r) // uploads are 1–20 MB	if err != nil {		return err	}	if err := store(id, data); err != nil {		return err	}	mu.Lock()	previews[id] = data[:min(len(data), 256)]	mu.Unlock()	return nil}

    data[:256] is only a new header over the multi-megabyte array io.ReadAll returned, so each 256-byte preview keeps the whole upload reachable: the map grows by the size of every file, not by 256 bytes.

  13. 13

    What happens to the OS thread when a goroutine blocks on a channel, on a socket read, and on a file read or a cgo call?

    Only the last two hold a thread.

    // Regular-file reads and cgo calls each hold an OS thread while they run:// bound them, or one burst leaves you with thousands of threads.type diskPool struct{ sem chan struct{} } func newDiskPool(n int) *diskPool { return &diskPool{sem: make(chan struct{}, n)} }…
  14. 14

    How does the Go compiler decide what to inline, and why can inlining change whether a value escapes to the heap?

    The compiler inlines a function whose body costs at most 80 nodes of its syntax tree.

    type Point struct{ X, Y int } // -m: can inline NewPointfunc NewPoint(x, y int) *Point { return &Point{X: x, Y: y} } //go:noinline…
  15. 15

    How does the Go memory allocator work (size classes, mcache, mcentral, mheap), and how does it limit fragmentation without moving objects?

    Go's allocator, modelled on TCMalloc, rounds each small object up to one of about 68 size classes and serves it from a per-P cache without a lock.

  16. 16

    A service spends a third of its CPU in runtime.mallocgc and the GC workers. How do you cut allocations on the hot path, and how do you prove each fix?

    GC cost follows the allocation rate, not the heap size, so the fix is fewer allocations: find the top sites with -alloc_space, change them, and prove each change with allocs/op before and after.

    type Row struct {	Name  string	Count int64} var bufs = sync.Pool{New: func() any { return new(bytes.Buffer) }}…
  17. 17

    Find the bug: under load, some API responses contain another request's JSON.

    var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }} func encode(v any) ([]byte, error) {	buf := bufPool.Get().(*bytes.Buffer)	buf.Reset()	defer bufPool.Put(buf) 	if err := json.NewEncoder(buf).Encode(v); err != nil {		return nil, err	}	return buf.Bytes(), nil}

    buf.Bytes() returns a view of the pooled buffer's own memory, and the deferred Put gives that buffer to the next caller while this one still holds the view.

  18. 18

    The heap profile is flat at 300 MB, yet the container's RSS stays at 1.4 GB and runtime.GC() does not move it. Where is the memory?

    RSS is not the heap. It also counts goroutine stacks, runtime metadata and free heap pages the allocator kept instead of returning to the OS, while the heap profile shows only sampled live objects as of the last GC.

    // metrics.Read does not stop the world; runtime.ReadMemStats does, briefly.var samples = []metrics.Sample{	{Name: "/memory/classes/total:bytes"},         // everything the runtime has mapped	{Name: "/memory/classes/heap/released:bytes"}, // returned to the OS: not in RSS	{Name: "/memory/classes/heap/objects:bytes"},  // live objects plus unswept garbage	{Name: "/memory/classes/heap/free:bytes"},     // free pages the process still holds…
  19. 19

    How do you find a memory leak in a Go service, and when do you read inuse_space rather than alloc_space in a heap profile?

    Confirm the live heap keeps growing across GC cycles, then diff two inuse_space heap profiles taken minutes apart: what grew is the leak.

    # live heap now, by allocation sitego tool pprof -inuse_space 'http://127.0.0.1:6060/debug/pprof/heap?gc=1' # two snapshots ten minutes apart, then show only what grewcurl -so before.pb.gz 'http://127.0.0.1:6060/debug/pprof/heap?gc=1'sleep 600…
  20. 20

    A 2 GiB pod with a ~700 MB live heap is OOM-killed during traffic spikes, and an old runbook suggests a 1 GiB ballast. What do you set instead, and what does it count?

    Set GOMEMLIMIT a little below the container limit (about 1.8 GiB for a 2 GiB pod) and keep GOGC=100.

    // Read the cgroup v2 limit and keep headroom for what GOMEMLIMIT does not count.func setMemoryLimit(fraction float64) {	b, err := os.ReadFile("/sys/fs/cgroup/memory.max") // "max" when unlimited	if err != nil {		return	}…
  21. 21

    Under load, throughput collapses and gctrace shows back-to-back cycles with assist time larger than background marking. What is GC mark assist, and how do you fix it?

    Mark assist makes a goroutine that allocates while marking is in progress do a share of the marking before its allocation returns.

    // Is the collector being paid for by request goroutines?// Counters are cumulative since start: read twice and compare the deltas.// "dedicated" is the dedicated background marking workers.func gcShares() (assist, background float64) {	s := []metrics.Sample{		{Name: "/cpu/classes/gc/mark/assist:cpu-seconds"},…
  22. 22

    An architect rejects Go for a 40 GB in-memory cache because "the GC has to walk 40 GB with the world stopped". What is actually stopped, and what does grow with the heap?

    Nothing walks the heap with the world stopped: marking is concurrent, and the two pauses per cycle do a small fixed amount of work, usually well under a millisecond.

    // wrong: ten million pointers the marker follows on every cycletype slowCache struct{ m map[string]*Entry } // right: no pointers inside, so the GC does not scan the data at alltype cache struct {	index map[uint64]extent // hashed key -> position; no pointers in key or value…
  23. 23

    What changed in the garbage collector in Go 1.26 with Green Tea, and what should a team check when upgrading?

    Go 1.26 made Green Tea the default marking algorithm: instead of chasing small objects one by one across the heap, it queues whole spans (8 KB pages) and scans the marked objects on each page together.

  24. 24

    p99 is 800 ms but the CPU profile explains almost none of it. Which profiles show what requests were waiting for, and when do you need go tool trace?

    A CPU profile samples only goroutines that are running, so waiting never shows up in it.

    var fr = trace.NewFlightRecorder(trace.FlightRecorderConfig{	MinAge:   10 * time.Second, // keep at least the last 10 s	MaxBytes: 64 << 20,}) func main() {…
  25. 25

    What does a cgo call cost the scheduler, and why must C never keep a Go pointer, let alone a uintptr, after the call returns?

    A cgo call holds its OS thread for its whole duration and costs tens of nanoseconds, against about one for a Go call.

    /*#include <stddef.h>static void *kept;static void register_buffer(void *p, size_t n) { kept = p; }static void unregister_buffer(void) { kept = NULL; }*/…