Runtime Source Dive — Junior¶
1. What is "the Go runtime"?¶
When you write go run main.go, your program isn't just the lines you typed. Two other things are stitched into the final binary:
- The Go standard library —
fmt,os,net/http, etc. - The Go runtime — a chunk of Go (plus a sprinkle of assembly) that manages goroutines, channels, memory, garbage collection, panics, and the bridge to the operating system.
The runtime is the layer that makes Go feel like Go. go func() { ... }() is just three tokens for you, but behind that the runtime spins up a tiny stack, hands it to a scheduler, and parks an OS thread waiting for work. None of that is your code — it's runtime code, shipped with every Go binary.
If you came from C, the runtime is what C doesn't have. If you came from Java, it's the JVM — except statically linked into the executable instead of installed separately.
2. Where the source lives¶
On your machine, the runtime source is inside the Go installation directory:
You'll see hundreds of .go and .s (assembly) files. The headline ones:
| File | What it covers |
|---|---|
proc.go | The scheduler — goroutines, M, P, G |
chan.go | Channel send/recv/close |
mheap.go, malloc.go | Memory allocator |
mgc.go, mgcsweep.go | Garbage collector |
panic.go | panic, recover, defer |
slice.go, map.go | Built-in data structures |
select.go | select statement |
stack.go | Goroutine stack growth |
time.go | Timers, time.Sleep |
The same code is on GitHub at github.com/golang/go/tree/master/src/runtime. Pin to a tag (e.g., go1.22.0) when you read — internals shift between versions.
3. Prerequisites¶
- Comfort with basic Go: goroutines, channels, slices, maps.
- A vague sense that "things must happen behind the scenes" when you write
go f(). - Willingness to read Go that looks normal but uses internal helpers (
mcall,gopark,systemstack).
You do not need assembly, OS internals, or compiler theory yet — those come in senior.md and professional.md.
4. Glossary¶
| Term | Meaning |
|---|---|
| Runtime | The Go code (and a bit of asm) embedded in every Go binary that runs goroutines, allocates memory, runs the GC |
| GOROOT | The directory where Go is installed; $GOROOT/src/runtime holds the source |
| GMP | Three runtime types: Goroutine, Machine (OS thread), Processor (logical CPU slot) |
| goroutine | A user-space thread managed by the runtime, not the OS |
| scheduler | The piece of the runtime that picks which goroutine runs on which M |
| mcall | A switch from a goroutine's stack to a special "scheduler" stack |
| gopark / goready | Park a goroutine (off the run queue); make it runnable again |
| systemstack | Run code on the OS-allocated stack instead of the goroutine stack |
| GC | Garbage collector — concurrent, tri-color mark-and-sweep |
5. Why read runtime source at all?¶
Most Go programmers never open runtime/proc.go. They don't need to — Go's whole point is that the runtime is invisible. So why bother?
- You will hit a bug that's runtime-shaped. Goroutine leak. Deadlock you can't reproduce. Surprising allocation. The fix is usually in your code, but understanding the symptom requires runtime knowledge.
- You will hit a performance question. "Why does this make 20M allocations?" "Why is GC pause 8ms?" Profilers (
pprof,trace) speak in runtime terms. - It demystifies Go. Goroutines aren't magic. Channels aren't magic. They're datastructures and algorithms — readable Go code. Once you've seen
runqgetinproc.go, "goroutines are lightweight threads" becomes "goroutines are entries in a per-P queue with an atomic head/tail".
You don't have to read all of it. You don't even have to understand half of it. The goal at this level is to know the map — what file does what — and to be able to open one and orient yourself.
6. A 30-second tour of proc.go¶
proc.go is the biggest file in the runtime (~6000+ lines). It owns the scheduler. The headline functions:
// Schedule a new goroutine. This is what `go f()` compiles to.
func newproc(fn *funcval)
// The main scheduling loop. Picks a runnable goroutine and runs it.
func schedule()
// Park the current goroutine — take it off the run queue.
func gopark(unlockf func(*g, unsafe.Pointer) bool, ...)
// Mark a goroutine as runnable again.
func goready(gp *g, traceskip int)
You don't run these from your code. They're called by: - The compiler (rewrites go f() into a newproc call). - Channel operations (gopark blocks the sender; goready wakes the receiver). - The network poller (gopark blocks on Read; goready wakes when an epoll event fires).
This is the "everything is just function calls" insight — there's no magic thread suspension. Blocked goroutines call gopark and the scheduler picks something else.
7. The G, M, P types¶
In runtime2.go you'll find the three structs that the scheduler revolves around:
type g struct {
stack stack // stack range for this goroutine
sched gobuf // saved registers (PC, SP) when parked
atomicstatus atomic.Uint32 // _Gidle, _Grunnable, _Grunning, _Gwaiting, ...
// ... ~100 more fields
}
type m struct {
g0 *g // a special "system" goroutine for scheduling work
curg *g // the goroutine currently running on this M
p puintptr // the P this M is bound to
// ... lots more
}
type p struct {
runq [256]guintptr // local run queue
runnext guintptr // priority slot
// ... more queues, caches, stats
}
- G = one goroutine. Cheap (KBs of memory). Many millions can exist.
- M = an OS thread. Created lazily, capped by
GOMAXPROCS+ blocked syscalls. - P = "permission to run Go code". One P per logical CPU you've asked for (default =
runtime.NumCPU()).
A G runs on an M, but only if that M holds a P. No P, no Go code — that's how GOMAXPROCS caps parallelism.
8. How to start exploring (a recipe)¶
go env GOROOT— find the source.- Open
runtime/proc.goin your editor with "Go to definition" enabled. - Search for
func schedule(— that's the entry to the scheduler loop. Read 40 lines top-to-bottom; don't try to understand every branch. - Follow one call:
findRunnable(where does a goroutine come from?). - Step away. Come back tomorrow.
Reading runtime code is a marathon, not a sprint. The first time you skim schedule() it's a wall of names you've never heard (stopm, pidleget, wakep). The third time, the names mean things.
Cheat code: start with the commit history of
runtime/proc.go. Many commits link to design proposals on the Go issue tracker. A 10-line change with a linked proposal often explains a 500-line region of code you'd never decode by reading.
9. The "go runtime" you can actually call¶
The exported runtime package (the one you import "runtime" in your code) is the tip of the iceberg. Useful functions for junior-level exploration:
import "runtime"
// How many goroutines exist right now?
runtime.NumGoroutine()
// How many CPUs is Go allowed to use?
runtime.GOMAXPROCS(0) // 0 means "just tell me, don't change it"
// Force a garbage collection. Useful in tests, not in production code.
runtime.GC()
// Read memory stats.
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Println("alloc:", m.Alloc, "heap objects:", m.HeapObjects)
These are thin wrappers over the unexported internals (gcount, gcStart, readmemstats_m). Reading the runtime source connects the exported API to what's actually happening.
10. A tiny example that touches the runtime¶
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("goroutine %d, total now: %d\n", id, runtime.NumGoroutine())
}(i)
}
wg.Wait()
fmt.Println("done, goroutines:", runtime.NumGoroutine())
}
Each go func(...) calls runtime.newproc under the hood. NumGoroutine() reads sched.gcount. sync.WaitGroup.Wait() ends up in runtime_Semacquire — a runtime hook that sync uses to block efficiently.
You don't see any of those calls in your code. They're hidden by the compiler and the standard library. Reading runtime source is how you make them visible.
11. Common confusion at this level¶
- "Goroutines are OS threads." No. A goroutine is
runtime.g, a struct. Many goroutines run on one OS thread (m). - **"
runtime.Gosched()issleep(0)." Kind of, but it specifically callsgosched_mto swap to the scheduler — not "be polite to the OS". - "The runtime is C." Mostly Go. A few low-level files (
asm_amd64.s,sys_linux_amd64.s) are assembly. The interesting algorithms (scheduler, GC, allocator) are in Go. - "The runtime is huge." It's ~50,000 lines. Big but readable. The Linux kernel scheduler is 10× that.
- "Channels use a mutex." Yes, internally.
runtime/chan.gohas ahchanstruct with alock. The optimization story is insenior.md.
12. The map you should leave with¶
$GOROOT/src/runtime/
├── proc.go # Scheduler: GMP, newproc, schedule, gopark
├── runtime2.go # Type definitions for G, M, P
├── chan.go # Channels (hchan, send, recv)
├── select.go # select
├── mheap.go # Heap arena management
├── malloc.go # mallocgc — the main allocation function
├── mgc.go # Garbage collector entry points
├── stack.go # Goroutine stack growth
├── panic.go # panic / recover / defer
├── time.go # Timers
├── netpoll.go # Network poller (epoll/kqueue/iocp)
├── sema.go # Semaphore primitive (used by sync.Mutex etc.)
└── asm_*.s # Architecture-specific assembly
If you can name these and roughly say what each does, you've achieved the junior-level goal of this topic.
13. Summary¶
The Go runtime is a chunk of Go (and a bit of assembly) embedded into every Go binary. It lives in $GOROOT/src/runtime. It runs goroutines (proc.go), manages channels (chan.go), allocates memory (malloc.go, mheap.go), and garbage-collects it (mgc.go). At this level the goal is not to understand it — it's to know the map, recognize the names, and lose the fear of opening the source. Tomorrow, open proc.go and read func schedule(. Don't try to finish it. Just look.
Further reading¶
- Go source:
https://github.com/golang/go/tree/master/src/runtime(pin to a tag likego1.22.0) - "The Go scheduler" — Daniel Morsing, 2013 — short and still accurate on the basics
- "Scalable Go Scheduler Design Doc" — Dmitry Vyukov, 2012 — the proposal that introduced P
runtimepackage docs:https://pkg.go.dev/runtime- "Go's work-stealing scheduler" — Jaana Dogan blog