The Go Runtime: Scheduler, GC, and Memory
Your Go source compiles to machine code, but execution is not bare metal.
Search across all documentation pages
Your Go source compiles to machine code, but execution is not bare metal.
A bundled runtime schedules goroutines, manages heap memory, runs the garbage collector, and coordinates syscalls so thousands of lightweight tasks share a small thread pool efficiently.
Runtime Basics collects runnable snippets for runtime.MemStats and GC metrics.
Sibling articles cover tri-color marking, Green Tea GC in Go 1.26, escape analysis, tuning knobs, stack growth, leak patterns, and production workflows.
pprof heap and goroutine profiles.GOMAXPROCS limits.pprof profiling, container memory limits, compiler escape analysis, observability metrics.Picture a running Go process as three cooperating subsystems.
Scheduling decides which goroutine runs on which OS thread.
Allocation hands out heap blocks when values cannot live on the stack.
Garbage collection traces live pointers and frees unreachable heap memory.
Goroutines are not OS threads.
Each goroutine starts with a small stack (on the order of a few KiB) that grows on demand.
The runtime parks goroutines that block on channels, mutexes, or I/O so the underlying thread can run other work.
The GPM model names the pieces:
GOMAXPROCS sets how many Ps exist.
That caps how many goroutines execute Go bytecode in parallel on CPU cores, not how many goroutines you may create.
Heap objects are created when the compiler cannot prove a value's lifetime fits a stack frame.
Returning a pointer to a local variable, storing data in interfaces or maps with unknown lifetime, or capturing variables in closures that outlive the frame commonly force heap allocation.
The allocator organizes memory in size classes inside pages (typically 8 KiB in the Go heap).
The garbage collector is tracing: it walks pointers from roots (globals, stacks, registers) and marks reachable objects.
Unreachable objects are swept and their memory returned to the allocator.
The scheduler and GC interact constantly.
When the GC needs to inspect stacks or enforce a stop-the-world (STW) phase, goroutines are paused briefly.
Most marking work runs concurrently with your code via write barriers that track pointer updates while the mark phase proceeds.
The classic algorithm is tri-color mark-sweep:
white = not yet visited (candidate garbage)
gray = visited, children not fully scanned
black = visited, children fully scanned
The runtime maintains the invariant that no black object points to a white object without a gray object in between.
Pacing decides when the next GC cycle starts.
By default GOGC=100 means the heap may grow to roughly 100% of live memory since the last collection before triggering another cycle.
GOMEMLIMIT (Go 1.19+) adds a soft memory cap so the collector paces more aggressively as RSS approaches a limit - critical in Kubernetes pods with hard cgroup ceilings.
Go 1.26 makes Green Tea GC the default collector.
Instead of a per-object work list that jumps randomly through the heap, Green Tea batches scanning by page, improving CPU cache locality and enabling vectorized scan loops on modern x86 hardware.
Opt out at build time with GOEXPERIMENT=nogreenteagc if you need the legacy mark path for comparison.
// Illustrative: inspect runtime state from application code
import "runtime"
func snapshot() {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
_ = ms.HeapAlloc // bytes in use on heap
_ = ms.NumGC // completed GC cycles
_ = ms.PauseTotalNs
}Escape analysis runs at compile time.
When a value's address cannot escape its function, the compiler allocates on the stack - no GC involvement, freed on return.
When it escapes, every allocation becomes GC work.
Production services instrument the runtime rather than guessing.
Export runtime/metrics or parse MemStats for HeapAlloc, HeapInuse, StackInuse, NumGoroutine, and per-cycle PauseNs.
Pair heap profiles from net/http/pprof with allocation traces to find hot make calls or unintended boxing.
Container sizing should account for stack memory (goroutine count times average stack) plus heap goal driven by GOGC and GOMEMLIMIT.
A service with millions of parked goroutines can use substantial RSS even when heap objects are modest.
| Knob | Strength | Weakness | Best Fit |
|---|---|---|---|
| Default GC (Green Tea) | Lower mark CPU on typical heaps | Some irregular heaps see less benefit | Go 1.26+ services without special GC needs |
GOGC tuning | Trades CPU for memory or vice versa | Mis-tuning raises latency spikes | Batch workers vs latency-sensitive APIs |
GOMEMLIMIT | Respects cgroup caps | Soft limit; OOM still possible under spikes | Kubernetes, serverless, shared hosts |
Manual runtime.GC() | Forces collection for tests | Hurts prod latency if abused | Benchmarks, diagnostic snapshots only |
sync.Pool | Reuses transient buffers | Pool entries can be cleared at GC | Serialization buffers, encode scratch space |
Finalizers (runtime.SetFinalizer) run arbitrary code during GC and are easy to misuse.
Prefer explicit Close() methods and defer for resources.
STW pauses are short in modern Go but still matter for tail latency.
Measure with GODEBUG=gctrace=1 during load tests, not only in dev.
GOMAXPROCS goroutines run Go code on CPU at once; excess goroutines queue or park.Goroutine scheduler, memory allocator, garbage collector, stack management, reflection support, panic/defer machinery, and interfaces to the OS (syscalls, signals, timers).
Your binary links it automatically - there is no separate JVM-style install.
OS threads are heavy (MB-scale stacks, kernel scheduling).
Goroutines are cheap (KiB-scale stacks, user-space scheduling).
Ms execute goroutines; Ps spread work across cores; blocking operations park goroutines without blocking threads unnecessarily.
When heap growth crosses the GC trigger derived from GOGC, live heap size, and optionally GOMEMLIMIT.
You can also trigger manually with runtime.GC() for diagnostics.
A small piece of code the compiler inserts when pointers are updated during concurrent marking.
It notifies the GC so it does not miss live objects while the heap mutates under running goroutines.
Green Tea became the default collector.
It scans by heap page instead of individual objects on the work list, improving locality and reducing mark-phase CPU for many workloads.
Build with GOEXPERIMENT=nogreenteagc to use the legacy algorithm.
Indirectly.
More Ps mean more parallel mark workers during GC, but also more concurrent mutator CPU competing for cache and memory bandwidth.
Match GOMAXPROCS to container CPU limits.
Live data remains allocated.
HeapAlloc after GC reflects reachable objects plus allocator fragmentation and unused spans not yet returned to the OS.
HeapIdle and HeapReleased show memory returned to the OS over time.
Goroutine stacks are not garbage-collected objects but still consume RSS.
Deep recursion or millions of goroutines increase StackInuse in MemStats independently of heap pressure.
Not in production builds.
GOGC=off exists for specialized benchmarking but is unsafe for long-running services because the heap grows without bound.
Stack allocations skip the heap entirely.
Every escape forces an allocation the GC must eventually trace and sweep.
High allocation rate is the main driver of GC CPU time.
process_resident_memory_bytes, go_memstats_heap_alloc_bytes, go_memstats_gc_cpu_fraction, goroutine count, and tail latency during GC phases.
Alert on sustained growth, not single spikes after deploy.
TinyGo and wazero hosted builds use different runtimes with distinct GC and scheduling trade-offs.
This section focuses on the standard cmd/compile runtime for Linux/macOS/Windows server binaries.
MemStats and goroutine count examplesStack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026