The Go Runtime

The Go runtime is a small operating system written in Go (with some assembly) that is statically linked into every Go binary. It is not a separate process, not a virtual machine, and not loaded dynamically — when you compile package main, the toolchain links the entire runtime package into the executable, and a tiny assembly bootstrap hands control to it before your main ever runs. The runtime’s job is to provide the services a Go program assumes but the language does not express directly: scheduling goroutines onto OS threads, growing and shrinking goroutine stacks, allocating memory, collecting garbage, polling the network, handling panics, and answering reflection queries. The runtime’s own design notes describe its abstractions in operating-system terms — goroutines, machines (threads), and processors — and emphasize that core runtime objects are “heap allocated, but are never freed, so their memory remains type stable” (runtime/HACKING.md). This note is the map for Section 6 of the Go Internals MOC: it frames the runtime as a whole and links down to the scheduler, garbage collector, and allocator that do the real work.

Mental Model

Picture a Go binary as a program running on two operating systems at once. The outer OS — Linux, macOS, Windows — gives the process threads, memory pages, and a way to wait for I/O. The inner OS is the Go runtime: it takes those coarse, expensive primitives and resells them as cheap, fine-grained ones. The outer OS hands the runtime a handful of threads; the runtime multiplexes millions of goroutines onto them. The outer OS hands the runtime large memory regions; the runtime carves them into size-classed objects with a per-thread cache. The outer OS has a scheduler that switches threads every few milliseconds; the runtime has its own scheduler that switches goroutines at function calls and safe points. Your code sees only the inner OS.

graph TD
    subgraph "Go process"
        UC["your code: goroutines"]
        subgraph "Go runtime — the inner OS"
            SCHED["scheduler (G-M-P)"]
            GC["garbage collector"]
            ALLOC["memory allocator"]
            POLL["network poller"]
            SYS["sysmon monitor"]
        end
    end
    subgraph "host OS — the outer OS"
        THREADS["OS threads"]
        PAGES["virtual memory pages"]
        EPOLL["epoll / kqueue / IOCP"]
    end
    UC --> SCHED
    SCHED --> THREADS
    ALLOC --> PAGES
    POLL --> EPOLL
    GC --> ALLOC
    SYS --> SCHED

Diagram: the Go runtime sits between your code and the host OS, reselling coarse OS primitives as fine-grained ones. The insight: a Go program never calls pthread_create or mmap for each goroutine or object — it asks the runtime, which amortizes a small number of OS resources across an enormous number of language-level entities.

The reason this layering exists is the FAQ’s stated goal for goroutines: multiplex “independently executing functions … onto a set of threads” so that “when a coroutine blocks … the run-time automatically moves other coroutines on the same operating system thread to a different, runnable thread” (Go FAQ). The runtime is the machinery that makes “just write go f()” affordable.

Mechanical Walk-through

Bootstrap — how the runtime starts before main

When the OS loads a Go executable, control does not go to main.main. It goes to an architecture-specific assembly entry point (rt0_*) which calls runtime.rt0_go. That function initializes the runtime: it sets up the first machine (m0) and its scheduling goroutine (g0), establishes the first processor (p0), reads environment variables and GODEBUG settings, sizes GOMAXPROCS, starts the system monitor and the garbage collector’s background workers, and finally schedules a goroutine that runs the package initializers and then main.main. Only after all of that does your code begin. The runtime is “running” before, during, and after your main.

The core abstractions: G, M, P

The runtime models execution with three types, all defined in src/runtime/runtime2.go:

  • G — goroutine. A g is a lightweight thread of execution: a stack, a program counter saved in a gobuf, a status, and bookkeeping. See Goroutine.
  • M — machine. An m is an OS thread. The runtime’s notes describe an M as “an OS thread that can be executing user Go code, runtime code, a system call, or be idle.” Each M also owns a fixed-size system stack (g0) used for runtime code that must not grow or be preempted.
  • P — processor. A p represents “the resources required to execute user Go code, such as scheduler and memory allocator state.” Exactly GOMAXPROCS Ps exist. A P is the right to run Go code and the per-CPU caches that go with it.

The scheduler’s job is to pair these: a runnable G needs an M (a thread) and a P (the right and the caches) to run. The full mechanics are in GMP Scheduler Model; this note only names the pieces.

runtime/HACKING.md makes a subtle but important point: all g, m, and p objects “are heap allocated, but are never freed, so their memory remains type stable.” Type stability means a pointer to a g always points at a valid g even after that goroutine is dead — the object is recycled, never returned to the allocator. This lets the runtime manipulate scheduler structures without write barriers and without races against the garbage collector.

What the runtime provides

The runtime is several cooperating subsystems, each its own note:

The g0 system stack and stack switching

A subtle structural point: runtime code often must run on a stack that cannot grow and cannot be preempted — for example, the code that implements stack growth, or the garbage collector’s stop-the-world logic. Each M therefore has a special goroutine, g0, with a fixed system stack. Runtime functions switch onto g0 using systemstack, mcall, or asmcgocall. runtime/HACKING.md notes that to get the user goroutine you write getg().m.curg, because getg() alone may return the M’s g0 stub when running on the system stack. This distinction — user stack vs system stack — pervades the runtime source.

Code and Configuration

Inspecting the runtime from a program

import "runtime"
 
func main() {
	println(runtime.NumGoroutine())   // live goroutines right now
	println(runtime.NumCPU())         // logical CPUs the OS reports
	println(runtime.GOMAXPROCS(0))    // current P count; arg 0 = query only
 
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)         // snapshot of allocator/GC counters
	println(ms.HeapAlloc, ms.NumGC)   // live heap bytes, GC cycles so far
}

Line-by-line: NumGoroutine reads the scheduler’s accounting of live Gs. NumCPU is what the host OS reports, not the same as GOMAXPROCS. GOMAXPROCS(0) queries the current P count without changing it (a non-zero argument sets it). ReadMemStats briefly coordinates with the GC to fill a MemStats struct — the programmatic view of the allocator and collector. These functions are the supported, stable surface of the runtime; most of the runtime is private.

Configuring the runtime — environment knobs

The runtime reads its configuration from the environment at startup:

GOMAXPROCS=4        # cap the number of Ps (CPU parallelism)
GOGC=200            # let the heap grow to 200% before a GC cycle
GOMEMLIMIT=900MiB   # soft memory ceiling the GC tries to respect
GODEBUG=schedtrace=1000,gctrace=1   # emit scheduler + GC traces
GOTRACEBACK=all     # dump all goroutine stacks on a crash

GOMAXPROCS sizes the scheduler, GOGC and GOMEMLIMIT tune the GC pacer, and GODEBUG toggles instrumentation — see GODEBUG and Runtime Configuration Knobs. As of Go 1.25, GOMAXPROCS is container-aware: on Linux the runtime “considers the CPU bandwidth limit of the cgroup containing the process,” defaulting to the lower of the cgroup limit and the logical-CPU count, and updates it periodically as limits change (Go 1.25 notes). This is detailed in GMP Scheduler Model.

Common Misunderstandings

“The Go runtime is a VM, like the JVM.” No. There is no bytecode and no interpreter. Go compiles directly to native machine code; the runtime is ordinary compiled code linked into the binary. It is a library of OS-like services, not an execution engine.

“Go programs need a runtime installed.” No. The runtime is statically linked into each executable. A Go binary is self-contained (modulo cgo and dynamic linking choices — see Static and Dynamic Linking in Go).

“The garbage collector is the runtime.” The GC is one subsystem. The runtime is equally the scheduler, the allocator, the poller, stack management, and the panic machinery.

runtime.GC() and runtime.Gosched() are normal tuning knobs.” They are escape hatches for rare situations. Calling runtime.GC() forces a full collection synchronously; runtime.Gosched() yields the current goroutine. Routine code should not need either — if you reach for them often, something else is wrong.

“More OS threads means more parallelism.” Go parallelism is bounded by GOMAXPROCS (the P count), not by the number of Ms. The runtime may create many Ms (one per blocked syscall, for instance) but only GOMAXPROCS of them run Go code simultaneously. See GMP Scheduler Model.

Alternatives and When to Choose Them

The Go runtime is not optional for the gc toolchain — it is the model. The genuine alternatives are other Go implementations. gccgo uses the GCC back end and historically a different (often simpler) runtime; TinyGo ships a minimal runtime aimed at microcontrollers and WebAssembly, trading away goroutine-stack growth and full GC sophistication for tiny binaries. These are covered in gc Compiler vs gccgo vs TinyGo. Compared with other languages: Rust has no runtime in this sense (no GC, no green-thread scheduler — async executors are libraries you choose); the JVM has a heavier runtime that is an interpreter-plus-JIT and exposes far more tuning surface; Erlang’s BEAM is the closest analogue, a runtime that schedules millions of lightweight processes much as Go schedules goroutines. Choose Go’s model when you want cheap concurrency and automatic memory management with near-native performance and a self-contained binary; choose a runtime-free language when you cannot afford GC pauses or a background scheduler at all.

Production Notes

The runtime is largely invisible until it is not. The three places it surfaces in production are scheduling latency, GC pause and CPU overhead, and memory footprint. The first is diagnosed with the execution tracer (The Go Execution Tracer) and GODEBUG=schedtrace; the second with GODEBUG=gctrace=1 and heap profiles (GC Tuning and Observability); the third with MemStats and GOMEMLIMIT. A recurring real-world incident is a Go service in a container behaving as though it has the host’s CPU count — pre-Go-1.25 the runtime ignored cgroup limits, so GOMAXPROCS defaulted too high, the GC spawned too many mark workers, and the service was throttled by the kernel; Go 1.25’s container-aware default fixed the common case, but services pinning GOMAXPROCS manually still need to set it to the cgroup limit. The other recurring lesson from the runtime team’s own talks (Go GC keynote) is that the runtime is tuned for latency over throughput by default — it will spend CPU to keep pauses sub-millisecond, and programs that need the opposite trade-off must say so explicitly via GOGC and GOMEMLIMIT.

See Also