Operating Systems Through the Go Runtime
A mini course that teaches operating systems concepts through the Go runtime.
Why the Go Runtime?
The Go runtime is essentially a user-space operating system. It:
- Schedules thousands of goroutines onto a small number of OS threads
- Manages memory with its own allocator and garbage collector
- Multiplexes I/O using epoll/kqueue so blocking calls don’t block threads
- Handles signals for preemption and OS integration
- Manages stacks that grow and shrink dynamically
All of this is written in readable Go (with some assembly for the lowest-level operations), making it far more approachable than a kernel like Linux while covering the same fundamental concepts.
Course Structure
| Module | Topic | Duration |
|---|---|---|
| 1 | Introduction: The Runtime as an OS | 45 min |
| 3 | Processes, Threads, and Goroutines | 60 min |
| 4 | The Go Scheduler | 75 min |
| 5 | Work Stealing and Preemption | 60 min |
| 6 | Synchronization Primitives | 60 min |
| 7 | Channels and Select | 60 min |
| 10 | File Systems, I/O, and the Network Poller | 60 min |
Optional Modules
| Module | Topic | Duration |
|---|---|---|
| 2 | System Calls | 60 min |
| 8 | Memory Management | 60 min |
| 9 | Goroutine Stacks | 45 min |
Materials
Each module includes:
- Lecture notes — Detailed reading material with Go runtime code walkthroughs
- Comprehension checks — Short questions to verify understanding
Additional resources:
- Programming Assignments — Three hands-on projects
- Final Exam — Comprehensive assessment
- Glossary — Key terms defined
- Code Walkthrough — Trace a goroutine’s full lifecycle
- Experiments — Hands-on runtime exploration
- Further Reading — Books, papers, and resources
Getting Started
- Read the syllabus for the full course overview and learning outcomes
- Clone the Go source tree to follow along with code references
- Start with Module 1
All code references use the format file.go:line and point to files under
src/runtime/ in the Go source tree unless otherwise noted.
Operating Systems Through the Go Runtime
Course Overview
This mini course teaches core operating systems concepts using the Go runtime as a living, readable case study. Rather than studying OS theory in isolation, students will read and analyze production code that implements scheduling, concurrency, memory management, and I/O — the same code that runs every Go program.
The Go runtime is an ideal subject because it is essentially a user-space operating system: it multiplexes thousands of goroutines onto OS threads, manages its own memory allocator and garbage collector, handles signals, and implements its own I/O scheduler. All of this is written in readable Go (with some assembly), making it far more approachable than a kernel like Linux.
Prerequisites: An introductory systems programming or computer organization course (or equivalent). Students should be comfortable reading C-like code and have basic familiarity with concepts like pointers, memory addresses, and CPU registers. Go syntax is introduced as needed — prior Go experience is helpful but not required.
Source code: All code references point to the Go source tree. Students should have
a copy available (the course references files under src/runtime/ and src/os/).
Learning Outcomes
By the end of this course, students will be able to:
- Explain the role of an operating system and identify which OS responsibilities the Go runtime handles in user space.
- Distinguish processes, OS threads, and goroutines, and explain why user-level threading is useful.
- Describe the GMP scheduling model (goroutines, machines, processors) and trace a goroutine through its lifecycle states.
- Explain work stealing as a distributed scheduling strategy and analyze its tradeoffs versus centralized scheduling.
- Analyze low-level synchronization primitives (futexes, semaphores, spin locks) and explain how they build on hardware atomics and OS support.
- Read and explain the channel implementation, including the blocking/waking mechanism and the select algorithm.
- Trace an I/O operation from
os.File.Read()through the internal poller to epoll/kqueue, and explain how the runtime makes blocking I/O non-blocking.
Optional learning outcomes:
- Trace how a system call works from a Go function call through the syscall package down to the hardware instruction, and explain the user/kernel boundary.
- Describe a multi-level memory allocator and explain how per-CPU caches, size classes, and spans reduce fragmentation and contention.
- Explain how goroutine stacks grow and shrink dynamically, contrasting this with fixed-size OS thread stacks.
Module Schedule
| # | Module | Duration | Key Runtime Files |
|---|---|---|---|
| 1 | Introduction: The Runtime as an OS | 45 min | runtime/ overview |
| 3 | Processes, Threads, and Goroutines | 60 min | runtime2.go, os_linux.go |
| 4 | The Go Scheduler | 75 min | proc.go, runtime2.go |
| 5 | Work Stealing and Preemption | 60 min | proc.go, signal_unix.go |
| 6 | Synchronization Primitives | 60 min | lock_futex.go, sema.go, rwmutex.go |
| 7 | Channels and Select | 60 min | chan.go, select.go |
| 10 | File Systems, I/O, and the Network Poller | 60 min | netpoll.go, os/file.go, internal/poll/ |
Total: 7 hours (allows flexibility for questions and discussion)
Optional Modules
| # | Module | Duration | Key Runtime Files |
|---|---|---|---|
| 2 | System Calls | 60 min | sys_linux_amd64.s, syscall/ |
| 8 | Memory Management | 60 min | malloc.go, mheap.go, mcache.go |
| 9 | Goroutine Stacks | 45 min | stack.go |
Assessments
| Assessment | Description | Weight |
|---|---|---|
| Reading Checks (per module) | Short comprehension questions after each module | 20% |
| Assignment 1: System Call Tracer | Build a tool that traces Go program system calls | 20% |
| Assignment 2: Goroutine Scheduler | Implement a simplified GMP scheduler | 30% |
| Assignment 3: Concurrent Data Structure | Build a channel-like primitive from scratch | 15% |
| Final Exam | Comprehensive exam covering all modules | 15% |
Narrative Arc
The course tells a coherent story, building from the bottom up:
- Module 1 establishes the foundation: what an OS does and how the Go runtime acts as a user-space operating system.
- Modules 3–5 cover the scheduler — the heart of the runtime. Students learn why goroutines exist, how the GMP model works, and how work stealing keeps all CPUs busy.
- Modules 6–7 explore concurrency from the programmer’s perspective: how locks and channels are implemented on top of the scheduler primitives (gopark/goready) introduced in Modules 4–5.
- Module 10 ties everything together by showing how I/O integrates with the scheduler, completing the picture of the runtime as a full OS.
The optional modules — System Calls (Module 2), Memory Management (Module 8), and Goroutine Stacks (Module 9) — provide deeper dives into specific subsystems and can be covered as time permits.
How to Use These Materials
- Notes provide detailed reading material with code walkthroughs. Read these before or after the corresponding lecture.
- Comprehension checks are short questions to verify understanding of each module.
- Assignments are hands-on programming exercises that reinforce key concepts.
- The exam tests conceptual understanding across all modules.
All code references use the format file.go:line and point to the Go source tree
under src/runtime/ unless otherwise noted. Students are encouraged to open the
actual source files and read the surrounding context.
Module 1: Introduction - The Runtime as an OS
Duration: 45 minutes
Background: What Makes an Operating System?
At its core, an operating system is the layer of software that stands between application code and raw hardware, solving three fundamental problems. First, it manages resources: a machine has a finite number of CPU cores, a fixed amount of RAM, and a handful of I/O devices, yet dozens or hundreds of programs need to share them simultaneously. Second, it provides abstraction: rather than forcing every programmer to understand the registers of a particular NIC or the geometry of a disk, the OS exposes uniform interfaces – files, sockets, virtual address spaces – that hide hardware specifics. Third, it enforces protection and isolation: one process’s bug should not be able to overwrite another’s memory or starve the rest of the system of CPU time. These three pillars – multiplexing, abstraction, and isolation – appear in every OS textbook from Tanenbaum to OSTEP, and they have driven the design of operating systems for over half a century.
Linux is the most widely deployed kernel in the world, running on everything from Android phones to supercomputers. It is a monolithic kernel: the process scheduler, virtual memory subsystem, file system layer (VFS), device drivers, and networking stack all execute in a single, shared address space in kernel mode. The kernel has grown from roughly 10,000 lines of C in Linus Torvalds’s 1991 original to well over 30 million lines of code today, with contributions from thousands of developers. Linux on x86-64 exposes over 450 system calls – the narrow interface through which user-space programs request kernel services. When a process calls read(), the CPU executes a SYSCALL instruction that traps into kernel mode, where the kernel validates arguments, performs the I/O (perhaps scheduling the process to sleep while waiting for a disk), and eventually returns a result. Processes get the illusion of private address spaces through page tables managed by the memory subsystem. The Completely Fair Scheduler (CFS) distributes CPU time across runnable threads using a red-black tree keyed by virtual runtime. All of this machinery exists to solve the same three problems: sharing hardware, presenting clean interfaces, and keeping programs from interfering with each other.
What is less commonly appreciated is that language runtimes have been reinventing these same mechanisms in user space for decades. The Erlang BEAM virtual machine is perhaps the most striking example: it implements its own lightweight processes (a single BEAM instance can run millions of them), its own preemptive scheduler that interrupts a process after a fixed number of “reductions” (abstract units of work), per-process garbage-collected heaps, and its own I/O multiplexing layer. An Erlang application running on BEAM looks structurally more like a small operating system than a typical user-space program. The Java Virtual Machine (JVM) similarly manages its own heap with sophisticated garbage collectors (G1, ZGC, Shenandoah), maps application threads onto OS threads with its own scheduling hints, and provides a bytecode-level abstraction that decouples programs from the underlying hardware and OS. This pattern is not entirely new – Smalltalk images in the 1970s managed their own object memory and green threads, and the Lisp machines of the 1980s ran language runtimes directly on custom hardware, blurring the line between OS and runtime entirely. Even in the world of OS research, projects like MIT’s Exokernel (1995) argued that the kernel should do as little as possible, pushing resource management into application-level “library operating systems” – a philosophy that rhymes with what modern language runtimes actually do, albeit above a conventional kernel rather than a minimal one.
The Go runtime sits squarely in this tradition. It is a substantial piece of systems software – roughly 150,000 lines of Go and assembly – that ships embedded in every compiled Go binary. It implements its own user-space thread scheduler (goroutines and the G-M-P model), its own memory allocator and garbage collector, its own I/O multiplexer (the “netpoller,” built on top of epoll or kqueue), and its own mechanisms for preemption and synchronization. Unlike the BEAM or JVM, Go compiles to native machine code and interacts with the kernel through direct system calls rather than an interpreter or JIT, which makes its runtime a particularly transparent case study: you can trace the path from a goroutine sending on a channel all the way down to a futex system call, reading real production code at every layer. This module introduces the Go runtime through the lens of operating systems – showing how the same problems that motivate kernel design (scheduling, memory management, I/O multiplexing, synchronization) reappear in the runtime, and how studying the runtime gives you a concrete, readable codebase in which to explore them.
1. What is an Operating System? (10 min)
An operating system performs three fundamental jobs:
- Resource management – multiplexing scarce hardware (CPUs, memory, disks, network) across many competing programs.
- Abstraction – hiding hardware details behind clean interfaces (files, processes, virtual memory) so programs don’t need to know whether they’re talking to an NVMe drive or a spinning disk.
- Protection – isolating programs from each other so one buggy process can’t corrupt another’s memory or monopolize the CPU.
Every OS textbook opens with these three pillars. What’s less commonly taught is that a language runtime solves the same problems, just one level up the stack.
2. The Go Runtime as a User-Space OS (10 min)
The Go runtime is a substantial piece of systems software (~150k lines of Go and assembly) that ships inside every Go binary. It implements:
| OS Concept | Kernel Implementation | Go Runtime Implementation |
|---|---|---|
| Processes/threads | fork, clone, kernel scheduler | Goroutines (G), runtime.newproc |
| CPU scheduling | CFS (Completely Fair Scheduler), time slices, run queues | Work-stealing scheduler, per-P run queues |
| Virtual memory | Page tables, mmap | Garbage-collected heap, mheap, mspan |
| I/O multiplexing | epoll, kqueue, io_uring | Integrated netpoller (netpoll_epoll.go) |
| System calls | SYSCALL instruction, trap table | entersyscall/exitsyscall handoff |
| Preemption | Timer interrupts, signals | Cooperative + async preemption via SIGURG |
The analogy is not perfect – the Go runtime runs in user space, relies on the real kernel for actual hardware access, and doesn’t enforce hard protection boundaries. But the design problems it faces are remarkably similar to those of an OS kernel, and studying the runtime gives students a way to read real, production-quality systems code that solves the same problems covered in an OS course.
Why study the Go runtime instead of (or alongside) a real kernel?
- Readable: The runtime is written mostly in Go, with targeted assembly only where necessary. Compare this to the Linux kernel’s mix of C, macros, and architecture-specific assembly.
- Self-contained: A single
src/runtime/directory (~250 files) contains the scheduler, memory allocator, garbage collector, and platform abstraction layer. - Runnable: Students can instrument the runtime, rebuild it with
GOROOT, and immediately run programs against their modified runtime. - Production-quality: Unlike teaching kernels (xv6, PintOS), this is code that runs every Go program in production at Google, Cloudflare, and thousands of other companies.
3. Tour of the Runtime Source Tree (10 min)
The Go runtime lives at src/runtime/ within the Go source tree. Here are the key files and their roles:
Core Scheduling
| File | Role |
|---|---|
proc.go | The heart of the scheduler. Contains schedule(), findRunnable(), goroutine creation (newproc), entersyscall/exitsyscall, sysmon, and the work-stealing loop. |
runtime2.go | Data structure definitions for g, m, p, and the global schedt scheduler state. |
runtime1.go | Runtime initialization, GOMAXPROCS, environment variable parsing. |
Memory Management
| File | Role |
|---|---|
malloc.go | The memory allocator: mallocgc, size classes, mcache/mcentral/mheap hierarchy. |
mheap.go | Heap structure, page-level allocation, mspan management. |
mgc.go | Garbage collector: concurrent mark-sweep, GC pacing, write barriers. |
stack.go | Goroutine stack allocation, growth, and shrinking. |
Platform Abstraction (System Calls)
| File | Role |
|---|---|
sys_linux_amd64.s | Raw system call wrappers for Linux/amd64: read, write, mmap, clone, futex. |
sys_darwin_arm64.s | System call trampolines for macOS/arm64 (calls through libc). |
os_linux.go | Linux-specific OS interface: thread creation, signal setup. |
os_darwin.go | macOS-specific OS interface. |
I/O and Networking
| File | Role |
|---|---|
netpoll_epoll.go | Linux I/O multiplexing via epoll. |
netpoll_kqueue.go | macOS/BSD I/O multiplexing via kqueue. |
netpoll.go | Platform-independent netpoller interface. |
Synchronization
| File | Role |
|---|---|
chan.go | Channel implementation: chansend, chanrecv, wait queues. |
sema.go | Semaphore-based synchronization (used by sync.Mutex). |
lock_futex.go | Lock implementation using Linux futexes. |
lock_sema.go | Lock implementation using semaphores (macOS, Windows). |
4. The Key Abstractions: G, M, P (10 min)
The Go scheduler is built around three core data structures, defined in src/runtime/runtime2.go. The comment at the top of src/runtime/proc.go, lines 24-34 introduces them:
// src/runtime/proc.go, lines 24-34
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
//
// Design doc at https://golang.org/s/go11sched.
G – Goroutine
A g represents a goroutine. It holds the goroutine’s stack, saved registers (program counter, stack pointer), status, and a pointer to the m currently running it (src/runtime/runtime2.go, lines 473-509):
// src/runtime/runtime2.go, lines 473-509
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
syscallbp uintptr // if status==Gsyscall, syscallbp = sched.bp to use in fpTraceback
...
atomicstatus atomic.Uint32
goid uint64
A goroutine moves through well-defined states (also in runtime2.go, lines 35-77):
_Gidle(0) – just allocated, not yet initialized_Grunnable(1) – on a run queue, ready to execute_Grunning(2) – currently executing on an M with a P_Gsyscall(3) – executing a system call, not running Go code_Gwaiting(4) – blocked (channel, mutex, sleep, etc.)_Gdead(6) – finished execution or on a free list
M – Machine (OS Thread)
An m represents an OS thread. It points to the goroutine it’s currently running (curg), its associated P, and a special g0 goroutine used for scheduling stack frames (src/runtime/runtime2.go, lines 618-653):
// src/runtime/runtime2.go, lines 618-653
type m struct {
g0 *g // goroutine with scheduling stack
...
curg *g // current running goroutine
...
p puintptr // currently attached P
nextp puintptr
oldp puintptr // The P that was attached before executing a syscall.
id int64
...
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
Key insight: m.g0 is a special goroutine whose stack is used whenever the scheduler itself needs to run. Scheduling decisions happen on g0’s stack, not on any user goroutine’s stack. This is analogous to how a kernel switches to a kernel stack to handle interrupts.
P – Processor (Logical CPU)
A p represents a “logical processor” – a resource token that an M must hold to execute Go code. The number of Ps equals GOMAXPROCS (default: number of CPU cores) (src/runtime/runtime2.go, lines 772-818):
// src/runtime/runtime2.go, lines 772-818
type p struct {
id int32
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
mcache *mcache
...
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time slice.
runnext guintptr
The P is the key innovation in Go’s scheduler design (introduced in Go 1.1). Each P has:
- A local run queue (
runq– a circular buffer of 256 goroutines) that can be accessed without locks - An mcache for fast, lock-free memory allocation
- A runnext slot for cache-friendly goroutine handoff
Without Ps, every goroutine operation would require locking a global run queue, creating a scalability bottleneck. The P design enables the work-stealing algorithm: when an M’s P runs out of goroutines, it steals from other Ps’ run queues.
How G, M, P Relate
┌─────┐ ┌─────┐ ┌─────┐
│ P │ │ P │ │ P │ (GOMAXPROCS = 3)
│runq │ │runq │ │runq │
└──┬──┘ └──┬──┘ └─────┘
│ │ (idle)
┌──┴──┐ ┌──┴──┐
│ M │ │ M │ ┌─────┐
│(thr)│ │(thr)│ │ M │ (blocked in syscall,
└──┬──┘ └──┬──┘ │ │ no P attached)
│ │ └──┬──┘
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│ G │ │ G │ │ G │
│(run)│ │(run)│ │(sys)│
└─────┘ └─────┘ └─────┘
An M must acquire a P before it can run Go code. When a goroutine enters a blocking system call, the runtime can detach the P from that M and hand it to a different M, keeping the CPU utilized. This is covered in the Processes, Threads, and Goroutines module.
5. Course Roadmap Preview (5 min)
This course explores OS concepts through the lens of the Go runtime. The seven core modules tell a bottom-up story:
- Introduction (this module) – The runtime as a user-space OS; G, M, P overview
- Processes, Threads, and Goroutines – OS threads vs. goroutines; the G and M structs; goroutine states
- The Go Scheduler – The scheduling loop;
schedule(),findRunnable(),execute(); run queues - Work Stealing and Preemption – Distributed scheduling; cooperative and asynchronous preemption via
SIGURG - Synchronization Primitives – Futexes, semaphores, spin locks;
sync.Mutexinternals - Channels and Select – Channel implementation;
hchan,chansend/chanrecv; theselectgoalgorithm - File Systems, I/O, and the Network Poller –
epoll/kqueue; the integrated netpoller; non-blocking I/O
Three optional modules provide deeper dives into specific subsystems:
- System Calls – How Go crosses the user/kernel boundary;
SYSCALLinstruction;entersyscall/exitsyscall - Memory Management – Virtual memory concepts; Go’s allocator hierarchy; size classes; garbage collection
- Goroutine Stacks – Growable stacks; stack growth and copying; contrast with fixed OS thread stacks
Suggested Exercises
- Explore the source: Clone the Go repository. Open
src/runtime/proc.goand read the top-level comment (lines 24-80). Identify whereschedule()is defined. - Count goroutines: Write a Go program that spawns 100,000 goroutines, each sleeping for 1 second. Use
runtime.NumGoroutine()to observe the count. How much memory does each goroutine consume? - GOMAXPROCS experiment: Run a CPU-bound benchmark with
GOMAXPROCS=1,2,4, and your machine’s core count. Observe the speedup and diminishing returns. - Build the runtime: Modify
src/runtime/proc.goto add aprintln("schedule called")at the top ofschedule(). Rebuild the toolchain with./make.bashand run a simple program. How often isschedule()called?
Key Definitions
- Goroutine (G): A lightweight, user-space thread of execution managed by the Go runtime. Initial stack size is 2 KB (vs. ~1 MB for OS threads).
- Machine (M): An OS thread. The runtime creates Ms as needed and parks them when idle.
- Processor (P): A logical scheduling context. An M needs a P to run Go code. The number of Ps is set by
GOMAXPROCS. - Work stealing: When a P’s run queue is empty, its M steals goroutines from other Ps’ run queues, balancing load without central coordination.
- sysmon: A special background thread (M without a P) that monitors for stuck goroutines, retakes Ps from long syscalls, and triggers GC.
Further Reading
- The Go scheduler design document (Dmitry Vyukov, 2012)
- Go source:
src/runtime/proc.go– The scheduler - Go source:
src/runtime/runtime2.go– Core data structures - GopherCon 2018: Kavya Joshi - The Scheduler Saga
Module 1: Introduction (The Runtime as an Operating System)
Comprehension Check
Question 1 (Short Answer)
List at least four responsibilities that a traditional operating system kernel handles, and for each one, identify the corresponding component or subsystem in the Go runtime.
Answer
| OS Responsibility | Go Runtime Equivalent |
|---|---|
| Process/thread scheduling | Goroutine scheduler (schedule(), findRunnable() in proc.go) |
| Memory management / virtual memory | mheap, mcache, mcentral, garbage collector |
| Synchronization primitives (mutexes, semaphores) | runtime.mutex (futex-based), sema.go, rwmutex |
| I/O and file system access | Network poller (netpoll.go), os.File, poll.FD |
| Stack management | Growable stacks (stack.go, newstack, copystack) |
| Inter-process communication | Channels (chan.go, hchan) |
Question 2 (True/False with Explanation)
True or False: The Go runtime runs entirely in user space and never makes system calls to the kernel.
Answer
False. The Go runtime runs in user space but relies heavily on the kernel for essential services. It makes system calls for:
- Creating OS threads (
cloneon Linux) - Futex operations for low-level synchronization
- Memory mapping (
mmap) to obtain memory from the OS - I/O multiplexing (
epoll_create,epoll_ctl,epoll_waiton Linux;kqueueon macOS/BSD) - Signal handling
The runtime acts as a “mini OS” by providing higher-level abstractions (goroutines, channels, GC) on top of these kernel primitives, but it cannot function without the underlying kernel.
Question 3 (Code Reading)
Consider this comment from the top of runtime/proc.go:
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
Why does the design separate M (machine/thread) from P (processor)? What problem would arise if every M always had exclusive access to a P?
Answer
The separation of M and P exists to decouple OS thread management from goroutine scheduling resources. Key reasons:
-
Syscall handling: When a goroutine makes a blocking system call, the M enters the kernel and blocks. If the M permanently owned the P, that P’s entire run queue of goroutines would stall. By detaching the P from a blocked M, the runtime can hand the P (and its run queue) to another M that can continue executing goroutines.
-
Limiting parallelism: The number of Ps (set by
GOMAXPROCS) controls the degree of true parallelism. There can be many more Ms than Ps (Ms are created for blocking syscalls, cgo calls, etc.). Without this separation, the runtime would either need to limit thread creation (causing syscall bottlenecks) or allow unbounded parallelism (causing excessive contention). -
Resource locality: The P holds per-processor caches (
mcache, run queue,sudogcache) that provide locality. When a P is handed off between Ms, these caches move with it.
Question 4 (What Would Happen If…)
What would happen if the Go runtime used a single global run queue instead of per-P local run queues? Consider both correctness and performance.
Answer
Correctness: The scheduler would still be correct. A single global queue can distribute work to all available threads.
Performance: There would be significant degradation:
- Lock contention: Every goroutine creation (
gostatement), every scheduling decision, and every goroutine completion would require acquiring a global lock. On a machine with many cores, this lock would become a severe bottleneck, serializing all scheduling decisions. - Cache locality loss: Goroutines that communicate frequently (e.g., a producer and consumer) would be randomly distributed across CPUs, losing cache locality. The per-P run queues and especially
runnextallow communicating goroutines to run on the same P. - Scalability: Performance would degrade roughly linearly with core count. The Go runtime design doc (go11sched) explicitly identifies the centralized run queue as the primary bottleneck in the old scheduler that the GMP model was designed to solve.
The runtime does still maintain a global run queue as a fallback, but it is checked only periodically (every 61 schedule ticks) or when local queues are empty.
Question 5 (Short Answer)
Explain what GOMAXPROCS controls and why its default value equals the number of available CPU cores. What does it not control?
Answer
GOMAXPROCS sets the number of P (processor) structs, which determines the maximum number of goroutines that can execute Go code simultaneously on different OS threads.
Default = number of CPUs: This ensures the runtime can fully utilize all available hardware parallelism without oversubscription.
What it does NOT control:
- The number of goroutines that can exist (that is effectively unlimited)
- The number of OS threads (Ms) – the runtime creates additional Ms as needed for blocking syscalls, cgo calls, etc. The thread count can far exceed GOMAXPROCS.
- The number of goroutines blocked in I/O or channel operations
- GC parallelism (though GC does use Ps)
Question 6 (Short Answer)
In what sense is the Go runtime’s relationship to goroutines analogous to a hypervisor’s relationship to virtual machines? In what ways does the analogy break down?
Answer
Similarities:
- Both multiplex many virtual entities (goroutines / VMs) onto fewer physical resources (OS threads+CPUs / physical CPUs)
- Both provide an abstraction layer that hides the complexity of the underlying hardware/OS
- Both perform scheduling decisions transparent to the managed entities
- Both manage memory (GC / memory virtualization)
Where the analogy breaks down:
- Goroutines are cooperative/semi-cooperative (preemption via async signals is a recent addition), while VMs are fully preemptable via hardware timer interrupts
- Goroutines share a single address space and can communicate via shared memory and channels; VMs have isolated address spaces
- The Go runtime is a library linked into the application, not a separate privileged layer; a hypervisor runs at a higher privilege level
- Goroutines are extremely lightweight (2KB initial stack) compared to VMs (GBs of memory)
- There is no hardware support (e.g., VT-x) for goroutine isolation
Module 3: Processes, Threads, and Goroutines
Duration: 60 minutes
Background: From Processes to Lightweight Threads
The history of concurrent execution in operating systems is a story of progressively reducing overhead. Early computers in the 1950s and 1960s ran one program at a time in batch processing mode: a job was loaded, it ran to completion, and the next job was loaded. The insight that CPUs spent most of their time waiting for I/O led to multiprogramming in systems like IBM OS/360 and Multics, where multiple programs could be resident in memory simultaneously, and the OS would switch between them when one blocked on I/O. Each program ran as a process with its own protected address space, file descriptors, and kernel metadata. This isolation was essential for safety, but it came at a cost: creating a process meant duplicating page tables, allocating kernel data structures, and flushing the TLB. By the 1980s, researchers recognized that many concurrent activities within the same application did not need full isolation. This led to the development of threads—multiple execution contexts sharing a single address space. The POSIX threads (pthreads) standard, formalized in 1995 as IEEE 1003.1c, gave Unix systems a portable API for kernel-managed threads, each with its own stack (typically 1–8 MB), register set, and scheduling state.
Linux took a distinctive approach to this problem. Rather than implementing
threads as a fundamentally different abstraction from processes, Linux unified
them under a single kernel data structure: task_struct. The clone() system
call, introduced in Linux 2.0 (1996), accepts a bitmask of flags that specify
exactly which resources the new task should share with its parent—address
space (CLONE_VM), file descriptors (CLONE_FILES), signal handlers
(CLONE_SIGHAND), and more. A “process” is simply a clone() call that
shares nothing; a “thread” is a clone() call that shares (almost)
everything. The kernel scheduler treats both identically. This design is
elegant and flexible, and it is why the Go runtime can call clone() directly
on Linux to create OS threads, bypassing the pthreads library entirely and
retaining full control over stack placement and signal masks.
Even with threads being cheaper than processes, OS threads remain expensive at scale. Each thread requires a kernel scheduling entity, a fixed-size stack (8 MB of virtual address space on Linux by default, 512 KB on macOS), and context switches that cost 1–10 microseconds due to kernel entry/exit, register save/restore, and cache/TLB disruption. Dan Kegel’s influential “C10K problem” essay (1999) crystallized the challenge: how do you handle 10,000 simultaneous network connections on a single server? A thread-per-connection model buckles under the weight of memory consumption and scheduling overhead at that scale. The problem has only intensified in the era of microservices and cloud computing, where C10M (ten million connections) is the new frontier.
This tension between the convenience of threads (a sequential programming model for each task) and their cost has driven a decades-long search for lightweight alternatives. Solaris 2 introduced green threads in the early 1990s, multiplexing user-level threads onto a smaller number of kernel threads. The GNU Portable Threads library (Pth) offered cooperative user-level threading on any Unix system. Erlang, designed for telecom switches, built its entire runtime around ultra-lightweight processes (not OS processes) that could number in the millions. More recently, Java’s Project Loom (finalized in Java 21, 2023) introduced virtual threads that are scheduled by the JVM rather than the OS. Rust’s async/await model compiles asynchronous tasks into state machines that are driven by a user-space executor. Each of these approaches embodies the same core idea: decouple the unit of concurrent work from the OS thread, so that the programmer can think in terms of tasks while the runtime manages the mapping to hardware.
Go’s answer to this problem is the goroutine: a user-level thread that
starts with a 2 KB stack, is scheduled cooperatively (with asynchronous
preemption added in Go 1.14), and can be created in about a microsecond. The
Go runtime multiplexes potentially millions of goroutines onto a small pool of
OS threads, handling all the complexity of stack growth, blocking system calls,
and work stealing behind the scenes. This module examines how that machinery
works, starting from the OS primitives (processes and threads) that form the
foundation, then diving into Go’s runtime representations of OS threads (the
m struct) and goroutines (the g struct), and finally tracing the lifecycle
of a goroutine through its state transitions.
Learning Objectives
By the end of this module, students will be able to:
- Explain the difference between OS processes, OS threads, and goroutines
- Describe the cost tradeoffs between OS threads and goroutines
- Read and understand the
mstruct (OS thread) andgstruct (goroutine) in the Go runtime - Trace how Go creates OS threads on Linux (clone) and Darwin (pthread_create)
- Enumerate goroutine states and explain their transitions
Part 1: OS Processes (10 min)
What is a Process?
A process is the OS’s unit of resource ownership and protection. Each process has:
- Address space: A virtual memory mapping (text, data, heap, stack segments) isolated from other processes via page tables.
- File descriptor table: An array of open file handles (files, sockets, pipes) private to the process.
- Process table entry: Kernel metadata including PID, parent PID, credentials (uid/gid), signal handlers, resource limits, and scheduling state.
- One or more threads of execution.
Process Creation
On Unix, processes are created with fork(), which duplicates the entire address space (copy-on-write in practice), followed by exec() to load a new program image. This is expensive:
- Page table duplication
- File descriptor table duplication
- Kernel data structure allocation
- TLB flush on the new process
Why Processes Are Too Heavy for Concurrency
If you want 10,000 concurrent activities, creating 10,000 processes is impractical:
- Each gets its own address space (page tables, memory mappings)
- Communication requires IPC (pipes, shared memory, sockets) – all involve kernel transitions
- Context switching between processes requires TLB flushes
This motivates threads: multiple execution contexts sharing a single address space.
Part 2: OS Threads (10 min)
What is a Thread?
A thread (sometimes called a “kernel thread” or “OS thread”) is an independent execution context within a process. Threads within the same process share:
- Address space (code, heap, global data)
- File descriptor table
- Signal handlers
- Process ID
Each thread has its own:
- Stack (typically 1-8 MB, allocated by the OS)
- Register set (saved/restored on context switch)
- Thread ID
- Signal mask
- errno (on Linux, thread-local storage)
Why OS Threads Are Expensive
There are three major costs:
1. Stack size (1-8 MB per thread)
The OS must allocate a fixed-size stack for each thread at creation time. The default on Linux is typically 8 MB (though only pages actually touched are physically allocated). On macOS, the default pthread stack is 512 KB for secondary threads. Even with lazy allocation, the virtual address space is reserved upfront. With 10,000 threads, that is 10-80 GB of virtual address space for stacks alone.
2. Context switch cost (~1-10 microseconds)
Switching between OS threads requires:
- Saving all CPU registers to kernel memory
- Switching kernel stack pointers
- Potentially flushing CPU caches and TLB entries
- Restoring registers for the new thread
- Returning from kernel mode to user mode
This takes roughly 1-10 microseconds depending on hardware and how much cache state is disrupted.
3. Kernel involvement in every operation
Creating, destroying, blocking, and waking threads all require system calls. The kernel must maintain scheduling data structures, and the scheduler itself runs with O(1) or O(log n) complexity per operation, but the constant factors include kernel entry/exit overhead.
Thread Creation on Linux and Darwin
Go creates OS threads through platform-specific mechanisms. On Linux, it uses the clone() system call directly. On Darwin (macOS), it uses pthread_create().
Linux: newosproc calls clone()
From src/runtime/os_linux.go, lines 170-201:
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
// Disable signals during clone, so that the new thread starts
// with signals disabled. It will enable them in minit.
var oset sigset
sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
ret := retryOnEAGAIN(func() int32 {
r := clone(cloneFlags, stk, unsafe.Pointer(mp),
unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
if r >= 0 {
return 0
}
return -r
})
sigprocmask(_SIG_SETMASK, &oset, nil)
// ...error handling...
}
The actual clone system call is issued from assembly in src/runtime/sys_linux_amd64.s, lines 574-623:
TEXT runtime·clone(SB),NOSPLIT|NOFRAME,$0
MOVL flags+0(FP), DI
MOVQ stk+8(FP), SI
MOVQ $0, DX
MOVQ $0, R10
MOVQ $0, R8
// Copy mp, gp, fn off parent stack for use by child.
MOVQ mp+16(FP), R13
MOVQ gp+24(FP), R9
MOVQ fn+32(FP), R12
// ...TLS setup...
MOVL $SYS_clone, AX
SYSCALL
Key observation: the clone() call receives the flags, the new stack pointer, and pointers to the m and g0 structs. The child thread starts executing at mstart, which enters the scheduler loop.
Darwin: newosproc calls pthread_create()
From src/runtime/os_darwin.go, lines 224-258:
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
// Initialize an attribute object.
var attr pthreadattr
var err int32
err = pthread_attr_init(&attr)
// ...
// Find out OS stack size for our own stack guard.
var stacksize uintptr
if pthread_attr_getstacksize(&attr, &stacksize) != 0 { ... }
mp.g0.stack.hi = stacksize // for mstart
// Tell the pthread library we won't join with this thread.
if pthread_attr_setdetachstate(&attr, _PTHREAD_CREATE_DETACHED) != 0 { ... }
// Finally, create the thread.
var oset sigset
sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
err = retryOnEAGAIN(func() int32 {
return pthread_create(&attr, abi.FuncPCABI0(mstart_stub), unsafe.Pointer(mp))
})
// ...
}
On Darwin, Go cannot use clone() (that is Linux-specific), so it goes through the POSIX threads API. The thread starts at mstart_stub, which does low-level setup and then calls mstart.
Part 3: The M Struct – Go’s Representation of an OS Thread (10 min)
In Go’s runtime, every OS thread is represented by an m struct (M for “machine”). This is defined in src/runtime/runtime2.go, lines 618-727.
Key Fields
// src/runtime/runtime2.go, lines 618-727 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=618)
type m struct {
g0 *g // goroutine with scheduling stack
morebuf gobuf // gobuf arg to morestack
procid uint64 // for debuggers, but offset not hard-coded
gsignal *g // signal-handling g
sigmask sigset // storage for saved signal mask
tls [tlsSlots]uintptr // thread-local storage
mstartfn func()
curg *g // current running goroutine
p puintptr // currently attached P (nil if not executing Go code)
nextp puintptr // the next P to install before executing
oldp puintptr // the P that was attached before executing a syscall
id int64
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
park note
alllink *m // on allm
schedlink muintptr
lockedg guintptr
// ...
}
Important Concepts
g0: The scheduling stack. Every M has a special goroutine called g0 that runs scheduler code. When the scheduler needs to make decisions (find a runnable goroutine, handle a syscall return, etc.), it switches to g0’s stack. This is necessary because the scheduler cannot run on a user goroutine’s stack – that stack might be tiny (2 KB) and might need to be moved.
curg: The current goroutine. This points to the user goroutine currently executing on this thread. When curg is nil, the M is running scheduler code on g0.
p: The attached P. An M must have an associated P (processor) to execute Go code. The M-P binding is how the runtime controls parallelism: GOMAXPROCS sets the number of Ps, which limits how many Ms can execute Go code simultaneously.
spinning: When an M has no work, it may enter a “spinning” state where it actively looks for work to steal from other Ps. This avoids the latency of parking and unparking threads.
park: A note (futex-based synchronization primitive) used to park the M when there is no work. The M sleeps on this note and is woken when new work appears.
Part 4: Goroutines – The G Struct (15 min)
A goroutine is Go’s lightweight, user-level thread. Each goroutine is represented by a g struct, defined in src/runtime/runtime2.go, lines 473-598.
Key Fields
// src/runtime/runtime2.go, lines 473-598 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=473)
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth
// prologue. It is stack.lo+StackGuard normally, but can be
// StackPreempt to trigger a preemption.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf // saved context (sp, pc, etc.) for scheduling
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc
param unsafe.Pointer
atomicstatus atomic.Uint32
goid uint64
schedlink guintptr
waitsince int64 // approx time when the g became blocked
waitreason waitReason // if status==Gwaiting
preempt bool // preemption signal
preemptStop bool // transition to _Gpreempted on preemption
lockedm muintptr // locked to this m
parentGoid uint64 // goid of goroutine that created this goroutine
gopc uintptr // pc of go statement that created this goroutine
startpc uintptr // pc of goroutine function
waiting *sudog // sudog structures this g is waiting on
timer *timer // cached timer for time.Sleep
// ...
}
The Stack
// src/runtime/runtime2.go, lines 462-465 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=462)
type stack struct {
lo uintptr
hi uintptr
}
Goroutine stacks start small. From src/runtime/stack.go, line 78:
// The minimum size of stack used by Go code
stackMin = 2048
That is 2 KB – compare this to the 1-8 MB default for OS thread stacks. Goroutine stacks grow dynamically: every function prologue checks if the stack needs to grow. If it does, the runtime allocates a new, larger stack (typically 2x), copies the old stack contents, and updates all pointers. This is called a copyable stack or segmented stack (Go used segmented stacks historically but switched to contiguous, copyable stacks in Go 1.4).
The gobuf: Saved Execution Context
// src/runtime/runtime2.go, lines 303-322 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=303)
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
When a goroutine is descheduled, its execution state (stack pointer, program counter, base pointer) is saved into g.sched (a gobuf). When it is scheduled again, these registers are restored. This is the core of user-level context switching – no kernel involvement needed.
Contrast with an OS context switch, which must save/restore:
- All general-purpose registers (16 on amd64)
- Floating-point / SSE / AVX state (potentially hundreds of bytes)
- Kernel stack pointer
- Various control registers
The goroutine context switch saves only 6 values. The Go compiler ensures that no other registers hold live values at safe points where goroutines can be descheduled.
Part 5: Goroutine States (10 min)
Goroutine states are defined as constants in src/runtime/runtime2.go, lines 17-120. The primary states are:
State Definitions
// src/runtime/runtime2.go, lines 17-120 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=17)
const (
// _Gidle means this goroutine was just allocated and has not
// yet been initialized.
_Gidle = iota // 0
// _Grunnable means this goroutine is on a run queue. It is
// not currently executing user code. The stack is not owned.
_Grunnable // 1
// _Grunning means this goroutine may execute user code. The
// stack is owned by this goroutine. It is assigned an M and
// usually has a P.
_Grunning // 2
// _Gsyscall means this goroutine is executing a system call.
// It is not executing user code. The stack is owned by this
// goroutine. It is assigned an M.
_Gsyscall // 3
// _Gwaiting means this goroutine is blocked in the runtime.
// It is not executing user code. It is not on a run queue,
// but should be recorded somewhere (e.g., a channel wait
// queue) so it can be ready()d when necessary.
_Gwaiting // 4
// _Gdead means this goroutine is currently unused. It may be
// just exited, on a free list, or just being initialized.
_Gdead // 6
// _Gcopystack means this goroutine's stack is being moved.
_Gcopystack // 8
// _Gpreempted means this goroutine stopped itself for a
// suspendG preemption.
_Gpreempted // 9
)
State Transition Diagram
newproc1()
_Gidle ──────────────────────► _Gdead ─────► _Grunnable
│ ▲ │
│ │ │
│ goexit0() │ execute()
│ │ │
│ │ ▼
│ _Grunning ◄──── _Grunning
│ │ │
│ │ │
│ gopark() entersyscall()
│ │ │
│ ▼ ▼
│ _Gwaiting _Gsyscall
│ │ │
│ │ │
│ goready() exitsyscall()
│ │ │
│ ▼ │
│ _Grunnable ◄─────────┘
│
└──────────────────────────────────────────────┘
Key State Transitions
| Transition | Function | What Happens |
|---|---|---|
_Gidle -> _Gdead | newproc1 | Goroutine struct allocated, not yet initialized |
_Gdead -> _Grunnable | newproc1 | Stack set up, placed on run queue |
_Grunnable -> _Grunning | execute | M picks up G, starts executing |
_Grunning -> _Gwaiting | gopark/park_m | G blocks (channel, mutex, sleep, etc.) |
_Gwaiting -> _Grunnable | goready/ready | G woken up, placed back on run queue |
_Grunning -> _Gsyscall | entersyscall | G enters a system call |
_Gsyscall -> _Grunnable | exitsyscall | G returns from system call |
_Grunning -> _Gdead | goexit0 | G’s function returns, G is cleaned up |
Stack Ownership
A critical concept: the goroutine state controls who owns the stack.
_Grunning: The goroutine owns its stack. Only it can read/write the stack._Grunnable: The stack is not owned. The scheduler may inspect it._Gwaiting: The stack is not owned, except that a channel operation may read or write parts of it under the channel lock._Gsyscall: The goroutine owns its stack, but the P may be detached._Gcopystack: The stack is being moved by the goroutine that initiated the copy.
This ownership model is essential for garbage collection: the GC needs to scan goroutine stacks, but can only do so when it owns the stack (i.e., when the goroutine is not in _Grunning).
Part 6: Cost Comparison – Goroutines vs. OS Threads (5 min)
| Property | OS Thread | Goroutine |
|---|---|---|
| Stack size | 1-8 MB (fixed at creation) | 2 KB initial (grows dynamically to ~1 GB) |
| Creation cost | ~100 microseconds (syscall + kernel alloc) | ~1 microsecond (user-space allocation) |
| Context switch | ~1-10 microseconds (kernel transition) | ~100-200 nanoseconds (user-space register swap) |
| Scheduling | Kernel scheduler (preemptive, general-purpose) | Go runtime scheduler (cooperative + async preemption) |
| Memory overhead per unit | ~8-64 KB kernel structures + stack | ~400 bytes g struct + 2 KB stack |
| Max practical count | ~10,000 (limited by kernel resources) | ~1,000,000+ (limited by memory) |
| Blocking | Blocks entire OS thread | Only blocks goroutine; M can run other Gs |
Why This Matters
A typical web server might have 10,000 concurrent connections. With the thread-per-connection model:
- 10,000 threads * 8 MB stack = 80 GB of virtual address space
- 10,000 kernel scheduling entities
- Context switches hit kernel on every transition
With goroutine-per-connection:
- 10,000 goroutines * 2 KB stack = 20 MB of memory
- ~4 kernel threads (GOMAXPROCS) for scheduling
- Context switches stay in user space
This is the fundamental insight behind Go’s concurrency model: goroutines provide the programming convenience of threads at a fraction of the cost.
Key Takeaways
- Processes provide isolation but are too expensive for fine-grained concurrency.
- OS threads share an address space but still carry significant kernel overhead (large stacks, expensive context switches).
- Goroutines are user-level threads managed entirely by the Go runtime, with 2 KB stacks and sub-microsecond context switches.
- The
mstruct represents an OS thread; thegstruct represents a goroutine. The runtime multiplexes many Gs onto fewer Ms. - Goroutine states (
_Gidle,_Grunnable,_Grunning,_Gsyscall,_Gwaiting,_Gdead) control stack ownership and are central to correctness of both the scheduler and the garbage collector. - On Linux, Go creates OS threads with the
clone()system call; on Darwin, withpthread_create(). Both paths ultimately start the new thread atmstart, entering the scheduler loop.
Discussion Questions
- Why does Go use
clone()directly on Linux instead ofpthread_create()? What control does this give the runtime? - If goroutines are so cheap, why does Go still need OS threads at all? Why not implement everything in user space?
- What would happen if goroutine stacks were fixed-size (say, 1 MB) instead of dynamically growing from 2 KB? How would this affect the design of Go programs?
- The
gstruct storesstackguard0, which can be set toStackPreemptto trigger preemption. How is this different from how the OS preempts threads? (We will explore this more in Module 5.)
Further Reading
- Go source:
src/runtime/runtime2.go(struct definitions) - Go source:
src/runtime/os_linux.goandsrc/runtime/os_darwin.go(thread creation) - Go source:
src/runtime/sys_linux_amd64.s(clone syscall wrapper) - Go source:
src/runtime/stack.go(stack size constants) - Scalable Go Scheduler Design Doc: https://golang.org/s/go11sched
Module 3: Processes, Threads, and Goroutines (G, M structs)
Comprehension Check
Question 1 (Code Reading)
Consider these fields from the g struct in runtime/runtime2.go:
type g struct {
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
...
}
Why does the g struct store both sched.sp/sched.pc and separate syscallsp/syscallpc fields? Under what circumstances are the syscall variants used?
Answer
The sched field (a gobuf) stores the goroutine’s saved registers (SP, PC, etc.) for normal context switches – when the goroutine is descheduled by the Go scheduler (e.g., blocked on a channel, preempted).
The separate syscallsp/syscallpc fields are used specifically when the goroutine is in the _Gsyscall state (executing a blocking system call). During a syscall:
- The
schedfields still point to the Go-level save point. syscallsp/syscallpcrecord the stack pointer and PC at the point of the syscall entry.
The GC needs syscallsp/syscallpc because:
- When a goroutine is in a syscall, its Go stack is not actively changing, but the GC needs to scan it.
- The GC uses
syscallspto know the boundary of the goroutine’s active stack frame for accurate scanning. - The
schedfields may be stale or point to a different context (the goroutine may have been through several scheduling rounds before entering the syscall).
This separation ensures the GC can correctly walk the stack of a goroutine regardless of whether it was descheduled normally or entered a syscall.
Question 2 (Short Answer)
Compare and contrast goroutines with: (a) OS threads (pthreads) (b) Green threads (as in early Java or Erlang processes)
What specific design choices make goroutines different from both?
Answer
vs. OS Threads (pthreads):
- Goroutines have ~2KB initial stacks vs. ~2-8MB for OS threads
- Goroutines are scheduled by the Go runtime in user space; OS threads by the kernel
- Goroutine context switches do not require kernel transitions
- Goroutines use growable, copyable stacks; OS threads have fixed-size stacks
- Creating a goroutine is ~100x cheaper than creating an OS thread
vs. Green threads:
- Green threads (e.g., early Java) typically use N:1 scheduling (all green threads on one OS thread). Goroutines use M:N scheduling (many goroutines on many OS threads via the GMP model), enabling true parallelism.
- Green threads often cannot handle blocking syscalls without blocking all green threads. Go’s runtime detaches the P from a blocked M, allowing other goroutines to continue.
- Goroutines have growable, copying stacks (Go switched from segmented to contiguous copying stacks in Go 1.4); many green thread implementations use fixed-size stacks.
- Go’s scheduler includes work stealing, which most green thread implementations lack.
- Go integrates non-blocking I/O (network poller) transparently, so goroutines that do I/O appear to block but actually yield to the scheduler.
Unique design choices:
- The M:N model with explicit P as a scheduling resource
- Integrated network poller making blocking-style I/O non-blocking under the hood
- Stack copying (not segmented stacks) allowing stacks to grow and shrink
- Asynchronous preemption via signals (SIGURG) for long-running goroutines without function call points
Question 3 (True/False with Explanation)
True or False: A goroutine is always executed by the same OS thread throughout its lifetime.
Answer
False. A goroutine can migrate between OS threads. When a goroutine is descheduled (e.g., it blocks on a channel, is preempted, or its P is stolen), it goes back into a run queue. When it is rescheduled, it may be picked up by a different M (OS thread) that acquires the relevant P.
The exception is runtime.LockOSThread(), which locks a goroutine to its current OS thread. While locked, the goroutine will only execute on that specific M, and the M will only execute that goroutine. This is necessary for certain use cases like OpenGL (which requires thread-local state), or interacting with C libraries that use thread-local storage.
Question 4 (Code Reading)
Consider these fields from the m struct:
type m struct {
g0 *g // goroutine with scheduling stack
...
curg *g // current running goroutine
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
...
spinning bool
}
What is g0 and why does each M need a separate goroutine for scheduling? Why can’t the scheduler code run on the current goroutine’s stack?
Answer
g0 is a special goroutine associated with each M that has a large, fixed-size stack (typically 8KB, allocated by the OS). It is used to run scheduling code, garbage collection, and stack management operations.
The scheduler cannot run on the current goroutine’s stack for several reasons:
-
Stack growth chicken-and-egg: The scheduler may need to grow or shrink a goroutine’s stack. If the scheduler were running on that same stack, it could not safely reallocate it.
-
Stack size guarantees: Goroutine stacks start small (2KB) and grow on demand. Scheduler and GC code needs a reliable, sufficiently large stack. The
g0stack provides this guarantee. -
Safety during context switches: When switching from one goroutine to another, there is a moment when the old goroutine’s state is being saved and the new one is being loaded. Running on a separate stack ensures neither goroutine’s stack is corrupted during this transition.
-
Syscall handling: When returning from a syscall or handling signals, the runtime needs a known-good stack to execute on before it can set up goroutine execution.
The pattern systemstack(func() { ... }) switches to g0’s stack to execute sensitive runtime operations.
Question 5 (Short Answer)
What are the possible states a goroutine (g) can be in? Describe the transitions between _Grunnable, _Grunning, _Gwaiting, and _Gsyscall.
Answer
Key goroutine states:
_Grunnable: Ready to run but not yet assigned to an M/P. Sitting in a run queue (local or global)._Grunning: Currently executing on an M with a P. Only one G per M can be in this state._Gwaiting: Blocked waiting for some event (channel op, mutex, I/O, sleep, select, etc.). Not on any run queue._Gsyscall: Executing a system call. The M is in the kernel; the P may have been detached.
Transitions:
_Grunnable->_Grunning: The scheduler picks the G from a run queue and callsexecute()._Grunning->_Grunnable: Preemption occurs, or the G yields. It goes back to a run queue._Grunning->_Gwaiting: The G blocks (e.g.,gopark()called by channel ops, mutex locks, etc.)._Gwaiting->_Grunnable: The event the G was waiting for occurs (e.g.,goready()called when a channel op completes or a mutex is released)._Grunning->_Gsyscall: The G callsentersyscall()before a blocking system call._Gsyscall->_Grunning: The syscall returns andexitsyscall()reacquires a P._Gsyscall->_Grunnable: The syscall returns but no P is immediately available; the G is placed on the global run queue.
Other states include _Gidle (just allocated), _Gdead (finished, available for reuse), _Gcopystack (stack is being copied), and _Gpreempted (stopped at an async preemption point).
Module 4: The Go Scheduler
Duration: 75 minutes
Background: CPU Scheduling From Hardware to User Space
CPU scheduling is one of the oldest and most fundamental problems in operating systems. At its core, the question is deceptively simple: given more work than processors to run it on, which task should run next? But behind that simplicity lies a web of competing goals. We want fairness, so that no process starves while others monopolize the CPU. We want high throughput, so the system completes as much useful work as possible. And we want low latency, so interactive tasks feel responsive to users. These goals are often in tension – optimizing for one can hurt another – and the history of OS scheduling is largely the story of navigating those trade-offs with increasingly clever designs. Early systems used simple first-come, first-served (FCFS) scheduling, then introduced preemptive round-robin to bound response times, and eventually developed multilevel feedback queues (MLFQ) that adapt scheduling priority based on observed process behavior.
Linux’s scheduling evolution illustrates how real systems have tackled these problems at scale. The 2.6 kernel introduced the O(1) scheduler, which used active and expired priority arrays to achieve constant-time scheduling decisions – a big improvement over the earlier O(n) scheduler that scanned every process on each context switch. But the O(1) scheduler’s heuristics for distinguishing interactive and batch processes were fragile and hard to tune. In 2007, Ingo Molnar replaced it with the Completely Fair Scheduler (CFS), which took a radically different approach: instead of priority arrays and heuristics, CFS tracks each task’s virtual runtime – a measure of how much CPU time it has received, weighted by its priority (nice value). Tasks are stored in a red-black tree ordered by virtual runtime, and the scheduler always picks the task with the smallest virtual runtime, achieving O(log n) scheduling with an elegant fairness guarantee. CFS served Linux well for over 15 years, but in 2023 (kernel 6.6), it was replaced by the EEVDF (Earliest Eligible Virtual Deadline First) scheduler. EEVDF builds on the same virtual-runtime foundation but adds a virtual deadline concept, allowing the scheduler to make better decisions about latency-sensitive tasks without the ad hoc “sleeper bonus” heuristics that CFS had accumulated over the years. The result is more predictable latency behavior with a cleaner theoretical basis.
Modern multicore hardware introduces another dimension to the scheduling problem: a single global run queue protected by one lock becomes a severe bottleneck when dozens or hundreds of cores contend for it. Linux solves this with per-CPU run queues – each processor core maintains its own queue of runnable tasks, and scheduling decisions are made locally without cross-core locking in the common case. This dramatically reduces contention, but it creates a new challenge: load balancing. If one core’s queue drains while another is overloaded, the system wastes hardware parallelism. Linux uses periodic load balancing and idle-core work stealing to redistribute tasks, with a topology-aware approach that accounts for the cache-sharing relationships between cores (preferring to migrate tasks within the same NUMA node or LLC domain before moving them farther away).
The kernel scheduler works at the level of OS threads, but many applications need concurrency at a much finer granularity. This is the domain of user-level scheduling, where a runtime manages its own lightweight tasks on top of OS threads. The M:N threading model – multiplexing M user-level threads onto N kernel threads – has a long and checkered history. Sun’s Solaris operating system experimented with M:N threading in the 1990s, and the Linux community explored it with the NGPT (Next Generation POSIX Threads) project in the early 2000s. Both efforts were largely abandoned in favor of 1:1 threading (one user thread per kernel thread), which was simpler to implement correctly and avoided thorny problems like priority inversion between the user-level and kernel schedulers. But the 1:1 model has costs: OS threads are heavyweight (typically consuming 1-8 MB of stack), expensive to create, and context-switching between them requires entering the kernel. Runtimes like the Erlang BEAM VM sidestepped this by implementing their own preemptive schedulers for lightweight processes, and Java’s ForkJoinPool uses work-stealing to efficiently distribute fine-grained tasks. These user-level approaches trade generality for performance within their specific domains.
Go revived the M:N model with a design that learns from earlier failures. Rather than trying to be a general-purpose threading library, Go’s scheduler is tightly integrated with the language runtime, the compiler, and the garbage collector. It can insert preemption points at compile time, it knows when a goroutine is about to make a blocking system call, and it can grow goroutine stacks dynamically (starting at just a few kilobytes). The result is a system that supports millions of concurrent goroutines with efficient scheduling – and at its heart is the GMP model we will study in this module. Understanding the design of Go’s scheduler is not just about learning Go; it is a case study in how the classic ideas of per-CPU run queues, work stealing, and cooperative-preemptive hybrid scheduling come together in a modern, practical system.
Learning Objectives
By the end of this module, students will be able to:
- Explain the GMP model and why the P (processor) abstraction exists
- Describe the P struct’s role: local run queue, caching, logical processor
- Read the scheduling loop (
schedule->findRunnable->execute) - Trace a goroutine through its complete lifecycle: creation, execution, blocking, waking, exit
- Explain the run queue data structures and the
runnextoptimization
Part 1: The GMP Model (10 min)
Overview
The Go scheduler is built around three core abstractions, described at the top of src/runtime/proc.go, lines 24-34:
// src/runtime/proc.go lines 24-34
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over
// worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
//
// Design doc at https://golang.org/s/go11sched.
Why Three Entities?
The original Go scheduler (before Go 1.1) only had G and M. Every M had a global mutex-protected run queue. This created severe contention: every time any goroutine was created, scheduled, or completed, it had to acquire the global lock. With 8+ cores, the lock became a bottleneck.
The solution was to introduce P (processor) – a logical processor that provides:
- A local run queue per P (no lock needed for local operations)
- Local caches (mcache, defer pool, sudog cache) to avoid global allocation locks
- Bounded parallelism – GOMAXPROCS controls the number of Ps, which bounds how many Ms can execute Go code simultaneously
The relationship:
G (goroutine) -- the work to be done
M (machine) -- the OS thread that does the work
P (processor) -- the "ticket" that allows an M to do Go work
Rule: an M must hold a P to execute Go code.
An M may exist without a P (blocked in syscall, idle).
A P always has a local run queue of Gs.
Worker Thread Parking/Unparking
The scheduler must balance two competing goals (from src/runtime/proc.go, lines 36-43):
- Keep enough running worker threads to utilize available hardware parallelism
- Park excessive running worker threads to conserve CPU resources and power
This is hard because scheduler state is distributed (per-P queues) and we cannot predict the future (a parked thread may be needed immediately).
Part 2: The P Struct (10 min)
The P struct is defined in src/runtime/runtime2.go, lines 772-928.
Key Fields
From src/runtime/runtime2.go, lines 772-818:
// src/runtime/runtime2.go lines 772-835
type p struct {
id int32
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
mcache *mcache
raceprocctx uintptr
deferpool []*_defer // pool of available defer structs
deferpoolbuf [32]*_defer
// Cache of goroutine ids, amortizes accesses to sched.goidgen.
goidcache uint64
goidcacheend uint64
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice.
runnext guintptr
// Available G's (status == Gdead)
gFree gList
sudogcache []*sudog
sudogbuf [128]*sudog
// ...
}
The Local Run Queue
The most important part of the P is its local run queue:
runqhead uint32
runqtail uint32
runq [256]guintptr
This is a lock-free, fixed-size circular buffer of 256 goroutine pointers. Key properties:
- No lock needed for the owning P to push/pop
- Lock-free stealing by other Ps using atomic CAS operations
- Fixed size of 256 – if it overflows, half the queue is moved to the global run queue
The runnext Slot
From src/runtime/runtime2.go, lines 806-818:
// src/runtime/runtime2.go, lines 806-818
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
//
// Note that while other P's may atomically CAS this to zero,
// only the owner P can CAS it to a valid G.
runnext guintptr
This is an optimization for the common producer-consumer pattern: when goroutine A sends on a channel and wakes goroutine B, B is placed in runnext so it runs immediately (inheriting A’s time slice). This avoids the latency of going to the back of the run queue.
P Local Caches
The P also holds various caches to reduce contention on global data structures:
mcache– memory allocator cache (avoids locking the heap for small allocations)deferpool– pool of_deferstructssudogcache– pool ofsudogstructs (used for channel operations)gFree– pool of dead goroutines for reusegoidcache– batch of goroutine IDs (avoids atomically incrementing a global counter for every new goroutine)
Part 3: The schedt Struct – Global Scheduler State (5 min)
The global scheduler state is held in a single schedt struct (variable sched), defined in src/runtime/runtime2.go, lines 930-1053.
Key Fields
// src/runtime/runtime2.go, lines 930-976
type schedt struct {
goidgen atomic.Uint64
lastpoll atomic.Int64 // time of last network poll
pollUntil atomic.Int64 // time to which current poll is sleeping
lock mutex
midle listHeadManual // idle m's waiting for work
nmidle int32 // number of idle m's
nmidlelocked int32 // number of locked m's waiting for work
mnext int64 // number of m's that have been created
maxmcount int32 // maximum number of m's allowed (or die)
ngsys atomic.Int32 // number of system goroutines
pidle puintptr // idle p's
npidle atomic.Int32
nmspinning atomic.Int32 // spinning M count
// Global runnable queue.
runq gQueue
// Global cache of dead G's.
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
}
// freem is the list of m's waiting to be freed
freem *m
gcwaiting atomic.Bool // gc is waiting to run
// ...
}
Global vs. Local State
| State | Where | Lock? |
|---|---|---|
| Per-P run queue | p.runq[256] | Lock-free (atomics) |
| Global run queue | sched.runq | sched.lock |
| Idle Ms | sched.midle | sched.lock |
| Idle Ps | sched.pidle | sched.lock |
| Dead Gs (reuse pool) | p.gFree (local) + sched.gFree (global) | Local: none; Global: sched.gFree.lock |
The design principle: fast paths use local, per-P state; slow paths fall back to global state under a lock.
Part 4: The gobuf Struct – Saved Register State (5 min)
When a goroutine is descheduled, its register state is saved into a gobuf. From src/runtime/runtime2.go, lines 303-322:
// src/runtime/runtime2.go, lines 303-322
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
This is stored in g.sched for each goroutine. The key operations:
- Save context: When a goroutine yields (via
gopark,Gosched, or preemption), its SP, PC, and BP are written tog.sched. - Restore context: When the scheduler picks a goroutine to run,
gogo(&gp.sched)loads SP, PC, and BP from the gobuf and jumps to the saved PC.
Why only 6 fields? The Go compiler cooperates with the scheduler to ensure that at any point where a goroutine can be descheduled (a “safe point”), all live values are on the stack, not in registers. This means the context switch only needs to save the stack pointer and program counter – the values themselves are preserved on the goroutine’s stack.
Part 5: The Scheduling Loop (15 min)
schedule()
The scheduling loop is the heart of the runtime. Every M that is ready to do work calls schedule(), which finds a runnable goroutine and starts executing it. From src/runtime/proc.go, lines 4135-4231:
// src/runtime/proc.go, lines 4135-4231
func schedule() {
mp := getg().m
if mp.locks != 0 {
throw("schedule: holding locks")
}
if mp.lockedg != 0 {
stoplockedm()
execute(mp.lockedg.ptr(), false) // Never returns.
}
// We should not schedule away from a g that is executing a cgo call,
// since the cgo call is using the m's g0 stack.
if mp.incgo {
throw("schedule: in cgo")
}
top:
pp := mp.p.ptr()
pp.preempt = false
// Safety check: if we are spinning, the run queue should be empty.
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// This thread is going to run a goroutine and is not spinning anymore,
// so if it was marked as spinning we need to reset it now and
// potentially start a new spinning M.
if mp.spinning {
resetspinning()
}
// ...disabled scheduling, locked-g handling...
execute(gp, inheritTime)
}
The Flow
schedule()
|
+--> findRunnable() // find a G to run (may block)
| |
| +--> returns (gp, inheritTime, tryWakeP)
|
+--> resetspinning() // if we were spinning, stop
|
+--> execute(gp, inheritTime) // start running gp
|
+--> gogo(&gp.sched) // restore context, jump to user code
// NEVER RETURNS to schedule()
Important: execute() never returns. When the goroutine eventually yields, the M will re-enter schedule() from a different path (via mcall which switches to g0 and calls the appropriate handler).
execute()
From src/runtime/proc.go, lines 3331-3383:
// src/runtime/proc.go, lines 3331-3383
func execute(gp *g, inheritTime bool) {
mp := getg().m
// Assign gp.m before entering _Grunning so running Gs have an M.
mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + stackGuard
if !inheritTime {
mp.p.ptr().schedtick++
}
// Check whether the profiler needs to be turned on or off.
hz := sched.profilehz
if mp.profilehz != hz {
setThreadCPUProfiler(hz)
}
trace := traceAcquire()
if trace.ok() {
trace.GoStart()
traceRelease(trace)
}
gogo(&gp.sched)
}
Key steps:
- Bind M and G together (
mp.curg = gp; gp.m = mp) - Transition status:
_Grunnable->_Grunning - Reset preemption state
- Increment
schedtickif this is a new time slice (not inherited) - Call
gogo()to restore the goroutine’s saved context and jump to user code
Part 6: findRunnable() – Finding Work (15 min)
findRunnable() is the most complex function in the scheduler. It implements a priority-ordered search for work. From src/runtime/proc.go, lines 3389-3658.
The Priority Order
// src/runtime/proc.go, lines 3389-3538
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
mp := getg().m
top:
pp := mp.p.ptr()
if sched.gcwaiting.Load() {
gcstopm() // 0. GC is running, stop
goto top
}
// ...timer checks...
// 1. Try to schedule the trace reader.
if traceEnabled() || traceShuttingDown() {
gp := traceReader()
if gp != nil { return gp, false, true }
}
// 2. Try to schedule a GC worker.
if gcBlackenEnabled != 0 {
gp, tnow := gcController.findRunnableGCWorker(pp, now)
if gp != nil { return gp, false, true }
}
// 3. Check global runnable queue once in a while (fairness).
if pp.schedtick%61 == 0 && !sched.runq.empty() {
lock(&sched.lock)
gp := globrunqget()
unlock(&sched.lock)
if gp != nil { return gp, false, false }
}
// 4. Wake up finalizer/cleanup goroutines if needed.
// ...
// 5. Local run queue.
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 6. Global run queue (take a batch).
if !sched.runq.empty() {
lock(&sched.lock)
gp, q := globrunqgetbatch(int32(len(pp.runq)) / 2)
unlock(&sched.lock)
if gp != nil {
runqputbatch(pp, &q)
return gp, false, false
}
}
// 7. Poll network (non-blocking).
if netpollinited() && netpollAnyWaiters() && ... {
list, delta := netpoll(0)
if !list.empty() {
gp := list.pop()
injectglist(&list)
return gp, false, false
}
}
// 8. Steal work from other Ps.
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
if !mp.spinning { mp.becomeSpinning() }
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil { return gp, inheritTime, false }
}
// 9. Try idle-time GC marking.
// ...
// 10. Give up: release P, park the M.
lock(&sched.lock)
// ...re-check global queue, needspinning...
releasep()
pidleput(pp, now)
unlock(&sched.lock)
// ...park the M, wait for wakeup...
}
The Priority Order Summarized
| Priority | Source | Lock Required? | Notes |
|---|---|---|---|
| 0 | GC stop-the-world | - | Stop if GC is waiting |
| 1 | Trace reader | - | Special system goroutine |
| 2 | GC mark worker | - | Assist the garbage collector |
| 3 | Global queue (1/61 chance) | sched.lock | Fairness: prevent local queue starvation |
| 4 | Finalizer/cleanup Gs | - | Wake if pending |
| 5 | Local run queue | None (atomics) | Fast path – most common |
| 6 | Global run queue (batch) | sched.lock | Take up to len(runq)/2 goroutines |
| 7 | Network poller | None | Non-blocking poll for I/O completions |
| 8 | Work stealing | None (atomics) | Steal from another P’s local queue |
| 9 | Idle GC marking | - | Use idle time for GC work |
| 10 | Park the M | sched.lock | Release P, sleep until woken |
The 1/61 Fairness Check
From src/runtime/proc.go, line 3443:
// src/runtime/proc.go, line 3443
if pp.schedtick%61 == 0 && !sched.runq.empty() {
Every 61st scheduling decision, the scheduler checks the global queue before the local queue. Without this, two goroutines on the same P that continuously spawn each other could starve goroutines on the global queue indefinitely. The number 61 is prime (to avoid resonance with other periodicities) and represents a balance between fairness and fast-path efficiency.
Work Stealing
When the local queue is empty and the global queue is empty, the scheduler attempts to steal work from other Ps. From src/runtime/proc.go, line 3522:
// src/runtime/proc.go, line 3522
gp, inheritTime, tnow, w, newWork := stealWork(now)
stealWork iterates over all Ps in a random order and attempts to steal half of another P’s local run queue. This is the key mechanism for load balancing across cores. The number of spinning Ms is limited to half the busy Ps to avoid excessive CPU consumption. From src/runtime/proc.go, line 3517:
// src/runtime/proc.go, line 3517
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
Part 7: Goroutine Lifecycle (15 min)
Creation: newproc / newproc1
When Go code executes go f(), the compiler emits a call to newproc. From src/runtime/proc.go, lines 5295-5308:
// src/runtime/proc.go, lines 5295-5308
func newproc(fn *funcval) {
gp := getg()
pc := sys.GetCallerPC()
systemstack(func() {
newg := newproc1(fn, gp, pc, false, waitReasonZero)
pp := getg().m.p.ptr()
runqput(pp, newg, true)
if mainStarted {
wakep()
}
})
}
Steps:
- Switch to the system stack (g0)
- Call
newproc1to allocate and initialize a new G - Put the new G on the current P’s local run queue via
runqput, withnext=true(so it goes intorunnext) - If the runtime is past initialization, call
wakep()to wake an idle M if one exists
newproc1 does the detailed initialization, from src/runtime/proc.go, lines 5313-5427:
// src/runtime/proc.go, lines 5313-5357
func newproc1(fn *funcval, callergp *g, callerpc uintptr,
parked bool, waitreason waitReason) *g {
if fn == nil {
fatal("go of nil func value")
}
mp := acquirem() // disable preemption
pp := mp.p.ptr()
newg := gfget(pp) // try to reuse a dead G from the free list
if newg == nil {
newg = malg(stackMin) // allocate new G with 2KB stack
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
// Set up the stack frame
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.parentGoid = callergp.goid
newg.gopc = callerpc
newg.startpc = fn.fn
// ...assign goid, set status to _Grunnable...
}
Key detail: the new goroutine’s PC is set up so that when fn returns, it returns into goexit, which performs cleanup. The gostartcallfn call arranges the stack frame so that fn appears to have been called by goexit.
Another key detail: gfget(pp) tries to reuse a dead goroutine from the P’s local free list (p.gFree) before allocating a new one. This amortizes allocation cost for programs that create and destroy many goroutines.
Execution: execute
Covered above in Part 5. The key transition: _Grunnable -> _Grunning, then gogo(&gp.sched) restores context and begins executing user code.
Blocking: gopark / park_m
gopark and goready (below) are the runtime’s universal blocking and waking primitives. Channels (Module 7), mutexes (Module 6), and I/O (Module 10) all ultimately use gopark to suspend a goroutine and goready to wake it. Understanding this pair is key to understanding all blocking operations in Go.
When a goroutine needs to block (channel operation, mutex, sleep, etc.), it calls gopark. From src/runtime/proc.go, lines 445-463:
// src/runtime/proc.go, lines 445-463
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer,
reason waitReason, traceReason traceBlockReason, traceskip int) {
if reason != waitReasonSleep {
checkTimeouts()
}
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waitTraceBlockReason = traceReason
mp.waitTraceSkip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}
gopark stores the wait reason and unlock function, then calls mcall(park_m). mcall switches from the user goroutine’s stack to the M’s g0 stack and calls park_m. From src/runtime/proc.go, lines 4253-4302:
// src/runtime/proc.go, lines 4253-4302
func park_m(gp *g) {
mp := getg().m
trace := traceAcquire()
if trace.ok() {
trace.GoPark(mp.waitTraceBlockReason, mp.waitTraceSkip)
}
casgstatus(gp, _Grunning, _Gwaiting)
if trace.ok() {
traceRelease(trace)
}
dropg() // disconnect G from M
if fn := mp.waitunlockf; fn != nil {
ok := fn(gp, mp.waitlock)
mp.waitunlockf = nil
mp.waitlock = nil
if !ok {
// The unlock function said "don't park after all"
casgstatus(gp, _Gwaiting, _Grunnable)
execute(gp, true) // Schedule it back, never returns.
}
}
// G is now parked. M calls schedule() to find new work.
schedule()
}
Key steps:
- Transition status:
_Grunning->_Gwaiting dropg(): disconnect G from M (m.curg = nil; g.m = nil)- Call the unlock function (e.g., release a channel lock). If it returns
false, the park is cancelled and the goroutine resumes. - Call
schedule()to find the next goroutine to run
The unlock function pattern is important: it allows the runtime to atomically release a lock and park, avoiding a race where another goroutine could wake us before we are fully parked.
Waking: goready / ready
When a blocked goroutine should be woken (e.g., a value arrives on a channel), goready is called. From src/runtime/proc.go, lines 481-485:
// src/runtime/proc.go, lines 481-485
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
Which calls ready. From src/runtime/proc.go, lines 1120-1140:
// src/runtime/proc.go, lines 1120-1140
func ready(gp *g, traceskip int, next bool) {
status := readgstatus(gp)
// Mark runnable.
mp := acquirem()
if status&^_Gscan != _Gwaiting {
dumpgstatus(gp)
throw("bad g->status in ready")
}
// status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
trace := traceAcquire()
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.ok() {
trace.GoUnpark(gp, traceskip)
traceRelease(trace)
}
runqput(mp.p.ptr(), gp, next)
wakep()
releasem(mp)
}
Key steps:
- Transition status:
_Gwaiting->_Grunnable - Put the goroutine on the current P’s local run queue via
runqputwithnext=true, meaning it goes intorunnextfor immediate scheduling - Call
wakep()to wake an idle M, since there is now new work available
Exit: goexit0 / gdestroy
When a goroutine’s function returns, it returns into goexit (which was set up during creation). The cleanup happens in goexit0. From src/runtime/proc.go, lines 4491-4501:
// src/runtime/proc.go, lines 4491-4501
func goexit0(gp *g) {
if goexperiment.RuntimeSecret && gp.secret > 0 {
memclrNoHeapPointers(unsafe.Pointer(gp.stack.lo),
gp.stack.hi-gp.stack.lo)
}
gdestroy(gp)
schedule()
}
And gdestroy does the actual cleanup, from src/runtime/proc.go, lines 4503-4539:
// src/runtime/proc.go, lines 4503-4511
func gdestroy(gp *g) {
mp := getg().m
pp := mp.p.ptr()
casgstatus(gp, _Grunning, _Gdead)
gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
if isSystemGoroutine(gp, false) {
sched.ngsys.Add(-1)
}
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil
gp._panic = nil
gp.writebuf = nil
gp.waitreason = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
dropg()
// ...put gp on free list for reuse (gfput)...
}
Key steps:
- Transition status:
_Grunning->_Gdead - Clear all goroutine-specific state (defers, panics, labels, etc.)
dropg(): disconnect G from M- Put the G on the P’s free list (
gfput) for reuse bynewproc1 - Call
schedule()to find the next goroutine
Complete Lifecycle Diagram
newproc()
|
newproc1() -- allocate G, set up stack
|
runqput(pp, newg, true) -- place on runnext
|
v
┌── _Grunnable ◄────────────────────────┐
| | |
| schedule() -> findRunnable() |
| | |
| execute() ready()
| | [goready]
| casgstatus -> _Grunning |
| gogo(&gp.sched) |
| | |
| ┌────┴────┐ |
| | | |
| gopark function |
| | returns |
| v | |
| _Gwaiting | |
| | | ────────────┘
| | v
| | goexit0()
| | |
| | casgstatus -> _Gdead
| | |
| | gfput() -- place on free list
| | |
| | schedule() -- find next G
| |
| └─── (woken by another G) ───► _Grunnable
|
└─────────── (stolen by another P)
Part 8: Run Queue Operations (10 min)
runqput – Adding to the Local Queue
From src/runtime/proc.go, lines 7478-7520:
// src/runtime/proc.go, lines 7478-7520
func runqput(pp *p, gp *g, next bool) {
if !haveSysmon && next {
// Without sysmon, avoid runnext to prevent starvation
next = false
}
if randomizeScheduler && next && randn(2) == 0 {
next = false
}
if next {
retryNext:
oldnext := pp.runnext
if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
if oldnext == 0 {
return
}
// Kick the old runnext out to the regular run queue.
gp = oldnext.ptr()
}
retry:
h := atomic.LoadAcq(&pp.runqhead) // load-acquire
t := pp.runqtail
if t-h < uint32(len(pp.runq)) {
pp.runq[t%uint32(len(pp.runq))].set(gp)
atomic.StoreRel(&pp.runqtail, t+1) // store-release
return
}
if runqputslow(pp, gp, h, t) {
return
}
goto retry
}
The logic:
- If
next=true, place the goroutine inrunnext(CAS loop). If there was already something inrunnext, kick it to the regular queue. - Try to add to the circular buffer (
runq[t % 256]). Uses load-acquire on head and store-release on tail for lock-free producer-consumer. - If the buffer is full (256 entries), call
runqputslowwhich moves half the queue to the global run queue.
runqputslow – Overflow to Global Queue
From src/runtime/proc.go, lines 7524-7560:
// src/runtime/proc.go, lines 7524-7560
func runqputslow(pp *p, gp *g, h, t uint32) bool {
var batch [len(pp.runq)/2 + 1]*g
// Grab a batch from local queue.
n := t - h
n = n / 2
for i := uint32(0); i < n; i++ {
batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
}
if !atomic.CasRel(&pp.runqhead, h, h+n) {
return false
}
batch[n] = gp
// Link the goroutines and put on global queue.
for i := uint32(0); i < n; i++ {
batch[i].schedlink.set(batch[i+1])
}
lock(&sched.lock)
globrunqputbatch(&q)
unlock(&sched.lock)
return true
}
When the local queue is full, half of it (128 goroutines) plus the new goroutine are moved to the global queue. This serves dual purposes:
- Makes room in the local queue
- Makes work available for other Ps to find (load balancing)
runqget – Taking from the Local Queue
From src/runtime/proc.go, lines 7598-7619:
// src/runtime/proc.go, lines 7598-7619
func runqget(pp *p) (gp *g, inheritTime bool) {
// If there's a runnext, it's the next G to run.
next := pp.runnext
// If the runnext is non-0 and the CAS fails, it could only have
// been stolen by another P, because other Ps can race to set
// runnext to 0, but only the current P can set it to non-0.
if next != 0 && pp.runnext.cas(next, 0) {
return next.ptr(), true
}
for {
h := atomic.LoadAcq(&pp.runqhead)
t := pp.runqtail
if t == h {
return nil, false
}
gp := pp.runq[h%uint32(len(pp.runq))].ptr()
if atomic.CasRel(&pp.runqhead, h, h+1) {
return gp, false
}
}
}
Key details:
- Check
runnextfirst. If set, take it and returninheritTime=true(the goroutine inherits the current time slice). - Then check the circular buffer. FIFO order (take from head). Uses CAS on head to handle concurrent stealers.
- The
inheritTimeflag is important: a goroutine fromrunnextshares the time slice with the goroutine that readied it, while a goroutine from the regular queue gets a fresh time slice.
Part 9: The runnext Optimization (5 min)
The runnext optimization deserves special attention because it is central to Go’s performance for channel-based patterns.
The Problem
Consider a producer-consumer pattern:
func producer(ch chan int) {
for i := 0; i < 1000000; i++ {
ch <- i // wake consumer, then block
}
}
func consumer(ch chan int) {
for v := range ch {
process(v) // process, then block waiting for next
}
}
Without runnext, when the producer sends:
- Consumer is woken, placed at the back of the run queue
- Producer blocks
- Other goroutines in the queue run first
- Eventually consumer runs, processes value, blocks again
- Repeat with high scheduling latency
The Solution
With runnext:
- Consumer is woken, placed in
runnext(front of queue) - Producer blocks
- Consumer runs immediately (inherits producer’s time slice)
- Consumer processes value, sends result/blocks
- Very low scheduling latency
From the comment in src/runtime/runtime2.go, lines 806-818:
If a set of goroutines is locked in a communicate-and-wait pattern, this schedules that set as a unit and eliminates the (potentially large) scheduling latency that otherwise arises from adding the ready’d goroutines to the end of the run queue.
The Starvation Guard
There is a subtle starvation risk: if two goroutines keep waking each other via runnext, they could monopolize the P forever. The sysmon thread (system monitor) guards against this by preempting goroutines that run too long. From src/runtime/proc.go, lines 7479-7489:
// src/runtime/proc.go, lines 7479-7489
if !haveSysmon && next {
// A runnext goroutine shares the same time slice as the
// current goroutine. To prevent a ping-pong pair from
// starving all others, we depend on sysmon to preempt
// "long-running goroutines".
next = false
}
On platforms without sysmon (like Wasm), runnext is disabled entirely to prevent starvation.
Key Takeaways
-
The GMP model separates concerns: G is the work, M is the thread, P is the scheduling context. P limits parallelism and provides per-core caches.
-
The P struct holds a local run queue (256-slot lock-free ring buffer), a
runnextslot for producer-consumer optimization, and various caches to avoid global locks. -
The scheduling loop (
schedule->findRunnable->execute) searches for work in priority order: GC work > global queue (1/61 fairness) > local queue > global queue (batch) > network poll > work stealing > idle GC > park. -
Goroutine lifecycle:
newproc1creates and enqueues;executetransitions to_Grunning;gopark/park_mblocks with_Gwaiting;goready/readywakes to_Grunnable;goexit0/gdestroycleans up to_Gdeadand recycles. -
The
runnextoptimization enables efficient producer-consumer scheduling by giving the woken goroutine the current time slice. -
Load balancing happens through work stealing (take half of another P’s queue) and overflow (when local queue is full, half goes to global).
Discussion Questions
-
Why is
findRunnable’s priority order designed the way it is? Why does GC work come before user goroutines? -
The global queue fairness check uses
schedtick%61. What would happen if this wereschedtick%2(too frequent) orschedtick%10000(too rare)? -
When a goroutine makes a blocking system call, the P is detached from the M. How does the runtime know when the syscall completes? (Hint: look at
exitsyscallandsysmon.) -
Why does
runqputslowmove half the queue to the global queue instead of just the overflowing goroutine? What would happen with a different strategy? -
The
runnextoptimization trades fairness for latency. In what scenarios might you want to disable it?
Further Reading
- Go source:
src/runtime/proc.go(scheduler implementation) - Go source:
src/runtime/runtime2.go(data structures) - Design document: https://golang.org/s/go11sched (Scalable Go Scheduler)
src/runtime/proc.go, lines 36-97: detailed comment on worker thread parking/unparking strategy
Module 4: The Go Scheduler (GMP Model, schedule(), findRunnable())
Comprehension Check
Question 1 (Code Reading)
Study this excerpt from schedule() in runtime/proc.go:
func schedule() {
mp := getg().m
if mp.locks != 0 {
throw("schedule: holding locks")
}
if mp.lockedg != 0 {
stoplockedm()
execute(mp.lockedg.ptr(), false) // Never returns.
}
top:
pp := mp.p.ptr()
pp.preempt = false
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
(a) Why does schedule() panic if mp.locks != 0?
(b) What does the mp.lockedg check accomplish?
(c) Why is it an error to be spinning with local work?
Answer
(a) If mp.locks != 0, the M is holding runtime locks. Calling schedule() could cause a deadlock because the scheduler might park the M or switch to a different goroutine that tries to acquire the same lock. The check is a safety invariant: you must release all locks before entering the scheduler.
(b) mp.lockedg is set when a goroutine has called runtime.LockOSThread(). If the M has a locked goroutine, it cannot schedule any other goroutine. stoplockedm() parks the M until its locked goroutine is runnable, then execute() runs it directly, bypassing the normal scheduling path. This ensures the goroutine always runs on its designated OS thread.
(c) An M is “spinning” when it has no local work and is actively searching for work (checking other Ps’ queues, the global queue, the network poller). If the M has local work (runnext is set or the local run queue is non-empty), it should not be marked as spinning – it should be running that work. This invariant prevents the scheduler from wastefully searching for work while ignoring work it already has, and it ensures the spinning thread count (sched.nmspinning) accurately reflects how many threads are genuinely idle.
Question 2 (Short Answer)
The findRunnable() function checks for work in a specific order. List at least five sources of work it checks, and explain why the ordering matters.
Answer
findRunnable() checks these sources (in order):
- GC work: If GC is in a STW phase (
sched.gcwaiting), stop the M. - Trace reader: If tracing is enabled, check for trace reader goroutines.
- GC marking work: If GC is in the mark phase, run GC mark workers.
- Local run queue (
pp.runqandpp.runnext): Check the P’s own run queue first. - Global run queue (
sched.runq): Check every 61 scheduling ticks to prevent starvation. - Network poller (
netpoll): Poll for ready network I/O goroutines. - Work stealing (
stealWork): Try to steal from other Ps’ run queues and timer heaps.
Why ordering matters:
- GC first: GC stop-the-world phases must be respected immediately for correctness.
- Local queue before global: Local queue access requires no lock and preserves cache locality. Checking it first is the fast path.
- Global queue periodically: The “every 61 ticks” check prevents goroutines on the global queue from being starved indefinitely by locally-produced work. The prime number 61 is chosen to avoid synchronization patterns.
- Network poller before stealing: Network-ready goroutines are “free” work that does not take from another P.
- Stealing last: Stealing is the most expensive option (requires accessing another P’s queue with atomics) and is the fallback when no local work exists.
Question 3 (True/False with Explanation)
True or False: The global run queue is checked on every call to schedule().
Answer
False. The global run queue is checked approximately every 61st scheduling tick. In findRunnable(), the code checks:
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
...
}
This is a deliberate tradeoff: checking the global queue requires acquiring sched.lock, which is contended. By checking only periodically, the scheduler avoids lock contention on the fast path. The value 61 is prime to avoid patterns where multiple Ps check at the same time. This means goroutines on the global queue may wait up to 61 scheduling decisions before being picked up, but it prevents global queue starvation.
Question 4 (What Would Happen If…)
The p struct has a runnext field described as:
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice.
What would happen to the performance of a producer-consumer pattern (one goroutine sending values on a channel, another receiving) if runnext were removed?
Answer
Without runnext, a producer-consumer pair would experience significantly higher latency:
-
Current behavior with
runnext: When the producer sends on a channel and wakes the consumer, the consumer is placed inrunnext. On the next scheduling decision, the consumer runs immediately on the same P, inheriting the remaining time slice. This creates a tight ping-pong between producer and consumer on the same CPU, preserving cache locality. -
Without
runnext: The woken consumer would be appended to the end of the P’s run queue. If there are N other runnable goroutines in the queue, the consumer would wait for all N to run before getting its turn. This adds O(N) scheduling latency to each communication round. -
Cache effects: With
runnext, the producer and consumer share CPU caches (the channel data, shared state). Without it, the consumer might be stolen by another P and run on a different CPU, causing cache misses. -
Throughput impact: For tight communicate-and-wait patterns (which are extremely common in Go), throughput could drop by 2-10x depending on run queue depth and GOMAXPROCS.
runnext effectively implements a form of “affinity scheduling” for communicating goroutine pairs.
Question 5 (Short Answer)
Explain the role of sysmon in the scheduler. What specific problems does it solve that the normal scheduling loop cannot?
Answer
sysmon is a special system monitoring goroutine that runs on a dedicated M without a P. It runs in an infinite loop, sleeping for increasing intervals (20us to 10ms). Its responsibilities include:
-
Retaking Ps from syscalls: If a goroutine has been in a syscall for >10ms,
sysmonretakes its P viaretake()and hands it to another M. Without this, Ps could be stuck on blocked syscalls indefinitely. -
Preempting long-running goroutines: If a goroutine has been running for >10ms without yielding,
sysmonsets itsstackguard0tostackPreempt, forcing a preemption check at the next function call. (In newer Go, it also sends SIGURG for async preemption.) -
Network poller deadline injection:
sysmoncallsnetpoll(0)(non-blocking) to check for ready I/O, ensuring network-blocked goroutines are woken even if no other scheduling activity is happening. -
Forced GC: If no GC has run for 2 minutes,
sysmontriggers one. -
Scavenging memory: It coordinates returning unused memory to the OS.
Why the normal scheduling loop cannot do this: The scheduling loop only runs when a P is making scheduling decisions. If all Ps are occupied (running compute-bound goroutines or stuck in syscalls), there is no scheduling activity to detect stalls. sysmon runs independently and can intervene from outside.
Question 6 (Code Reading)
Consider the execute() function’s signature and a key operation it performs:
func execute(gp *g, inheritTime bool) {
mp := getg().m
...
mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + stackGuard
...
gogo(&gp.sched)
}
What is the purpose of the inheritTime parameter? Why does the function reset stackguard0 to gp.stack.lo + stackGuard?
Answer
inheritTime: When true, the goroutine inherits the remaining time slice from the previously running goroutine (this happens when a goroutine comes from runnext). When false, the goroutine gets a fresh time slice. This is tracked to determine when a goroutine has exhausted its time quantum and should check schedtick for preemption. If every goroutine inherited time, tight runnext chains could starve other goroutines in the run queue; if none did, the scheduling overhead for communicating pairs would increase.
Resetting stackguard0: The stackguard0 field is compared against the stack pointer in every function prologue to check for stack overflow. During preemption, stackguard0 is set to the special value stackPreempt (a very large value that always triggers the check). When a goroutine is about to resume execution, stackguard0 must be reset to its normal value (stack.lo + stackGuard) so that the stack check works correctly for detecting actual stack growth needs, rather than perpetually triggering preemption.
Question 7 (Short Answer)
Why does the Go scheduler use a fixed-size local run queue (256 entries in p.runq) rather than a dynamically-sized slice?
Answer
The fixed-size array serves several purposes:
-
Lock-free access: The local run queue is implemented as a lock-free circular buffer using atomic operations on
runqheadandrunqtail. A fixed-size array makes this possible since there is no need for reallocation (which would require synchronization). A dynamically-sized slice would need a lock for resizing. -
Bounded memory: Each P’s memory footprint is predictable and bounded. With 256 entries of
guintptr(8 bytes each), each run queue uses exactly 2KB. -
Overflow to global queue: When the local queue is full, excess goroutines are moved to the global run queue (via
runqput->runqputslow, which moves half the local queue to the global queue). This provides natural load balancing and ensures no P hoards too many goroutines. -
Cache efficiency: A fixed-size array has predictable memory layout and fits in a few cache lines, improving the performance of the scheduling hot path.
Module 5: Work Stealing and Preemption
Background: Load Balancing and Preemption in Parallel Systems
A parallel system with P processors and a collection of tasks faces a fundamental scheduling question: which processor should run which task? The simplest answer – static partitioning, where tasks are divided evenly among processors at the start – works only when you know the tasks’ durations in advance and they are roughly equal. In practice, tasks vary wildly in cost. A web server might handle one request in 50 microseconds and the next in 500 milliseconds. A parallel divide-and-conquer algorithm generates subtasks whose sizes depend on the input data. Static partitioning leaves some processors idle while others are overwhelmed, and the overall completion time is dictated by the slowest processor. The problem gets worse as processor counts grow: on a 64-core machine, even modest imbalance means most of the hardware sits idle.
Work stealing is the dominant solution to this load-balancing problem, and its theoretical foundations were laid by Robert Blumofe and Charles Leiserson in their 1999 paper “Scheduling Multithreaded Computations by Work Stealing.” The key idea is elegant: each processor maintains its own local deque (double-ended queue) of tasks. When a processor finishes its current work, it first checks its own deque. If it is empty, the processor becomes a thief and picks a random victim processor to steal from, taking tasks from the opposite end of the victim’s deque. Blumofe and Leiserson proved that for a computation with total work T1 and critical path length T-infinity, a work-stealing scheduler completes execution in expected time T1/P + O(T-infinity), which is provably close to optimal. Their Cilk system at MIT demonstrated these ideas in practice and showed that stealing from the “old” end of the deque (the larger, higher-level tasks) minimizes both the number of steals and the communication overhead. The “steal half” variant, where the thief takes half the victim’s queue, further reduces the number of steal operations needed to balance load, converging in O(log n) steals.
Work stealing has become ubiquitous in systems software. Java’s ForkJoinPool, introduced in Java 7 (2011), brought work stealing to mainstream concurrent programming, implementing the fork/join paradigm where tasks recursively split into subtasks that are distributed across worker threads with per-thread deques. Intel’s Threading Building Blocks (TBB) and Cilk Plus used the same approach for C++ parallelism. In the Rust ecosystem, the Tokio async runtime uses a work-stealing scheduler where each worker thread has a local queue of async tasks, and idle workers steal from busy ones – a design that provides good latency and throughput for I/O-heavy server workloads. Even the Linux kernel’s Completely Fair Scheduler (CFS) uses a form of work stealing for its multi-core load balancer: each CPU has its own run queue, and the kernel periodically rebalances by pulling tasks from the busiest CPUs to the least busy ones, organized through a hierarchy of scheduling domains that reflect the machine’s NUMA topology. The pattern is the same everywhere – distribute state for fast-path performance, steal for balance.
Work stealing solves the problem of where to run tasks, but a complementary problem remains: how long should a task run before the scheduler reclaims the processor? This is the problem of preemption. Early operating systems like classic Mac OS and Windows 3.1 used cooperative scheduling, where programs voluntarily yielded the processor. This was simple but fragile – a single misbehaving or compute-intensive program could freeze the entire system. Modern operating systems use preemptive scheduling, typically driven by hardware timer interrupts. Linux, for example, offers multiple preemption levels through its CONFIG_PREEMPT settings: from no preemption (only at explicit schedule points, suitable for throughput-oriented servers) to full kernel preemption (suitable for latency-sensitive desktop and real-time workloads). The tension between cooperative and preemptive scheduling is not just historical – it reappears in every user-space runtime that multiplexes its own tasks onto OS threads, including Go, Erlang, and async runtimes in Rust and JavaScript.
This module examines how Go’s runtime addresses both problems. Go’s scheduler implements work stealing across per-P local run queues, with a lock-free steal-half algorithm directly inspired by the Blumofe-Leiserson framework. For preemption, Go takes a layered approach: cooperative preemption via stack-growth checks at function prologues (present since Go 1.2), and asynchronous signal-based preemption via SIGURG (added in Go 1.14 to handle tight loops without function calls). Overseeing everything is the sysmon thread – a watchdog goroutine that runs without a P and enforces 10ms time slices, reclaims processors from goroutines stuck in system calls, and polls the network. Together, these mechanisms form a scheduler that is both highly concurrent and fair, even in the face of uncooperative workloads.
Learning Objectives
By the end of this module, students will understand:
- Why distributed scheduling outperforms centralized run queues
- How Go’s work-stealing scheduler balances load across processors
- The spinning thread concept and its role in avoiding unnecessary thread wake-ups
- How the global run queue provides fairness and overflow handling
- The dual preemption strategy: cooperative (stack checks) and asynchronous (signals)
- The sysmon background thread and its role in enforcing preemption and syscall hand-off
1. Why Distributed Scheduling? (10 min)
The Problem with Centralized Run Queues
Traditional OS schedulers and early Go (before Go 1.1) used a single global run queue protected by a mutex. Every time a goroutine became runnable or a thread needed work, it had to acquire this lock. On a 64-core machine, this single lock becomes a devastating bottleneck.
The Go scheduler explicitly rejected centralization. From the top-of-file comment in proc.go:
// [src/runtime/proc.go, lines 36-56](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/proc.go;l=36)
// Worker thread parking/unparking.
// We need to balance between keeping enough running worker threads to utilize
// available hardware parallelism and parking excessive running worker threads
// to conserve CPU resources and power. This is not simple for two reasons:
// (1) scheduler state is intentionally distributed (in particular, per-P work
// queues), so it is not possible to compute global predicates on fast paths;
// (2) for optimal thread management we would need to know the future (don't park
// a worker thread when a new goroutine will be readied in near future).
//
// Three rejected approaches that would work badly:
// 1. Centralize all scheduler state (would inhibit scalability).
// 2. Direct goroutine handoff. That is, when we ready a new goroutine and there
// is a spare P, unpark a thread and handoff it the thread and the goroutine.
// This would lead to thread state thrashing, as the thread that readied the
// goroutine can be out of work the very next moment, we will need to park it.
// Also, it would destroy locality of computation as we want to preserve
// dependent goroutines on the same thread; and introduce additional latency.
// 3. Unpark an additional thread whenever we ready a goroutine and there is an
// idle P, but don't do handoff. This would lead to excessive thread parking/
// unparking as the additional threads will instantly park without discovering
// any work to do.
Key insight: The scheduler designers rejected three seemingly reasonable approaches. Centralization kills scalability. Direct handoff causes thrashing. Eager unparking wastes energy. The solution is work stealing with spinning threads.
The GMP Model Recap
Recall from Module 4:
- G (goroutine): the unit of work
- M (machine): an OS thread
- P (processor): a scheduling context with a local run queue
Each P has a local run queue (a 256-element ring buffer). Work is distributed across these queues. When a P runs out of work, it steals from others.
2. The Work Stealing Algorithm (15 min)
stealWork: The Top-Level Stealer
When findRunnable() can’t find work locally, it calls stealWork(). This function makes up to 4 passes over all Ps in a random order, trying to steal goroutines:
From src/runtime/proc.go, lines 3828-3895:
// src/runtime/proc.go, lines 3828-3895
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
// GC work may be available.
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
// ...timer stealing on last pass...
// Don't bother to attempt to steal if p2 is idle.
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, ranTimer
}
}
}
}
// No goroutines found to steal.
return nil, false, now, pollUntil, ranTimer
}
Design decisions to note:
-
Random start order (
stealOrder.start(cheaprand())): Prevents all idle Ps from hammering the same victim. The enumeration visits all Ps in a pseudo-random permutation. -
4 attempts (
stealTries = 4): Stealing is racy – a victim’s queue might be momentarily empty even if it’s about to receive work. Multiple passes increase the chance of finding work. -
Timer stealing only on the last pass (
stealTimersOrRunNextG): Timers are expensive to steal (requires locking another P’s timer heap). The algorithm tries cheaper steals first. -
Skip idle Ps (
!idlepMask.read(enum.position())): No point stealing from Ps that have no work either.
runqsteal and runqgrab: Steal Half
The actual stealing is a two-step process. runqsteal calls runqgrab to grab half the victim’s goroutines, then returns one to run immediately:
From src/runtime/proc.go, lines 7727-7747:
// src/runtime/proc.go, lines 7727-7747
// Steal half of elements from local runnable queue of p2
// and put onto local runnable queue of p.
// Returns one of the stolen elements (or nil if failed).
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
if n == 0 {
return gp
}
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
if t-h+n >= uint32(len(pp.runq)) {
throw("runqsteal: runq overflow")
}
atomic.StoreRel(&pp.runqtail, t+n) // store-release, makes the item available for consumption
return gp
}
The runqgrab function is the lock-free core. It steals n - n/2 goroutines (i.e., half, rounding up for the thief):
From src/runtime/proc.go, lines 7662-7725:
// src/runtime/proc.go, lines 7662-7725
func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
for {
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
n := t - h
n = n - n/2
if n == 0 {
if stealRunNextG {
// Try to steal from pp.runnext.
if next := pp.runnext; next != 0 {
// ...carefully steal runnext with back-off...
if !pp.runnext.cas(next, 0) {
continue
}
batch[batchHead%uint32(len(batch))] = next
return 1
}
}
return 0
}
if n > uint32(len(pp.runq)/2) { // read inconsistent h and t
continue
}
for i := uint32(0); i < n; i++ {
g := pp.runq[(h+i)%uint32(len(pp.runq))]
batch[(batchHead+i)%uint32(len(batch))] = g
}
if atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
return n
}
}
}
Why steal half? This is the classic work-stealing result from Blumofe and Leiserson (1999). If you steal only one goroutine, you might need to steal again immediately. If you steal all, you over-correct. Stealing half balances the queues in O(log n) steals.
Lock-free protocol: Note there are no mutex locks. The algorithm uses:
atomic.LoadAcqfor reading head/tail (load-acquire semantics)atomic.CasRelfor committing the steal (compare-and-swap with release semantics)- Retry loop if the CAS fails (another thread stole concurrently)
The runnext Slot
Each P has a special runnext field – a single goroutine slot for the next G to run. When a goroutine wakes another goroutine (e.g., via channel send), the awakened G goes into runnext rather than the run queue. This preserves locality: the producer and consumer likely share cache lines.
Stealing from runnext is the last resort (only on the final steal attempt). The code even includes a usleep(3) back-off to avoid thrashing when a goroutine has just placed something in runnext and is about to use it (lines 7694-7701).
3. Spinning Threads (10 min)
The Concept
A “spinning” thread is an M that has no work but is actively looking for some. It has not yet gone to sleep. Spinning threads are a controlled form of busy-waiting that trades CPU cycles for reduced latency.
From src/runtime/proc.go, lines 68-83:
// src/runtime/proc.go, lines 68-83
// A worker thread is considered spinning if it is out of local work and did
// not find work in the global run queue or netpoller; the spinning state is
// denoted in m.spinning and in sched.nmspinning. Threads unparked this way are
// also considered spinning; we don't do goroutine handoff so such threads are
// out of work initially. Spinning threads spin on looking for work in per-P
// run queues and timer heaps or from the GC before parking. If a spinning
// thread finds work it takes itself out of the spinning state and proceeds to
// execution. If it does not find work it takes itself out of the spinning
// state and then parks.
//
// If there is at least one spinning thread (sched.nmspinning>1), we don't
// unpark new threads when submitting work. To compensate for that, if the last
// spinning thread finds work and stops spinning, it must unpark a new spinning
// thread. This approach smooths out unjustified spikes of thread unparking,
// but at the same time guarantees eventual maximal CPU parallelism
// utilization.
The invariant: If there is at least one spinning thread, we don’t wake new threads when work arrives. This prevents thundering herd – imagine 1000 goroutines becoming runnable at once; without this, we’d wake 1000 threads.
Note: The Go source comment says
nmspinning>1(more than one), but the text says “at least one” and the actual code inwakep()checksnmspinning == 0(i.e., wake only if there are zero spinning threads). The>1in the comment appears to be a long-standing typo; the intended meaning is>0.
The Wake-up Policy: wakep()
The wake-up decision (in wakep()) is:
- There is an idle P available, AND
- There are no spinning worker threads (
sched.nmspinning == 0)
If both conditions hold, wake one thread. That thread becomes spinning, searches for work, and if it finds some, it calls resetspinning() which calls wakep() again – potentially waking another thread. This creates a cascade that ramps up parallelism smoothly.
From src/runtime/proc.go, lines 4021-4035:
// src/runtime/proc.go, lines 4021-4035
func resetspinning() {
gp := getg()
if !gp.m.spinning {
throw("resetspinning: not a spinning m")
}
gp.m.spinning = false
nmspinning := sched.nmspinning.Add(-1)
if nmspinning < 0 {
throw("findRunnable: negative nmspinning")
}
// M wakeup policy is deliberately somewhat conservative, so check if we
// need to wakeup another P here. See "Worker thread parking/unparking"
// comment at the top of the file for details.
wakep()
}
The Delicate Dance: Spinning to Non-Spinning Transition
The transition from spinning to non-spinning is the trickiest part of the scheduler. There is a race between a thread submitting new work and a spinning thread deciding to park:
From src/runtime/proc.go, lines 3622-3638:
// src/runtime/proc.go, lines 3622-3638
// Delicate dance: thread transitions from spinning to non-spinning
// state, potentially concurrently with submission of new work. We must
// drop nmspinning first and then check all sources again (with
// #StoreLoad memory barrier in between). If we do it the other way
// around, another thread can submit work after we've checked all
// sources but before we drop nmspinning; as a result nobody will
// unpark a thread to run the work.
//
// This applies to the following sources of work:
//
// * Goroutines added to the global or a per-P run queue.
// * New/modified-earlier timers on a per-P timer heap.
// * Idle-priority GC work (barring golang.org/issue/19112).
//
// If we discover new work below, we need to restore m.spinning as a
// signal for resetspinning to unpark a new worker thread (because
// there can be more than one starving goroutine).
A memory barrier (or fence) is a CPU instruction that constrains the order in which memory operations become visible to other cores. Without barriers, the CPU and compiler may reorder reads and writes for performance. A StoreLoad barrier ensures that all stores before the barrier are visible to other cores before any loads after the barrier execute.
The pattern for work submission:
- Submit work to queue
- StoreLoad memory barrier
- Check
sched.nmspinning
The pattern for spinning->non-spinning:
- Decrement
nmspinning - StoreLoad memory barrier
- Re-check all work sources
This is a classic example of the “flag + data” synchronization pattern. The memory barriers ensure that if both sides race, at least one of them will see the other’s update.
Discussion question: What happens if we reverse the order – check for work first, then decrement nmspinning? (Answer: A submitter could add work, see nmspinning > 0, and not wake a thread. Then the spinner decrements nmspinning and parks. The work sits unprocessed.)
4. The Global Run Queue (5 min)
Overflow: runqputslow
When a P’s local run queue is full (256 entries), the next runqput call triggers runqputslow, which moves half the local queue to the global queue:
From src/runtime/proc.go, lines 7524-7560:
// src/runtime/proc.go, lines 7524-7560
func runqputslow(pp *p, gp *g, h, t uint32) bool {
var batch [len(pp.runq)/2 + 1]*g
// First, grab a batch from local queue.
n := t - h
n = n / 2
if n != uint32(len(pp.runq)/2) {
throw("runqputslow: queue is not full")
}
for i := uint32(0); i < n; i++ {
batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()
}
if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
return false
}
batch[n] = gp
// ...optional randomization...
// Link the goroutines.
for i := uint32(0); i < n; i++ {
batch[i].schedlink.set(batch[i+1])
}
q := gQueue{batch[0].guintptr(), batch[n].guintptr(), int32(n + 1)}
// Now put the batch on global queue.
lock(&sched.lock)
globrunqputbatch(&q)
unlock(&sched.lock)
return true
}
The global queue requires sched.lock, which is why it’s avoided on the fast path.
Fairness: The schedtick % 61 Check
Without special handling, two goroutines on a local queue could starve goroutines on the global queue by constantly respawning each other. The scheduler prevents this:
From src/runtime/proc.go, lines 3440-3450:
// src/runtime/proc.go, lines 3440-3450
// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
if pp.schedtick%61 == 0 && !sched.runq.empty() {
lock(&sched.lock)
gp := globrunqget()
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
Why 61? It’s a prime number. Using a prime avoids resonance with any regular patterns in the program. If we used 64, and a program happened to schedule exactly 64 goroutines between global queue checks, we’d check at the same point every time. A prime ensures the check point moves through the schedule cycle.
5. Cooperative Preemption (5 min)
Function Prologue Stack Checks
Every non-leaf Go function begins with a prologue that checks if the stack needs to grow. The compiler inserts code equivalent to:
if SP < g.stackguard0 {
call runtime.morestack
}
The scheduler exploits this for cooperative preemption. To preempt a goroutine, it sets gp.stackguard0 = stackPreempt (a value so large that any stack check will fail). The goroutine then calls morestack, which detects the preemption request and yields:
From src/runtime/proc.go, lines 6880-6886:
// src/runtime/proc.go, lines 6880-6886
gp.preempt = true
// Every call in a goroutine checks for stack overflow by
// comparing the current stack pointer to gp->stackguard0.
// Setting gp->stackguard0 to StackPreempt folds
// preemption into the normal stack overflow check.
gp.stackguard0 = stackPreempt
From preempt.go, lines 27-33:
// Synchronous safe-points are implemented by overloading the stack
// bound check in function prologues. To preempt a goroutine at the
// next synchronous safe-point, the runtime poisons the goroutine's
// stack bound to a value that will cause the next stack bound check
// to fail and enter the stack growth implementation, which will
// detect that it was actually a preemption and redirect to preemption
// handling.
Limitation: A goroutine in a tight loop with no function calls (e.g., for { i++ }) will never check its stack and never be preempted cooperatively. This was a real problem before Go 1.14.
6. Asynchronous (Signal-Based) Preemption (10 min)
The SIGURG Choice
Go 1.14 introduced true preemptive scheduling via OS signals. The runtime sends a signal to the thread running a goroutine that needs preemption. But which signal?
From src/runtime/signal_unix.go, lines 44-74:
// src/runtime/signal_unix.go, lines 44-74
// sigPreempt is the signal used for non-cooperative preemption.
//
// There's no good way to choose this signal, but there are some
// heuristics:
//
// 1. It should be a signal that's passed-through by debuggers by
// default. On Linux, this is SIGALRM, SIGURG, SIGCHLD, SIGIO,
// SIGVTALRM, SIGPROF, and SIGWINCH, plus some glibc-internal signals.
//
// 2. It shouldn't be used internally by libc in mixed Go/C binaries
// because libc may assume it's the only thing that can handle these
// signals. For example SIGCANCEL or SIGSETXID.
//
// 3. It should be a signal that can happen spuriously without
// consequences. For example, SIGALRM is a bad choice because the
// signal handler can't tell if it was caused by the real process
// alarm or not (arguably this means the signal is broken, but I
// digress). SIGUSR1 and SIGUSR2 are also bad because those are often
// used in meaningful ways by applications.
//
// 4. We need to deal with platforms without real-time signals (like
// macOS), so those are out.
//
// We use SIGURG because it meets all of these criteria, is extremely
// unlikely to be used by an application for its "real" meaning (both
// because out-of-band data is basically unused and because SIGURG
// doesn't report which socket has the condition, making it pretty
// useless), and even if it is, the application has to be ready for
// spurious SIGURG.
const sigPreempt = _SIGURG
This is a great example of systems engineering trade-offs. The signal must be:
- Debugger-transparent (so GDB doesn’t stop on it)
- Not used by libc (for CGo compatibility)
- Safe to receive spuriously (the handler must be idempotent)
- Available on all platforms (no real-time signals)
How Async Preemption Works
From preempt.go, lines 35-43:
// Preemption at asynchronous safe-points is implemented by suspending
// the thread using an OS mechanism (e.g., signals) and inspecting its
// state to determine if the goroutine was at an asynchronous
// safe-point. Since the thread suspension itself is generally
// asynchronous, it also checks if the running goroutine wants to be
// preempted, since this could have changed. If all conditions are
// satisfied, it adjusts the signal context to make it look like the
// signaled thread just called asyncPreempt and resumes the thread.
// asyncPreempt spills all registers and enters the scheduler.
The flow is:
sysmonor GC decides goroutine G needs preemption- Calls
preemptone(pp), which callspreemptM(mp) preemptMsendsSIGURGto the OS thread running G- Signal handler fires, checks if G is at an async safe-point
- If safe, rewrites the signal return context to call
asyncPreempt asyncPreemptsaves all registers and callsschedule()
Async safe-points: Not every instruction is safe for preemption. The runtime needs to be able to find all GC roots (stack pointers). The compiler emits metadata marking which instructions are safe.
preemptone: The Combined Approach
From src/runtime/proc.go, lines 6866-6895:
// src/runtime/proc.go, lines 6866-6895
func preemptone(pp *p) bool {
mp := pp.m.ptr()
if mp == nil || mp == getg().m {
return false
}
gp := mp.curg
if gp == nil || gp == mp.g0 {
return false
}
gp.preempt = true
// Every call in a goroutine checks for stack overflow by
// comparing the current stack pointer to gp->stackguard0.
// Setting gp->stackguard0 to StackPreempt folds
// preemption into the normal stack overflow check.
gp.stackguard0 = stackPreempt
// Request an async preemption of this P.
if preemptMSupported && debug.asyncpreemptoff == 0 {
pp.preempt = true
preemptM(mp)
}
return true
}
Notice the belt-and-suspenders approach: it sets BOTH the cooperative preemption flag (stackguard0 = stackPreempt) AND sends the async signal (preemptM(mp)). Whichever fires first wins.
7. The sysmon Thread (10 min)
What is sysmon?
sysmon is a special background thread that runs without a P. It’s the scheduler’s watchdog, responsible for:
- Preempting long-running goroutines
- Retaking Ps from goroutines stuck in syscalls
- Polling the network (if no other thread is doing it)
- Triggering garbage collection
From src/runtime/proc.go, lines 6486-6502:
// src/runtime/proc.go, lines 6486-6502
func sysmon() {
lock(&sched.lock)
sched.nmsys++
checkdead()
unlock(&sched.lock)
lastgomaxprocs := int64(0)
lasttrace := int64(0)
idle := 0 // how many cycles in succession we had not wokeup somebody
delay := uint32(0)
for {
if idle == 0 { // start with 20us sleep...
delay = 20
} else if idle > 50 { // start doubling the sleep after 1ms...
delay *= 2
}
if delay > 10*1000 { // up to 10ms
delay = 10 * 1000
}
usleep(delay)
// ...
}
}
Adaptive sleep: sysmon starts by polling every 20 microseconds. If it finds nothing to do for 50 iterations (~1ms), it starts doubling its sleep interval, up to a maximum of 10ms. When it finds work again, it resets. This balances responsiveness against CPU overhead.
retake: Preempting and Reclaiming Ps
The retake function is called by sysmon to enforce time slices and reclaim Ps from syscalls:
From src/runtime/proc.go, lines 6626-6628:
// src/runtime/proc.go, lines 6626-6628
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms
From src/runtime/proc.go, lines 6656-6671:
// src/runtime/proc.go, lines 6656-6671
// Preempt G if it's running on the same schedtick for
// too long. This could be from a single long-running
// goroutine or a sequence of goroutines run via
// runnext, which share a single schedtick time slice.
schedt := int64(pp.schedtick)
if int64(pd.schedtick) != schedt {
pd.schedtick = uint32(schedt)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
preemptone(pp)
// If pp is in a syscall, preemptone doesn't work.
sysretake = true
}
The logic is:
- If
pp.schedtickchanged since last check, the P is making progress – update the timestamp - If
pp.schedtickhasn’t changed for 10ms, the same goroutine (or runnext chain) has been running too long – preempt it
Syscall Hand-off
When a goroutine enters a syscall, its M is blocked in the kernel. But the P is sitting idle. The retake function can steal this P and give it to another M:
From src/runtime/proc.go, lines 6700-6705:
// src/runtime/proc.go, lines 6700-6705
// On the one hand we don't want to retake Ps if there is no other work to do,
// but on the other hand we want to retake them eventually
// because they can prevent the sysmon thread from deep sleep.
if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
thread.resume()
goto done
The P is retaken from a syscalling M unless ALL of these conditions hold:
- The P’s run queue is empty (no work waiting)
- There are spinning threads or idle Ps available (system is not starved)
- The syscall started less than 10ms ago
When the M returns from the syscall, it finds its P gone. It must acquire a new P to continue running Go code, or park itself and put the goroutine on the global queue.
The schedule() Function: Putting It All Together
The main scheduler loop ties everything together:
From src/runtime/proc.go, lines 4135-4231:
// src/runtime/proc.go, lines 4135-4231
func schedule() {
mp := getg().m
if mp.locks != 0 {
throw("schedule: holding locks")
}
if mp.lockedg != 0 {
stoplockedm()
execute(mp.lockedg.ptr(), false) // Never returns.
}
// ...
top:
pp := mp.p.ptr()
pp.preempt = false
// Safety check: if we are spinning, the run queue should be empty.
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// ...
// This thread is going to run a goroutine and is not spinning anymore,
// so if it was marked as spinning we need to reset it now and potentially
// start a new spinning M.
if mp.spinning {
resetspinning()
}
// ...
}
The search order in findRunnable() is:
- Check GC work
- Every 61st tick, check the global run queue (fairness)
- Check the local run queue
- Check the global run queue (again, without the tick restriction)
- Poll network
- Steal from other Ps via
stealWork() - If nothing found, park the thread
8. Summary: The Complete Picture
┌─────────────────────────────────────┐
│ Global Run Queue │
│ (overflow + fairness, locked) │
└──────────┬────────────┬──────────────┘
│ │
schedtick%61 runqputslow
│ │
┌─────────────────────┼────────────┼─────────────────────┐
│ │ │ │
┌────▼────┐ ┌─────▼────┐ ┌────▼─────┐ ┌─────────┐
│ P0 │◄─steal───│ P1 │ │ P2 │──steal──►│ P3 │
│ runq │ │ runq │ │ runq │ │ runq │
│ runnext │ │ runnext │ │ runnext │ │ runnext │
└────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘
│ │ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼─────┐ ┌────▼────┐
│ M0 │ │ M1 │ │ M2 │ │ M3 │
│ (thread)│ │ (thread)│ │ (thread) │ │ (thread)│
└─────────┘ └─────────┘ └──────────┘ └─────────┘
sysmon thread (no P):
- preempts goroutines after 10ms
- retakes Ps from syscalls
- polls network
Key Takeaways
-
Distributed state is fast but complex: Per-P queues avoid lock contention but require careful synchronization with atomics and memory barriers.
-
Work stealing balances load: Steal half, random victim selection, multiple attempts.
-
Spinning threads prevent thundering herd: At most one spinning thread is sufficient to discover new work and cascade wake-ups.
-
Two-tier preemption: Cooperative (stack checks) handles the common case; async signals (SIGURG) handle tight loops.
-
sysmon is the safety net: It runs independently, enforcing time slices and reclaiming resources from blocked syscalls.
Discussion Questions
-
Why does the scheduler steal half the victim’s goroutines instead of just one? What would change if it stole all of them?
-
The spinning thread protocol uses
sched.nmspinningas a shared counter. Why not use per-P spinning flags instead? -
Consider a program that makes many blocking syscalls (e.g., a web server doing file I/O). How does the P hand-off mechanism keep the program responsive?
-
Why is 61 a better choice than 64 for the global queue fairness check interval?
-
What would happen in a pre-1.14 Go runtime if a goroutine entered
for { }(an empty infinite loop)?
Further Reading
- Scalable Go Scheduler Design Doc – Dmitry Vyukov’s original proposal
- Blumofe, R. and Leiserson, C. “Scheduling Multithreaded Computations by Work Stealing” (1999)
- Go 1.14 Release Notes – Asynchronous preemption
- proposal: Non-cooperative goroutine preemption
Module 5: Work Stealing and Preemption
Comprehension Check
Question 1 (Code Reading)
Study the stealWork function from runtime/proc.go:
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
...
(a) Why does the function try to steal 4 times rather than just once?
(b) Why is the iteration order randomized with cheaprand()?
(c) Why does it only steal timers and runnext on the last iteration (stealTimersOrRunNextG)?
Answer
(a) Multiple passes increase the probability of finding work. Other Ps are concurrently producing and consuming goroutines. A single pass might miss work due to race conditions: a goroutine might be added to a P’s queue just after the stealer checked it. Four attempts give a reasonable probability of finding newly available work without being overly expensive.
(b) Randomization prevents systematic bias. If all idle Ps always started scanning from P0, they would all contend on the same victim’s run queue. By randomizing the start position, stealers spread out across victims, reducing contention. cheaprand() is used rather than a full PRNG because it is faster and cryptographic quality is not needed.
(c) Stealing runnext and timers is more aggressive and potentially more disruptive:
runnextrepresents a goroutine that the victim P’s current goroutine just readied, likely for a tight communication pattern. Stealing it breaks locality and hurts the victim’s performance. It should only be done as a last resort.- Timers belong to a specific P and stealing them requires acquiring the timer lock on the remote P. This is expensive and only worthwhile if no regular work was found in earlier passes.
By deferring these to the last iteration, the scheduler prefers to steal regular run queue entries first.
Question 2 (Short Answer)
Explain the “spinning thread” mechanism. What is a spinning thread, and why does the scheduler limit the number of spinning threads?
Answer
A spinning thread is an M that has no local work and is actively searching for work by checking other Ps’ run queues, the global queue, and the network poller. Its m.spinning flag is set to true, and sched.nmspinning tracks the global count.
Purpose: Spinning threads are ready to immediately pick up new work without the latency of being unparked from sleep. They act as a buffer of ready workers.
Why limit them: The scheduler maintains the invariant that at most one thread should be spinning at a time (more precisely, it avoids unparking new threads when there is already a spinning thread). The rule in wakep() is:
Unpark a new thread only if there is an idle P AND there are no spinning threads.
This prevents thread thrashing: if the scheduler unparked a thread for every new goroutine, threads would frequently be unparked only to find no work and immediately park again. This wastes CPU (the unpark/park cycle costs thousands of cycles) and power.
Compensation mechanism: When the last spinning thread finds work and stops spinning, it must call wakep() to potentially unpark a new spinning thread. This ensures at least one thread is always ready to find new work.
Question 3 (True/False with Explanation)
True or False: The Go scheduler’s cooperative preemption (checking stackguard0 at function prologues) guarantees that a goroutine cannot monopolize a CPU for more than 10ms.
Answer
False. Cooperative preemption only triggers at function call sites. A goroutine running a tight loop without function calls (e.g., for { i++ }) will never hit a preemption check and can monopolize the CPU indefinitely.
This was a real problem in Go before version 1.14. The solution was asynchronous preemption: the sysmon thread sends a SIGURG signal to the M running the long-running goroutine. The signal handler injects a preemption point by modifying the goroutine’s saved state, causing it to yield at a safe point.
However, even async preemption has limitations:
- The goroutine must be at a point where its stack can be safely scanned (an “async-safe point”)
- Certain runtime-internal code paths disable preemption (
mp.locks > 0) - The signal delivery itself has some latency
So while async preemption greatly reduces the window, it does not provide hard real-time guarantees of exactly 10ms response time.
Question 4 (What Would Happen If…)
What would happen if work stealing always stole the entire run queue of the victim P instead of stealing half?
Answer
Stealing the entire queue would cause several problems:
-
Thrashing: If P1 steals all of P2’s work, P2 would immediately become idle and start stealing – possibly from P1. This ping-pong effect would waste CPU on stealing overhead rather than executing goroutines.
-
Loss of locality: The goroutines in P2’s queue likely have cache affinity for P2’s CPU. Moving all of them at once causes more cache misses than moving half.
-
Unfairness: The victim P’s current goroutine may be producing work for its local queue (e.g., spawning goroutines). Stealing everything leaves the victim unable to schedule any of its own recently-produced work.
-
Contention: Stealing the full queue takes longer (more atomic operations, more memory copies), increasing the window for contention with the victim.
Stealing half is a well-studied strategy from the work-stealing literature (Cilk, etc.). It provides a good balance: the thief gets enough work to stay busy for a while, and the victim retains enough work to continue without immediately needing to steal from others. The runqgrab function implements this by calculating n = victim_queue_size / 2.
Question 5 (Code Reading)
Consider the runqsteal function:
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
if n == 0 {
return gp
}
h := atomic.LoadAcq(&pp.runqhead)
if t-h+n >= uint32(len(pp.runq)) {
throw("runqsteal: runq overflow")
}
atomic.StoreRel(&pp.runqtail, t+n)
return gp
}
Why does this function use atomic.LoadAcq and atomic.StoreRel? What memory ordering guarantee do these provide that a plain load/store would not?
Answer
The local run queue is a lock-free single-producer, multi-consumer circular buffer:
- The owning P writes to
runqtail(producer) - Other Ps read from
runqhead(consumers, via stealing)
atomic.LoadAcq(&pp.runqhead) (load-acquire): Ensures that all memory writes visible to the thread that last modified runqhead are also visible to the current thread. This guarantees that after reading runqhead, we see the correct state of any goroutines that were consumed (removed from the queue). Without acquire semantics, the CPU might reorder the load before prior stores, leading to reading stale queue entries.
atomic.StoreRel(&pp.runqtail, t+n) (store-release): Ensures that all the writes to pp.runq elements (placing the stolen goroutines into the thief’s queue) are visible to other threads before the updated runqtail becomes visible. Without release semantics, another thread might see the new tail but read uninitialized or stale goroutine pointers from the queue.
Together, acquire-release semantics provide the minimum synchronization needed for the lock-free queue to operate correctly without using a full memory barrier (which would be more expensive) or a mutex.
Question 6 (Short Answer)
Explain how SIGURG-based asynchronous preemption works in Go. Why was SIGURG chosen as the signal?
Answer
Mechanism:
sysmondetects a goroutine running for >10ms on a P (viaretake()).- It calls
preemptone(pp), which setsgp.preempt = trueandgp.stackguard0 = stackPreempt. - If cooperative preemption is insufficient (no function calls), it calls
signalM(mp, sigPreempt)which sendsSIGURGto the M’s OS thread. - The signal handler (
sighandler) runsdoSigPreempt, which:- Checks if the goroutine is at an async-safe point (a point where the stack map is available for GC)
- If safe, injects a call to
asyncPreemptby modifying the signal context’s PC
asyncPreempt(an assembly function) saves all registers and callsasyncPreempt2, which callsgopreempt_m, which puts the goroutine back into the run queue.
Why SIGURG:
- It is one of the few signals not commonly used by applications or the system.
- Sending SIGURG to a process does not have visible side effects (unlike SIGSEGV, SIGSTOP, etc.).
- It does not terminate the process by default (its default disposition is “ignore”).
- It can be sent to a specific thread via
tgkillon Linux. - Other candidates (SIGUSR1, SIGUSR2) are commonly used by application code and libraries, so using them would conflict. SIGURG is traditionally used only for TCP out-of-band data, which is extremely rare in practice.
Question 7 (Short Answer)
What is the retake() function’s role, and what are its two main responsibilities?
Answer
retake() is called by sysmon and iterates over all Ps to handle two situations:
-
Preempting long-running goroutines (
_Prunning): If a P has been in the running state for more than onesysmontick (~10ms),retake()setsgp.stackguard0 = stackPreempton the running goroutine to trigger cooperative preemption, and sends SIGURG for async preemption. -
Retaking Ps from syscalls (
_Psyscall): If a P’s M has been in a syscall for >10ms (and there is work to do or idle Ps available),retake()callshandoffp(pp)to take the P away from the syscall-blocked M and either:- Starts a new M to run goroutines from the P’s queue
- Puts the P on the idle list if its queue is empty
Without retake(), a single long-running goroutine or blocking syscall could monopolize a P indefinitely, starving other goroutines.
Module 6: Synchronization Primitives
Background: The Evolution of Synchronization
The fundamental challenge of concurrent programming is deceptively simple to state: when multiple threads of execution share mutable state, their interleaved operations can produce results that no sequential execution would. A counter incremented by two threads can lose updates; a linked list modified concurrently can develop cycles or dangling pointers; a partially-written data structure can be observed in a state that its author never intended. This problem is as old as multiprogramming itself, and the history of systems software is in many ways the history of increasingly sophisticated answers to it.
The formal study of synchronization begins with Edsger Dijkstra’s 1965 paper introducing the semaphore, a counting primitive with two operations – P (wait) and V (signal) – sufficient to solve mutual exclusion and coordination problems. Dijkstra’s work established the intellectual framework, but practical implementations required hardware support. Early processors offered test-and-set instructions; later architectures introduced compare-and-swap (CAS), first appearing on the IBM System/370 in 1970, which became the workhorse of lock-free programming. Modern hardware provides further refinements: x86 processors implement Total Store Order (TSO), a relatively strong memory model where stores are seen in program order by other cores, while ARM and RISC-V use weaker ordering models that demand explicit barriers or load-acquire/store-release instructions. These architectural differences mean that correct synchronization code is not portable at the instruction level – a lesson that has motivated language-level memory models like C11’s and Go’s.
A pivotal innovation in the Linux ecosystem was the futex (fast userspace mutex), introduced by Hubertus Franke, Matthew Kirkwood, and Rusty Russell in 2002 and merged into Linux 2.5.7. The key insight was that most lock acquisitions are uncontended: if a thread can acquire a lock with a single atomic operation in user space, there is no reason to enter the kernel at all. The futex syscall is invoked only on the slow path – when a thread must sleep because the lock is held, or when it must wake a sleeping waiter. This design eliminated the overhead of a syscall on every lock/unlock cycle, and it remains the foundation of pthread mutexes, condition variables, and many user-space synchronization libraries on Linux to this day. The design also inspired similar approaches on other platforms, though macOS takes a different path using Mach semaphores and pthread primitives.
Real-world synchronization involves tradeoffs that go beyond correctness. Fairness and throughput are often in tension: a lock that always hands ownership to the longest-waiting thread (strict FIFO) prevents starvation but suffers from “lock convoy” effects and poor cache locality, while a lock that lets any thread grab ownership (barging) maximizes throughput at the risk of starving unlucky waiters. The evolution from simple spinlocks to sleeping locks to adaptive mutexes – which spin briefly before sleeping, as seen in Linux’s mutex implementation and Windows’s SRWLOCK – reflects decades of effort to balance these concerns. Priority inversion, famously diagnosed in the 1997 Mars Pathfinder mission when a low-priority thread holding a bus mutex blocked the high-priority watchdog task, led to the widespread adoption of priority inheritance protocols. Reader-writer locks introduce their own design space: Linux’s percpu_rwsem optimizes the common read path by using per-CPU counters, accepting a heavyweight write-side operation in exchange. Lock-free and wait-free data structures, built directly on hardware atomics, eliminate blocking entirely but are notoriously difficult to design correctly and do not always outperform well-tuned locks under real workloads.
Go’s synchronization stack reflects all of these lessons. It is built in layers: hardware atomics at the bottom, OS primitives (futexes on Linux, semaphores on macOS) above them, a runtime mutex for the scheduler’s own internal use, and finally the sema.go semaphore that powers sync.Mutex, sync.RWMutex, and other user-visible primitives. Go’s sync.Mutex even implements an explicit starvation mode inspired by the fairness-vs-throughput tradeoff – it barges under low contention for throughput but switches to direct handoff after a goroutine has waited more than 1ms. This module walks through each layer from the bottom up, showing how these abstractions compose and where the hard-won lessons of synchronization history appear in production code.
Learning Objectives
By the end of this module, students will understand:
- How hardware atomics (CAS, load-acquire, store-release) underpin all synchronization
- The futex abstraction on Linux and its use in the Go runtime
- How macOS uses semaphores instead of futexes for the same purpose
- The runtime mutex implementation (lock_spinbit.go)
- The
sema.gosemaphore that powerssync.Mutex, including its treap data structure - How starvation is handled with handoff mode
- The reader-writer mutex and the
rwmutexMaxReaderstrick
1. The Need for Synchronization (5 min)
Mutual Exclusion
When multiple goroutines (or threads) access shared data, we need to ensure that at most one is modifying the data at a time. Without this, we get data races: torn reads, lost updates, and corrupted state.
Ordering
Beyond mutual exclusion, we often need to ensure that one operation happens before another. A goroutine writing to a channel must complete before the goroutine reading from that channel sees the value. This requires memory ordering guarantees that go beyond simple atomicity.
The Synchronization Stack in Go
Go has a layered synchronization architecture:
User-visible: sync.Mutex, sync.RWMutex, sync.WaitGroup, channels
│
▼
Runtime: sema.go (semacquire1 / semrelease1)
│
▼
Runtime: runtime mutex (lock_spinbit.go: lock2 / unlock2)
│
▼
OS interface: lock_futex.go (Linux) or lock_sema.go (macOS)
notesleep/notewakeup, semasleep/semawakeup
│
▼
Hardware: atomic CAS, load-acquire, store-release
Each layer builds on the one below. We’ll work from the bottom up.
2. Hardware Atomics (5 min)
Compare-and-Swap (CAS)
The fundamental building block. CAS(addr, old, new) atomically:
- Reads
*addr - If it equals
old, writesnewand returns true - Otherwise returns false
In Go’s runtime: atomic.Cas(addr, old, new), atomic.Casuintptr(addr, old, new).
Load-Acquire and Store-Release
These provide memory ordering guarantees:
-
Load-Acquire (
atomic.LoadAcq): No subsequent memory operations can be reordered before this load. Used by consumers to see all writes that happened before the corresponding store-release. -
Store-Release (
atomic.StoreRel,atomic.CasRel): No preceding memory operations can be reordered after this store. Used by producers to publish data.
These appear throughout the run queue implementation:
src/runtime/proc.go, lines 7664-7665
// src/runtime/proc.go, lines 7664-7665 (in runqgrab)
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
src/runtime/proc.go, line 7721
// src/runtime/proc.go, line 7721 (in runqgrab)
if atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
Atomic Exchange
atomic.Xchg(addr, new) atomically writes new and returns the old value. Used in the mutex fast path:
src/runtime/lock_spinbit.go, line 165
// src/runtime/lock_spinbit.go, line 165
v8 := atomic.Xchg8(k8, mutexLocked)
if v8&mutexLocked == 0 {
// We got the lock!
return
}
3. Futex: The Fundamental Blocking Primitive (Linux) (10 min)
What is a Futex?
A futex (“fast userspace mutex”) is a Linux system call that lets a thread sleep until a memory location changes value. The key insight: the fast path (uncontended lock/unlock) never enters the kernel. Only when contention occurs does the thread make a syscall to sleep.
The Go runtime on Linux (lock_futex.go) uses futexes for two purposes:
- One-time notifications (
notesleep/notewakeup) – used for thread synchronization in the runtime itself - Thread parking (
semasleep/semawakeup) – used to put Ms to sleep and wake them
notesleep / notewakeup: One-Time Notifications
A note is the simplest synchronization primitive: a one-shot event. One thread sleeps on it; another wakes it. It cannot be reused without calling noteclear.
src/runtime/lock_futex.go, lines 21-33
// src/runtime/lock_futex.go, lines 21-33
// One-time notifications.
func noteclear(n *note) {
n.key = 0
}
func notewakeup(n *note) {
old := atomic.Xchg(key32(&n.key), 1)
if old != 0 {
print("notewakeup - double wakeup (", old, ")\n")
throw("notewakeup - double wakeup")
}
futexwakeup(key32(&n.key), 1)
}
The protocol:
noteclearsetskey = 0notewakeupatomically setskey = 1, then callsfutexwakeup- Double wakeup is a fatal error (this is a one-shot primitive)
The sleeping side:
src/runtime/lock_futex.go, lines 35-53
// src/runtime/lock_futex.go, lines 35-53
func notesleep(n *note) {
gp := getg()
if gp != gp.m.g0 {
throw("notesleep not on g0")
}
ns := int64(-1)
if *cgo_yield != nil {
// Sleep for an arbitrary-but-moderate interval to poll libc interceptors.
ns = 10e6
}
for atomic.Load(key32(&n.key)) == 0 {
gp.m.blocked = true
futexsleep(key32(&n.key), 0, ns)
if *cgo_yield != nil {
asmcgocall(*cgo_yield, nil)
}
gp.m.blocked = false
}
}
Key details:
- Must run on g0 (the system stack). This is a low-level primitive for blocking OS threads, not goroutines.
- The
futexsleep(key32(&n.key), 0, ns)call says: “sleep if*key == 0”. The kernel checks the value atomically with going to sleep, avoiding the lost-wakeup race. - The outer
forloop handles spurious wakeups. - With CGo, it polls periodically (every 10ms) for libc interceptors.
semasleep / semawakeup: Thread Parking
These are used by the runtime mutex to park and wake OS threads:
src/runtime/lock_futex.go, lines 138-163
// src/runtime/lock_futex.go, lines 138-163
//go:nosplit
func semasleep(ns int64) int32 {
mp := getg().m
for v := atomic.Xadd(&mp.waitsema, -1); ; v = atomic.Load(&mp.waitsema) {
if int32(v) >= 0 {
return 0
}
futexsleep(&mp.waitsema, v, ns)
if ns >= 0 {
if int32(v) >= 0 {
return 0
} else {
return -1
}
}
}
}
//go:nosplit
func semawakeup(mp *m) {
v := atomic.Xadd(&mp.waitsema, 1)
if v == 0 {
futexwakeup(&mp.waitsema, 1)
}
}
The protocol uses mp.waitsema as a counter:
semasleep: decrements the counter. If it goes negative, the thread sleeps via futex.semawakeup: increments the counter. If it was negative (meaning someone is sleeping), wakes them.
This is a counting semaphore: if semawakeup is called before semasleep, the subsequent semasleep returns immediately.
4. Semaphore-Based Blocking (macOS / Darwin) (5 min)
macOS doesn’t have futexes. Instead, lock_sema.go uses OS-level semaphores (pthread mutexes and condition variables under the hood). The API is the same (notesleep/notewakeup, semasleep/semawakeup), but the implementation differs.
notesleep / notewakeup on macOS
src/runtime/lock_sema.go, lines 23-44
// src/runtime/lock_sema.go, lines 23-44
func notewakeup(n *note) {
var v uintptr
for {
v = atomic.Loaduintptr(&n.key)
if atomic.Casuintptr(&n.key, v, locked) {
break
}
}
// Successfully set waitm to locked.
// What was it before?
switch {
case v == 0:
// Nothing was waiting. Done.
case v == locked:
// Two notewakeups! Not allowed.
throw("notewakeup - double wakeup")
default:
// Must be the waiting m. Wake it up.
semawakeup((*m)(unsafe.Pointer(v)))
}
}
The note’s key field serves triple duty:
0= not yet signaled, no one waitinglocked(= 1) = signaled- Any other value = pointer to the waiting M
src/runtime/lock_sema.go, lines 46-72
// src/runtime/lock_sema.go, lines 46-72
func notesleep(n *note) {
gp := getg()
if gp != gp.m.g0 {
throw("notesleep not on g0")
}
semacreate(gp.m)
if !atomic.Casuintptr(&n.key, 0, uintptr(unsafe.Pointer(gp.m))) {
// Must be locked (got wakeup).
if n.key != locked {
throw("notesleep - waitm out of sync")
}
return
}
// Queued. Sleep.
gp.m.blocked = true
if *cgo_yield == nil {
semasleep(-1)
} else {
const ns = 10e6
for atomic.Loaduintptr(&n.key) == 0 {
semasleep(ns)
asmcgocall(*cgo_yield, nil)
}
}
gp.m.blocked = false
}
The protocol:
- Sleeper tries to CAS
keyfrom 0 to&m(its own M pointer) - If CAS fails, the note was already signaled – return immediately
- If CAS succeeds, call
semasleep(-1)to block on the M’s OS semaphore - Waker CASes
keytolocked. If the old value was an M pointer, callssemawakeup(m)
Comparison with futex: The futex version is simpler because the kernel handles the “check-and-sleep” atomicity. On macOS, the runtime must carefully encode the waiting state in the key field and use CAS to avoid races.
5. The Runtime Mutex (10 min)
Architecture: lock_spinbit.go
The runtime mutex (runtime.mutex) is used internally throughout the Go runtime – for the scheduler lock, timer heaps, and more. The current implementation (lock_spinbit.go, introduced in Go 1.24) uses an elegant bit-packing scheme.
src/runtime/lock_spinbit.go, lines 29-45
// src/runtime/lock_spinbit.go, lines 29-45
// The mutex state consists of four flags and a pointer. The flag at bit 0,
// mutexLocked, represents the lock itself. Bit 1, mutexSleeping, is a hint that
// the pointer is non-nil. The fast paths for locking and unlocking the mutex
// are based on atomic 8-bit swap operations on the low byte; bits 2 through 7
// are unused.
//
// Bit 8, mutexSpinning, is a try-lock that grants a waiting M permission to
// spin on the state word. Most other Ms must attempt to spend their time
// sleeping to reduce traffic on the cache line.
//
// Bit 9, mutexStackLocked, is a try-lock that grants an unlocking M permission
// to inspect the list of waiting Ms and to pop an M off of that stack.
//
// The upper bits hold a (partial) pointer to the M that most recently went to
// sleep. The sleeping Ms form a stack linked by their mWaitList.next fields.
The state word layout:
┌──────────────────────────────┬────┬────┬───────┬─────────┬────────┐
│ M pointer (upper bits) │ 9 │ 8 │ 7..2 │ 1 │ 0 │
│ (head of waiter stack) │stkL│spin│unused │sleeping │locked │
└──────────────────────────────┴────┴────┴───────┴─────────┴────────┘
The Fast Path: 8-bit Swap
src/runtime/lock_spinbit.go, lines 155-171
// src/runtime/lock_spinbit.go, lines 155-171
func lock2(l *mutex) {
gp := getg()
if gp.m.locks < 0 {
throw("runtime·lock: lock count")
}
gp.m.locks++
k8 := key8(&l.key)
// Speculative grab for lock.
v8 := atomic.Xchg8(k8, mutexLocked)
if v8&mutexLocked == 0 {
if v8&mutexSleeping != 0 {
atomic.Or8(k8, mutexSleeping)
}
return
}
semacreate(gp.m)
// ...slow path...
}
The uncontended case is a single atomic.Xchg8 – an 8-bit atomic swap of just the low byte. This is extremely fast because:
- It’s a single instruction on modern CPUs
- It only touches the low byte, leaving the waiter stack pointer intact
- The 8-bit operation avoids ABA problems with the pointer bits
The Slow Path: Spin then Sleep
src/runtime/lock_spinbit.go, lines 182-257
// src/runtime/lock_spinbit.go, lines 182-257
var weSpin, atTail, haveTimers bool
v := atomic.Loaduintptr(&l.key)
tryAcquire:
for i := 0; ; i++ {
if v&mutexLocked == 0 {
// Try to acquire...
}
if !weSpin && v&mutexSpinning == 0 && atomic.Casuintptr(&l.key, v, v|mutexSpinning) {
v |= mutexSpinning
weSpin = true
}
if weSpin || atTail || mutexPreferLowLatency(l) {
if i < spin {
procyield(mutexActiveSpinSize) // ~30 PAUSE instructions
// ...
} else if i < spin+mutexPassiveSpinCount {
osyield()
// ...
}
}
// Go to sleep
gp.m.mWaitList.next = mutexWaitListHead(v)
next := (uintptr(unsafe.Pointer(gp.m)) &^ mutexMMask) | v&mutexMMask | mutexSleeping
if atomic.Casuintptr(&l.key, v, next) {
semasleep(-1)
// ...
}
}
The slow path progression:
- Active spin (
procyield): Execute PAUSE instructions. Only for multi-CPU systems, only 4 iterations. - Passive spin (
osyield): Yield the CPU to other threads. Only 1 iteration. - Sleep (
semasleep): Push ourselves onto the waiter stack and call the OS to sleep.
The spin bit: Only one thread at a time gets the mutexSpinning bit. This prevents all waiting threads from spinning simultaneously (which would waste CPU). The other waiters go to sleep.
Anti-starvation: Threads at the tail of the waiter stack (atTail = true) get permission to spin, preventing indefinite starvation.
The Waiter Stack
Waiting Ms are linked through their mWaitList.next fields, forming a LIFO stack. The head of the stack is packed into the upper bits of l.key:
src/runtime/lock_spinbit.go, lines 88-91
// src/runtime/lock_spinbit.go, lines 88-91
type mWaitList struct {
next muintptr // next m waiting for lock
startTicks int64 // when this m started waiting
}
When a thread goes to sleep, it CASes its M pointer into the upper bits of l.key, linking to the previous head via mWaitList.next. When unlock2 wakes a thread, it pops from this stack.
6. The sema.go Semaphore (15 min)
Purpose
The sema.go semaphore is the mechanism that sync.Mutex, sync.RWMutex, and sync.WaitGroup use to block and wake goroutines (not OS threads). Unlike the runtime mutex which blocks Ms, this semaphore blocks Gs.
src/runtime/sema.go, lines 5-15
// src/runtime/sema.go, lines 5-15
// Semaphore implementation exposed to Go.
// Intended use is provide a sleep and wakeup
// primitive that can be used in the contended case
// of other synchronization primitives.
// Thus it targets the same goal as Linux's futex,
// but it has much simpler semantics.
//
// That is, don't think of these as semaphores.
// Think of them as a way to implement sleep and wakeup
// such that every sleep is paired with a single wakeup,
// even if, due to races, the wakeup happens before the sleep.
The Hash Table: semTable
To avoid a single global lock, the semaphore system uses a hash table indexed by the address of the semaphore word:
src/runtime/sema.go, lines 48-58
// src/runtime/sema.go, lines 48-58
// Prime to not correlate with any user patterns.
const semTabSize = 251
type semTable [semTabSize]struct {
root semaRoot
pad [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}
func (t *semTable) rootFor(addr *uint32) *semaRoot {
return &t[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root
}
- 251 buckets (prime, to avoid correlation with allocation patterns)
- Each bucket padded to a cache line to prevent false sharing
- Address shifted right by 3 before hashing (alignment)
The Treap Data Structure
Each semaRoot contains a treap (tree + heap): a balanced binary search tree where:
- The BST property is on the semaphore address: left subtree has smaller addresses, right subtree has larger
- The heap property is on a random priority (
ticket): parents have smaller tickets than children
src/runtime/sema.go, lines 30-44
// src/runtime/sema.go, lines 30-44
// A semaRoot holds a balanced tree of sudog with distinct addresses (s.elem).
// Each of those sudog may in turn point (through s.waitlink) to a list
// of other sudogs waiting on the same address.
// The operations on the inner lists of sudogs with the same address
// are all O(1). The scanning of the top-level semaRoot list is O(log n),
// where n is the number of distinct addresses with goroutines blocked
// on them that hash to the given semaRoot.
type semaRoot struct {
lock mutex
treap *sudog // root of balanced tree of unique waiters.
nwait atomic.Uint32 // Number of waiters. Read w/o the lock.
}
The two-level structure:
treap (BST on address, heap on ticket)
┌───────┐
│addr=A │
│ticket=3│
└───┬───┘
/ \
┌───────┐ ┌───────┐
│addr=B │ │addr=C │
│ticket=7│ │ticket=5│
└───┬───┘ └───────┘
│
waitlink list (same address B):
sudog1 -> sudog2 -> sudog3
Within each treap node, goroutines waiting on the same address form a linked list via waitlink/waittail. Operations on this inner list are O(1). Finding the right address in the treap is O(log n).
semacquire1: The Core Acquire
src/runtime/sema.go, lines 146-201
// src/runtime/sema.go, lines 146-201
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
gp := getg()
if gp != gp.m.curg {
throw("semacquire not on the G stack")
}
// Easy case.
if cansemacquire(addr) {
return
}
// Harder case:
// increment waiter count
// try cansemacquire one more time, return if succeeded
// enqueue itself as a waiter
// sleep
// (waiter descriptor is dequeued by signaler)
s := acquireSudog()
root := semtable.rootFor(addr)
// ...
for {
lockWithRank(&root.lock, lockRankRoot)
root.nwait.Add(1)
// Check cansemacquire to avoid missed wakeup.
if cansemacquire(addr) {
root.nwait.Add(-1)
unlock(&root.lock)
break
}
// Any semrelease after the cansemacquire knows we're waiting
// (we set nwait above), so go to sleep.
root.queue(addr, s, lifo)
goparkunlock(&root.lock, reason, traceBlockSync, 4+skipframes)
if s.ticket != 0 || cansemacquire(addr) {
break
}
}
// ...
releaseSudog(s)
}
The flow:
- Fast path: Try
cansemacquire(atomic decrement if > 0). If it succeeds, return immediately – no locks needed. - Slow path: Acquire the semaRoot lock, increment
nwait, try once more (to avoid missed wakeup), then enqueue and park viagoparkunlock. - On wakeup: Check
s.ticket(handoff mode) or retrycansemacquire.
cansemacquire: The Atomic Fast Path
src/runtime/sema.go, lines 291-301
// src/runtime/sema.go, lines 291-301
func cansemacquire(addr *uint32) bool {
for {
v := atomic.Load(addr)
if v == 0 {
return false
}
if atomic.Cas(addr, v, v-1) {
return true
}
}
}
A simple CAS loop: atomically decrement the counter if it’s positive. This is the uncontended fast path that avoids all locks.
semrelease1: Release and Wake
src/runtime/sema.go, lines 207-289
// src/runtime/sema.go, lines 207-289
func semrelease1(addr *uint32, handoff bool, skipframes int) {
root := semtable.rootFor(addr)
atomic.Xadd(addr, 1)
// Easy case: no waiters?
if root.nwait.Load() == 0 {
return
}
// Harder case: search for a waiter and wake it.
lockWithRank(&root.lock, lockRankRoot)
if root.nwait.Load() == 0 {
unlock(&root.lock)
return
}
s, t0, tailtime := root.dequeue(addr)
if s != nil {
root.nwait.Add(-1)
}
unlock(&root.lock)
if s != nil {
// ...profiling...
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes)
if s.ticket == 1 && getg().m.locks == 0 && getg() != getg().m.g0 {
// Direct G handoff
goyield()
}
}
}
Key steps:
- Atomically increment the semaphore counter
- Check
nwait(without the lock) – if zero, no waiters, return - Lock the root, dequeue a waiter from the treap, unlock
- If
handoffmode and we can re-acquire the semaphore, setticket = 1(direct grant) - Mark the waiter as ready (
readyWithTime) - In handoff mode, yield immediately via
goyield()to let the waiter run
Starvation Handling and Handoff Mode
The handoff parameter is critical for fairness. When sync.Mutex enters starvation mode (a goroutine has been waiting > 1ms), it calls semrelease with handoff=true.
src/runtime/sema.go, lines 260-287
// src/runtime/sema.go, lines 260-287
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes)
if s.ticket == 1 && getg().m.locks == 0 && getg() != getg().m.g0 {
// Direct G handoff
//
// readyWithTime has added the waiter G as runnext in the
// current P; we now call the scheduler so that we start running
// the waiter G immediately.
//
// Note that waiter inherits our time slice: this is desirable
// to avoid having a highly contended semaphore hog the P
// indefinitely. goyield is like Gosched, but it emits a
// "preempted" trace event instead and, more importantly, puts
// the current G on the local runq instead of the global one.
// We only do this in the starving regime (handoff=true), as in
// the non-starving case it is possible for a different waiter
// to acquire the semaphore while we are yielding/scheduling,
// and this would be wasteful.
goyield()
}
In handoff mode:
- The releaser atomically grabs the semaphore on behalf of the waiter (
s.ticket = 1) - The waiter is placed in
runnextof the current P - The releaser calls
goyield()to immediately switch to the waiter - This prevents new goroutines from stealing the semaphore between release and acquire
The queue Method: Treap Insertion
src/runtime/sema.go, lines 370-396
// src/runtime/sema.go, lines 370-396
// Add s as new leaf in tree of unique addrs.
// The balanced tree is a treap using ticket as the random heap priority.
// That is, it is a binary tree ordered according to the elem addresses,
// but then among the space of possible binary trees respecting those
// addresses, it is kept balanced on average by maintaining a heap ordering
// on the ticket: s.ticket <= both s.prev.ticket and s.next.ticket.
// https://en.wikipedia.org/wiki/Treap
// https://faculty.washington.edu/aragon/pubs/rst89.pdf
//
// s.ticket compared with zero in couple of places, therefore set lowest bit.
// It will not affect treap's quality noticeably.
s.ticket = cheaprand() | 1
s.parent = last
*pt = s
// Rotate up into tree according to ticket (priority).
for s.parent != nil && s.parent.ticket > s.ticket {
if s.parent.prev == s {
root.rotateRight(s.parent)
} else {
if s.parent.next != s {
panic("semaRoot queue")
}
root.rotateLeft(s.parent)
}
}
The treap maintains balance probabilistically:
- Insert as a BST leaf (ordered by address)
- Assign a random priority (
cheaprand() | 1) - Rotate up while parent has higher priority
- The
| 1ensures ticket is never zero (zero has special meaning for handoff)
7. Reader-Writer Mutex (10 min)
Overview
The runtime’s rwmutex (in rwmutex.go) is used internally by the runtime (e.g., for protecting allocator state). It’s a simplified version of sync.RWMutex.
src/runtime/rwmutex.go, lines 18-30
// src/runtime/rwmutex.go, lines 18-30
type rwmutex struct {
rLock mutex // protects readers, readerPass, writer
readers muintptr // list of pending readers
readerPass uint32 // number of pending readers to skip readers list
wLock mutex // serializes writers
writer muintptr // pending writer waiting for completing readers
readerCount atomic.Int32 // number of pending readers
readerWait atomic.Int32 // number of departing readers
readRank lockRank // semantic lock rank for read locking
}
The rwmutexMaxReaders Trick
src/runtime/rwmutex.go, line 67
// src/runtime/rwmutex.go, line 67
const rwmutexMaxReaders = 1 << 30
This constant is the key to the reader-writer protocol. readerCount serves dual purpose:
- When positive: number of active readers
- When negative: a writer is pending (readerCount was decremented by
rwmutexMaxReaders)
Read Lock (rlock)
src/runtime/rwmutex.go, lines 70-98
// src/runtime/rwmutex.go, lines 70-98
func (rw *rwmutex) rlock() {
acquireLockRankAndM(rw.readRank)
lockWithRankMayAcquire(&rw.rLock, getLockRank(&rw.rLock))
if rw.readerCount.Add(1) < 0 {
// A writer is pending. Park on the reader queue.
systemstack(func() {
lock(&rw.rLock)
if rw.readerPass > 0 {
// Writer finished.
rw.readerPass -= 1
unlock(&rw.rLock)
} else {
// Queue this reader to be woken by the writer.
m := getg().m
m.schedlink = rw.readers
rw.readers.set(m)
unlock(&rw.rLock)
notesleep(&m.park)
noteclear(&m.park)
}
})
}
}
The flow:
- Atomically increment
readerCount - If result is positive: no writer pending, proceed (fast path)
- If result is negative: a writer has subtracted
rwmutexMaxReaders- Check
readerPass– if > 0, the writer already finished and we can proceed - Otherwise, add ourselves to the
readerslist and sleep vianotesleep
- Check
Write Lock (lock)
src/runtime/rwmutex.go, lines 121-140
// src/runtime/rwmutex.go, lines 121-140
func (rw *rwmutex) lock() {
// Resolve competition with other writers and stick to our P.
lock(&rw.wLock)
m := getg().m
// Announce that there is a pending writer.
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for any active readers to complete.
lock(&rw.rLock)
if r != 0 && rw.readerWait.Add(r) != 0 {
// Wait for reader to wake us up.
systemstack(func() {
rw.writer.set(m)
unlock(&rw.rLock)
notesleep(&m.park)
noteclear(&m.park)
})
} else {
unlock(&rw.rLock)
}
}
The protocol:
- Acquire
wLockto serialize writers - Subtract
rwmutexMaxReadersfromreaderCount– this makes it negative, signaling readers - The return value
rtells us how many readers were active at that moment - Add
rtoreaderWait– as readers finish, they decrementreaderWait - If
readerWaitbecomes 0 (or was already 0), all readers are done - Otherwise, sleep until the last departing reader wakes us
Read Unlock (runlock)
src/runtime/rwmutex.go, lines 101-118
// src/runtime/rwmutex.go, lines 101-118
func (rw *rwmutex) runlock() {
if r := rw.readerCount.Add(-1); r < 0 {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
throw("runlock of unlocked rwmutex")
}
// A writer is pending.
if rw.readerWait.Add(-1) == 0 {
// The last reader unblocks the writer.
lock(&rw.rLock)
w := rw.writer.ptr()
if w != nil {
notewakeup(&w.park)
}
unlock(&rw.rLock)
}
}
releaseLockRankAndM(rw.readRank)
}
When a reader unlocks:
- Decrement
readerCount - If result is negative, a writer is waiting
- Decrement
readerWait– if we’re the last departing reader (readerWait reaches 0), wake the writer
Write Unlock
src/runtime/rwmutex.go, lines 143-164
// src/runtime/rwmutex.go, lines 143-164
func (rw *rwmutex) unlock() {
// Announce to readers that there is no active writer.
r := rw.readerCount.Add(rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
throw("unlock of unlocked rwmutex")
}
// Unblock blocked readers.
lock(&rw.rLock)
for rw.readers.ptr() != nil {
reader := rw.readers.ptr()
rw.readers = reader.schedlink
reader.schedlink.set(nil)
notewakeup(&reader.park)
r -= 1
}
// If r > 0, there are pending readers that aren't on the
// queue. Tell them to skip waiting.
rw.readerPass += uint32(r)
unlock(&rw.rLock)
// Allow other writers to proceed.
unlock(&rw.wLock)
}
The writer unlock:
- Add
rwmutexMaxReadersback toreaderCount(makes it positive again) - Wake all readers on the
readerslist vianotewakeup - If
r > 0, some readers arrived after the writer and already incrementedreaderCountbut haven’t been queued yet. SetreaderPassso they skip the queue. - Release
wLockto allow other writers
The Elegance of the Design
The rwmutexMaxReaders trick encodes the “writer pending” state directly in the reader counter. This means:
- Readers in the common (no-writer) case do a single atomic add and proceed
- The writer’s presence is communicated through the sign of
readerCount - No separate “writer waiting” flag is needed
- The maximum number of concurrent readers is 2^30 (about 1 billion), which is more than sufficient
8. Summary: The Synchronization Hierarchy
| Layer | Primitive | Blocks | Platform |
|---|---|---|---|
| Hardware | CAS, LoadAcq, StoreRel | N/A | All |
| OS (Linux) | futexsleep / futexwakeup | OS thread | Linux |
| OS (macOS) | semasleep / semawakeup | OS thread | Darwin |
| Runtime | notesleep / notewakeup | OS thread (one-shot) | All (via above) |
| Runtime | lock2 / unlock2 (lock_spinbit.go) | OS thread | All |
| Runtime | semacquire1 / semrelease1 (sema.go) | Goroutine | All |
| User | sync.Mutex, sync.RWMutex | Goroutine | All |
Key insight: The runtime has two parallel stacks of synchronization – one for blocking OS threads (used by the scheduler itself) and one for blocking goroutines (used by Go code). They use the same OS primitives at the bottom but diverge at the level above.
Discussion Questions
-
Why does the runtime need both
lock_futex.goandlock_sema.go? Why not use one approach everywhere? -
In the
sema.gotreap, why use a treap instead of a simpler structure like a hash table of linked lists? -
The runtime mutex uses an 8-bit atomic swap for the fast path. Why is this faster than a full word-width CAS?
-
In the rwmutex, why does the writer subtract
rwmutexMaxReadersfromreaderCountinstead of using a separate “writer waiting” boolean? -
Consider
semrelease1withhandoff=true. Why does it callgoyield()instead ofGosched()? What’s the difference?
Further Reading
- Ulrich Drepper, “Futexes Are Tricky” (2011) – the classic futex reference
- Mullender and Cox, “Semaphores in Plan 9” – cited in
sema.go’s header comment - sync.Mutex source – see how it uses
runtime_SemacquireMutex - Aragon and Seidel, “Randomized Search Trees” (1989) – the treap paper cited in
sema.go
Module 6: Synchronization Primitives (futex, semaphores, rwmutex)
Comprehension Check
Question 1 (Code Reading)
Consider this implementation of futexsleep from runtime/os_linux.go:
func futexsleep(addr *uint32, val uint32, ns int64) {
if ns < 0 {
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, nil, nil, 0)
return
}
var ts timespec
ts.setNsec(ns)
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, &ts, nil, 0)
}
What does _FUTEX_WAIT_PRIVATE mean, and why does the runtime use the “PRIVATE” variant instead of plain FUTEX_WAIT?
Answer
_FUTEX_WAIT_PRIVATE is FUTEX_WAIT | FUTEX_PRIVATE_FLAG. The FUTEX_PRIVATE_FLAG tells the kernel that this futex is only shared within a single process (not shared between processes via shared memory).
The runtime uses the PRIVATE variant because:
- Performance: Private futexes are faster in the kernel because they do not need to go through the full virtual-to-physical address translation to find the futex hash bucket. The kernel can use the virtual address directly, since it is unique within the process. This avoids a page table walk.
- Correctness: The Go runtime’s internal locks are never shared between processes. All Ms (threads) belong to the same process, so there is no need for cross-process futex semantics.
- Simpler kernel path: The private path avoids potential lock contention on shared futex hash buckets that cross-process futexes require.
Question 2 (Short Answer)
The Go runtime implements its own mutex type (in lock_futex.go or lock_sema.go) rather than using pthread_mutex_t. What are the key differences between the runtime’s mutex and a pthread mutex? Why not use pthreads?
Answer
Key differences:
-
Implementation: The runtime’s mutex on Linux is built directly on
futex()system calls. On non-Linux platforms, it uses OS-specific semaphores.pthread_mutex_twraps similar primitives but through the C library. -
Stack requirements: The runtime’s mutex can be used on the system stack (
g0) and innosplitcontexts.pthread_mutex_toperations may involve C function calls that expect a C stack and calling convention. -
No error checking: The runtime’s mutex is minimal – it does not track ownership, recursion, or error conditions like
EDEADLK. It is a simple spin-then-sleep lock. -
Integration with scheduler: The runtime’s lock operations interact with the scheduler (e.g.,
mp.locks++prevents preemption while holding a lock). pthread mutexes have no knowledge of the Go scheduler.
Why not pthreads:
- Using pthreads would require cgo, which has significant overhead per call (~200ns) and stack switching requirements.
- The runtime mutex is used extremely early in process startup, before pthreads or the C runtime are initialized.
- The runtime needs to control the locking implementation for correctness: e.g., it must not call
goparkwhile holding certain locks, and it must prevent stack splits during lock operations.
Question 3 (True/False with Explanation)
True or False: Go’s sync.Mutex is implemented directly using the runtime’s internal runtime.mutex (the futex-based lock).
Answer
False. sync.Mutex and runtime.mutex are completely separate implementations.
-
runtime.mutexis a low-level lock used internally by the runtime for protecting scheduler data structures, the heap, channel internals, etc. It is built onfutex(Linux) or OS semaphores and operates at the M (OS thread) level. It blocks the entire OS thread. -
sync.Mutexis a user-facing lock that integrates with the goroutine scheduler. It uses the runtime’ssemaphoremechanism (runtime_SemacquireMutex/runtime_Semrelease), which parks and unparks goroutines (not OS threads). When a goroutine blocks onsync.Mutex.Lock(), only the goroutine is parked – the M/P can continue scheduling other goroutines.
Additionally, sync.Mutex implements a more sophisticated algorithm with two modes:
- Normal mode: New arrivals spin briefly then queue. Goroutines are woken in FIFO order.
- Starvation mode: If a waiter has been waiting >1ms, the mutex switches to starvation mode where the lock is directly handed to the first waiter, preventing tail latency.
Question 4 (What Would Happen If…)
Go’s sync.RWMutex allows multiple concurrent readers or a single writer. What would happen if RLock()/RUnlock() used an atomic counter but did not coordinate with writers at all (i.e., readers never blocked even when a writer is waiting)?
Answer
This would cause writer starvation. In a read-heavy workload:
- Readers would continuously acquire the read lock via atomic increment.
- A writer calling
Lock()would wait for the reader count to reach zero. - But new readers would keep arriving and incrementing the count, never letting it reach zero.
- The writer would wait indefinitely.
Go’s actual sync.RWMutex prevents this by manipulating readerCount when a writer arrives:
When a writer calls Lock(), it subtracts rwmutexMaxReaders (a large constant, 1<<30) from readerCount, making it negative. New readers calling RLock() see the negative value and know a writer is pending, so they block (park). This ensures that once a writer is waiting, no new readers can proceed – only existing readers drain, and then the writer gets the lock.
Without this mechanism, any workload with continuous read traffic would make writes effectively impossible.
Question 5 (Short Answer)
Explain the difference between a semaphore and a futex. How does the Go runtime use each?
Answer
Futex (Fast Userspace Mutex):
- A Linux kernel primitive that provides atomic “compare-and-sleep” and “wake” operations on a 32-bit integer in user space.
- The fast path (uncontended lock/unlock) is entirely in user space – no system call needed.
- The slow path (contended) involves a system call to put the thread to sleep or wake it.
- In the Go runtime: used to implement
runtime.mutex(the internal lock) andruntime.note(one-shot notification).
Semaphore (in Go’s runtime):
- Implemented in
runtime/sema.goas a higher-level primitive. - A goroutine-aware counting semaphore built on top of a treap (tree + heap) of waiters.
- Waiters are goroutines (not OS threads) and are parked/unparked via
gopark/goready. - Supports FIFO ordering and lifo wakeup for different use cases.
- In the Go runtime: used by
sync.Mutex,sync.RWMutex,sync.WaitGroup, andsync.Cond.
Key distinction: Futexes operate at the OS thread level (block/wake threads), while Go’s semaphores operate at the goroutine level (park/ready goroutines). This is crucial because blocking an OS thread is much more expensive than parking a goroutine.
Question 6 (Code Reading)
Consider a simplified version of the semaphore acquire path:
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
// Fast path: *addr > 0, try to decrement
if cansemacquire(addr) {
return
}
// Slow path: increment waiter count, then try to acquire again
s := acquireSudog()
root := semtable[addr.hash()].root
root.queue(addr, s, lifo)
gopark_m(...) // park the goroutine
releaseSudog(s)
}
Why does the slow path increment the waiter count and try again before actually parking? What race condition does this double-check prevent?
Answer
This is a classic double-checked locking / lost wakeup prevention pattern:
- The fast path checks if the semaphore is available (
*addr > 0) and tries to decrement it atomically. - If it fails, the goroutine enters the slow path.
- Between the fast path check and the slow path, another goroutine might have released the semaphore (called
semrelease). - If the goroutine blindly parked without rechecking, the release would have incremented
*addrand potentially calledgoready– but since the goroutine was not yet parked, the wakeup would be lost. The goroutine would sleep forever.
By registering as a waiter first (adding to the queue) and then trying to acquire again:
- If the semaphore became available, the goroutine acquires it and dequeues itself.
- If it is still unavailable, the goroutine is already properly registered as a waiter, so any future
semreleasewill find and wake it.
This ensures no wakeup is ever lost, which is the same principle behind the atomic check in futex(FUTEX_WAIT, addr, val).
Module 7: Channels and Select (60 min)
Background: Message Passing and the CSP Tradition
Concurrent programs must coordinate access to shared state, and the history of
systems programming offers two fundamental paradigms for doing so. The first,
shared memory with synchronization, gives threads direct access to common
data structures and uses locks, condition variables, and atomic operations to
prevent races. This is the model of pthreads, Java’s synchronized blocks, and
the mutexes we studied earlier in this course. The second paradigm, message
passing, structures concurrent programs as independent sequential processes
that communicate by sending and receiving messages over explicit channels. Rather
than protecting shared data with locks, message-passing programs avoid sharing
altogether: data moves between processes by copying (or transferring ownership),
and synchronization is implicit in the send/receive handshake. The distinction
is not absolute – as we will see, Go’s channel implementation itself uses a
mutex internally – but it reflects a deep difference in how programmers reason
about concurrency. The shared-memory style asks “how do I protect this data?”
while the message-passing style asks “how do I route this data?”
The theoretical foundations of message passing trace to two landmark papers from the 1970s. Carl Hewitt’s Actor model (1973) proposed that computation consists of autonomous actors that communicate by sending asynchronous messages to named recipients; each actor has a mailbox, processes messages one at a time, and can create new actors or send messages in response. Tony Hoare’s Communicating Sequential Processes (CSP, 1978) took a different approach: processes communicate through unnamed, synchronous channels, and a send blocks until a matching receive occurs (and vice versa). Where the Actor model emphasizes identity (messages are sent to a specific actor), CSP emphasizes the channel as a first-class connective tissue between anonymous processes. The Actor model’s asynchronous, identity-based messaging influenced Erlang, Akka, and the “let it crash” school of fault-tolerant systems. CSP’s synchronous, channel-based model influenced occam, Limbo, and – most prominently today – Go. Both models avoid shared mutable state, but they differ in buffering semantics, naming, and the role of the communication medium itself.
These ideas have been realized in strikingly different ways across programming
languages and systems. Erlang processes communicate through asynchronous
mailboxes with selective receive, giving each process an unbounded message
queue and pattern matching to pluck out messages of interest – a direct
descendant of the Actor model. Rust’s standard library provides mpsc
(multi-producer, single-consumer) channels that transfer ownership of values,
leveraging the type system to guarantee that sent data cannot be accessed by the
sender afterward; the crossbeam crate extends this with multi-consumer
channels and a select! macro. Kotlin offers channels within its coroutine
framework, with configurable buffering policies (rendezvous, buffered,
conflated, unlimited) that let programmers tune the coupling between producer
and consumer. Clojure’s core.async brings CSP-style channels to the JVM with
go blocks that compile to state machines, much as Go’s goroutines are
multiplexed onto OS threads. Even Unix pipes, dating to Doug McIlroy’s 1964
proposal and Ken Thompson’s 1973 implementation, embody the same principle:
composing programs as sequential stages connected by byte streams, with
back-pressure arising naturally from the pipe buffer’s bounded capacity. Go’s
channels sit squarely in this lineage, offering typed, optionally-buffered
channels as first-class values, with goroutines as lightweight processes.
A key operation in any channel-based system is multiplexing: waiting on
multiple channels simultaneously and proceeding with whichever is ready first.
This problem has deep roots in operating systems. Unix’s select() system call
(4.2BSD, 1983) let a process block until any file descriptor in a set became
ready for I/O – solving the problem of a server needing to handle multiple
clients without dedicating a thread to each one. poll() improved the interface,
and Linux’s epoll (2002) scaled it to hundreds of thousands of descriptors.
Go’s select statement is the channel-level analogue: it blocks until one of
several channel operations can proceed, then executes that case. But Go’s
select adds two refinements absent from Unix I/O multiplexing. First,
randomized fairness: when multiple cases are ready simultaneously, select
chooses one uniformly at random, preventing starvation. Second, deadlock-safe
locking: the select implementation acquires locks on all involved channels
in address order to prevent lock-ordering deadlocks, a technique borrowed from
database concurrency control. The combination of randomization and careful lock
management makes select both fair and safe, properties that are surprisingly
difficult to achieve together.
In this module, we open up Go’s channel and select implementation to see how
these ideas are realized in practice. The runtime’s hchan struct is a
mutex-protected circular buffer with embedded wait queues – a bounded
producer-consumer buffer, much like the ones studied in OS textbooks, but with
several clever optimizations such as direct sender-to-receiver copying that
bypasses the buffer entirely. The select implementation is a carefully
orchestrated multi-pass algorithm that shuffles cases for fairness, sorts them
for deadlock freedom, and uses per-goroutine “pseudo-g” (sudog) structures to
allow a single goroutine to wait on many channels at once. Understanding this
machinery connects the high-level elegance of CSP to the low-level realities of
locks, memory layout, and scheduler integration.
1. Channel Invariants
The channel implementation maintains a key structural invariant, documented at
the top of chan.go:
// src/runtime/chan.go, lines 9-18 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=9)
// Invariants:
// At least one of c.sendq and c.recvq is empty,
// except for the case of an unbuffered channel with a single goroutine
// blocked on it for both sending and receiving using a select statement,
// in which case the length of c.sendq and c.recvq is limited only by the
// size of the select statement.
//
// For buffered channels, also:
// c.qcount > 0 implies that c.recvq is empty.
// c.qcount < c.dataqsiz implies that c.sendq is empty.
These invariants encode a fundamental property: if there is data in the buffer, no goroutine should be blocked waiting to receive; if the buffer is not full, no goroutine should be blocked waiting to send. The only exception is select statements on unbuffered channels, where a single goroutine might appear on both queues simultaneously.
OS connection: These invariants are analogous to the monitor invariants in classical synchronization – they define the legal states of the shared data structure and constrain when threads (goroutines) may block.
2. The hchan Struct: Channel Internals
Every channel in Go is represented at runtime by an hchan struct:
// src/runtime/chan.go, lines 34-55 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=34)
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
timer *timer // timer feeding this chan
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
bubble *synctestBubble
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
Field-by-field analysis
| Field | Purpose |
|---|---|
qcount | Number of elements currently in the buffer. |
dataqsiz | Capacity of the buffer (0 for unbuffered channels). |
buf | Pointer to the circular buffer array. |
elemsize | Size of each element in bytes. |
closed | Non-zero if the channel has been closed. |
elemtype | Type descriptor for elements (used by GC and typedmemmove). |
sendx | Next index for writing into the buffer (producer cursor). |
recvx | Next index for reading from the buffer (consumer cursor). |
recvq | Doubly-linked list of goroutines waiting to receive. |
sendq | Doubly-linked list of goroutines waiting to send. |
lock | Mutex protecting all mutable fields. |
The sendx and recvx indices implement a standard circular buffer: they
advance through the buffer array and wrap to 0 when they reach dataqsiz. The
channel is full when qcount == dataqsiz and empty when qcount == 0.
Important design note: The comment on lock warns against readying a
goroutine while holding the channel lock, because goready can trigger stack
shrinking, and stack shrinking also needs to acquire channel locks – creating a
potential deadlock cycle. This is a concrete example of lock ordering constraints
in the runtime.
3. The waitq and sudog Structs
waitq
The wait queue is a simple doubly-linked list:
// src/runtime/chan.go, lines 57-60 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=57)
type waitq struct {
first *sudog
last *sudog
}
sudog (pseudo-g)
A sudog represents a goroutine waiting on a synchronization object. The name
stands for “pseudo-g” – it is a proxy for a g (goroutine) in a wait list:
// src/runtime/runtime2.go, lines 396-448 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=396)
// sudog (pseudo-g) represents a g in a wait list, such as for sending/receiving
// on a channel.
//
// sudog is necessary because the g <-> synchronization object relation
// is many-to-many. A g can be on many wait lists, so there may be
// many sudogs for one g; and many gs may be waiting on the same
// synchronization object, so there may be many sudogs for one object.
//
// sudogs are allocated from a special pool. Use acquireSudog and
// releaseSudog to allocate and free them.
type sudog struct {
// The following fields are protected by the hchan.lock of the
// channel this sudog is blocking on. shrinkstack depends on
// this for sudogs involved in channel ops.
g *g
next *sudog
prev *sudog
elem maybeTraceablePtr // data element (may point to stack)
// The following fields are never accessed concurrently.
// For channels, waitlink is only accessed by g.
// For semaphores, all fields (including the ones above)
// are only accessed when holding a semaRoot lock.
acquiretime int64
releasetime int64
ticket uint32
// isSelect indicates g is participating in a select, so
// g.selectDone must be CAS'd to win the wake-up race.
isSelect bool
// success indicates whether communication over channel c
// succeeded. It is true if the goroutine was awoken because a
// value was delivered over channel c, and false if awoken
// because c was closed.
success bool
waiters uint16
parent *sudog // semaRoot binary tree
waitlink *sudog // g.waiting list or semaRoot
waittail *sudog // semaRoot
c maybeTraceableChan // channel
}
Why sudogs exist
A goroutine blocked in a select statement may be waiting on multiple
channels simultaneously. A single g struct cannot appear in multiple wait
queues, so the runtime allocates a separate sudog for each channel case. This
is the “many-to-many” relationship the comment describes.
Key fields for channel operations:
g: Points back to the goroutine this sudog represents.next/prev: Links for the channel’swaitqlinked list.elem: Pointer to the data element being sent or received. May point into the goroutine’s stack.isSelect: If true, waking this sudog requires a CAS ong.selectDoneto handle the race where multiple channels become ready simultaneously.success: Indicates whether the channel operation completed normally (true) or the channel was closed (false).waitlink: Used byselectto chain all of a goroutine’s sudogs together for cleanup.
OS connection: The sudog pool is analogous to how OS kernels pre-allocate
wait queue entries rather than embedding them in thread structures, allowing a
thread to wait on multiple events simultaneously (cf. Linux poll/epoll
implementation).
4. makechan: Channel Creation
The makechan function is called by the compiler when it encounters make(chan T, size):
// src/runtime/chan.go, lines 75-125 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=75)
func makechan(t *chantype, size int) *hchan {
elem := t.Elem
// compiler checks this but be safe.
if elem.Size_ >= 1<<16 {
throw("makechan: invalid channel element type")
}
if hchanSize%maxAlign != 0 || elem.Align_ > maxAlign {
throw("makechan: bad alignment")
}
mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// Hchan does not contain pointers interesting for GC when elements
// stored in buf do not contain pointers.
// buf points into the same allocation, elemtype is persistent.
// SudoG's are referenced from their owning thread so they can't be collected.
var c *hchan
switch {
case mem == 0:
// Queue or element size is zero.
c = (*hchan)(mallocgc(hchanSize, nil, true))
// Race detector uses this location for synchronization.
c.buf = c.raceaddr()
case !elem.Pointers():
// Elements do not contain pointers.
// Allocate hchan and buf in one call.
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// Elements contain pointers.
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
c.elemsize = uint16(elem.Size_)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
if debugChan {
print("makechan: chan=", c, "; elemsize=", elem.Size_, "; dataqsiz=", size, "\n")
}
return c
}
Three allocation strategies
-
Unbuffered or zero-size elements (
mem == 0): Only thehchanstruct itself is allocated. No buffer is needed. -
Pointer-free elements (
!elem.Pointers()): Thehchanstruct and the buffer are allocated in a singlemallocgccall. The buffer is placed immediately after the struct in memory (c.buf = add(unsafe.Pointer(c), hchanSize)). This is an optimization: the GC doesn’t need to scan the buffer, so a single non-pointer allocation suffices. -
Pointer-containing elements (default): Two separate allocations are needed. The buffer must be allocated with type information (
mallocgc(mem, elem, true)) so the GC can scan it for pointers.
OS connection: This is a nice example of how a runtime can cooperate with its garbage collector by choosing different allocation strategies based on type information – something impossible with a C-style malloc that has no type awareness.
5. chansend: Sending on a Channel
The chansend function is the heart of the send operation (c <- x):
// src/runtime/chan.go, lines 176-183 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=176)
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
if c == nil {
if !block {
return false
}
gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
throw("unreachable")
}
Sending on a nil channel parks the goroutine forever. This is by design: it
allows select statements to disable cases by setting channel variables to nil.
Phase 1: Non-blocking fast path (no lock)
// src/runtime/chan.go, lines 197-215 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=197)
// Fast path: check for failed non-blocking operation without acquiring the lock.
//
// After observing that the channel is not closed, we observe that the channel is
// not ready for sending. Each of these observations is a single word-sized read
// (first c.closed and second full()).
// Because a closed channel cannot transition from 'ready for sending' to
// 'not ready for sending', even if the channel is closed between the two
// observations, they imply a moment between the two when the channel was both
// not yet closed and not ready for sending. We behave as if we observed the
// channel at that moment, and report that the send cannot proceed.
if !block && c.closed == 0 && full(c) {
return false
}
This is a lock-free fast path for non-blocking sends (used by select with a
default case). The comment explains a subtle linearizability argument: even
though the two reads (c.closed and full(c)) are not atomic together, the
monotonicity of closed (once closed, always closed) guarantees correctness.
Phase 2: Direct send to waiting receiver
// src/runtime/chan.go, lines 222-234 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=222)
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value we want to send
// directly to the receiver, bypassing the channel buffer (if any).
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
If a receiver is already waiting, the send copies the value directly to the receiver’s stack, bypassing the channel buffer entirely. This is an important optimization: even on a buffered channel, if a receiver is blocked, the data goes straight to the receiver without first going into the buffer and then back out.
Phase 3: Buffered send
// src/runtime/chan.go, lines 236-250 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=236)
if c.qcount < c.dataqsiz {
// Space is available in the channel buffer. Enqueue the element to send.
qp := chanbuf(c, c.sendx)
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
Standard circular buffer enqueue: copy the element to the sendx slot, advance
the index (wrapping at dataqsiz), increment the count.
Phase 4: Blocking
// src/runtime/chan.go, lines 257-283 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=257)
// Block on the channel. Some receiver will complete our operation for us.
gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// No stack splits between assigning elem and enqueuing mysg
// on gp.waiting where copystack can find it.
mysg.elem.set(ep)
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c.set(c)
gp.waiting = mysg
gp.param = nil
c.sendq.enqueue(mysg)
// Signal to anyone trying to shrink our stack that we're about
// to park on a channel. The window between when this G's status
// changes and when we set gp.activeStackChans is not safe for
// stack shrinking.
gp.parkingOnChan.Store(true)
reason := waitReasonChanSend
if c.bubble != nil {
reason = waitReasonSynctestChanSend
}
gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, traceBlockChanSend, 2)
When the buffer is full (or the channel is unbuffered and no receiver is waiting), the goroutine parks itself:
- Acquires a
sudogfrom the pool. - Records the element pointer (
mysg.elem) so the eventual receiver can copy the data. - Enqueues the
sudogonc.sendq. - Calls
goparkto suspend the goroutine, releasing the channel lock atomically.
When a receiver eventually arrives, it will dequeue this sudog, copy the data
from mysg.elem, and call goready to wake the sender.
Critical subtlety: The comment about “No stack splits” is important. Between
setting mysg.elem (which points into the sender’s stack) and enqueuing the
sudog where copystack can find it, a stack copy would invalidate the pointer.
The runtime prevents this by ensuring no function calls that could trigger stack
growth occur in this window.
6. chanrecv: Receiving from a Channel
The receive operation (<-c) is implemented by chanrecv, which is symmetric
to chansend:
// src/runtime/chan.go, lines 518-524 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=518)
// chanrecv receives on channel c and writes the received data to ep.
// ep may be nil, in which case received data is ignored.
// If block == false and no elements are available, returns (false, false).
// Otherwise, if c is closed, zeros *ep and returns (true, false).
// Otherwise, fills in *ep with an element and returns (true, true).
// A non-nil ep must point to the heap or the caller's stack.
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
Receive phases
The function follows the same structure as chansend:
-
Nil channel check (line 532-538): Receiving from a nil channel parks forever.
-
Non-blocking fast path (lines 548-579): Check
empty(c)without the lock. If the channel is closed and empty, return the zero value. -
Direct receive from waiting sender (lines 600-609):
if sg := c.sendq.dequeue(); sg != nil { // Found a waiting sender. If buffer is size 0, receive value // directly from sender. Otherwise, receive from head of queue // and add sender's value to the tail of the queue (both map to // the same buffer slot because the queue is full). recv(c, sg, ep, func() { unlock(&c.lock) }, 3) return true, true }For unbuffered channels, data is copied directly from sender to receiver. For buffered channels (which must be full if a sender is blocked), the receiver takes from the buffer head, and the sender’s value fills the buffer tail.
-
Buffered receive (lines 612-629): Standard circular buffer dequeue.
if c.qcount > 0 { // Receive directly from queue qp := chanbuf(c, c.recvx) if ep != nil { typedmemmove(c.elemtype, ep, qp) } typedmemclr(c.elemtype, qp) c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 } c.qcount-- unlock(&c.lock) return true, true } -
Blocking (lines 636-685): Acquire a sudog, enqueue on
c.recvq, park.
7. closechan: Closing a Channel
Closing a channel wakes up all waiters:
// src/runtime/chan.go, lines 414-486 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/chan.go;l=414)
func closechan(c *hchan) {
if c == nil {
panic(plainError("close of nil channel"))
}
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
c.closed = 1
var glist gList
// release all readers
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem.get() != nil {
typedmemclr(c.elemtype, sg.elem.get())
sg.elem.set(nil)
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
glist.push(gp)
}
// release all writers (they will panic)
for {
sg := c.sendq.dequeue()
if sg == nil {
break
}
sg.elem.set(nil)
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
glist.push(gp)
}
unlock(&c.lock)
// Ready all Gs now that we've dropped the channel lock.
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}
Key points
-
Double close panics: The runtime checks
c.closed != 0under the lock. -
Receivers get zero values: The
typedmemclrcall zeros out the receive buffer. Thesuccess = falseflag tells receivers they woke due to close, not data delivery. -
Senders will panic: Woken senders see
success == false, checkc.closed, and panic with “send on closed channel.” -
Lock ordering: All goroutines are collected into
glistbefore releasing the lock, then readied after the lock is released. This follows the constraint from thehchan.lockcomment: “do not ready a G while holding this lock.”
OS connection: This wake-all pattern is analogous to pthread_cond_broadcast
or the Linux FUTEX_WAKE with a large wake count. Closing a channel is
effectively a broadcast signal to all waiters.
8. The select Statement Implementation
The scase struct
Each case in a select statement is described by an scase:
// src/runtime/select.go, lines 20-23 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/select.go;l=20)
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
This is remarkably simple: just a channel pointer and a pointer to the data element. The direction (send vs. receive) is implicit in the position: send cases come first, followed by receive cases.
Compiler optimizations
Before selectgo is called, the compiler has already optimized away simple
cases:
select {}(no cases): compiled to a directblock()call (parks forever).selectwith one case + default: compiled to a non-blockingchansend/chanrecv.selectwith one case (no default): compiled to a blockingchansend/chanrecv.
Only multi-case selects (or degenerate cases where channels have been niled out)
reach selectgo.
selectgo: The Multi-Pass Algorithm
// src/runtime/select.go, lines 107-122 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/select.go;l=107)
// selectgo implements the select statement.
//
// cas0 points to an array of type [ncases]scase, and order0 points to
// an array of type [2*ncases]uint16 where ncases must be <= 65536.
// Both reside on the goroutine's stack (regardless of any escaping in
// selectgo).
//
// selectgo returns the index of the chosen scase, which matches the
// ordinal position of its respective select{recv,send,default} call.
// Also, if the chosen scase was a receive operation, it reports whether
// a value was received.
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int,
block bool) (int, bool) {
The function takes a flat array of cases and an array that is split into two
halves: pollorder (randomized iteration order) and lockorder (sorted by
channel address for lock acquisition).
Step 1: Generate randomized poll order
// src/runtime/select.go, lines 167-177
// generate permuted order
norder := 0
for i := range scases {
cas := &scases[i]
// Omit cases without channels from the poll and lock orders.
if cas.c == nil {
cas.elem = nil // allow GC
continue
}
// ...
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
Cases with nil channels are skipped. The remaining cases are shuffled using a
Fisher-Yates shuffle (cheaprandn). This randomization is critical for
fairness: without it, earlier cases in the source code would always have
priority, potentially starving later cases.
Step 2: Sort lock order by channel address
// src/runtime/select.go, lines 206-240
// sort the cases by Hchan address to get the locking order.
// simple heap sort, to guarantee n log n time and constant stack footprint.
for i := range lockorder {
j := i
// Start with the pollorder to permute cases on the same channel.
c := scases[pollorder[i]].c
for j > 0 && scases[lockorder[(j-1)/2]].c.sortkey() < c.sortkey() {
k := (j - 1) / 2
lockorder[j] = lockorder[k]
j = k
}
lockorder[j] = pollorder[i]
}
A heap sort orders cases by channel address. This establishes a global lock ordering that prevents deadlocks when two goroutines running select statements try to lock overlapping sets of channels.
OS connection: This is a textbook application of lock ordering to prevent deadlock – the same technique used in database systems (two-phase locking with ordered resources) and OS kernels (e.g., Linux’s lock ordering for inodes).
Pass 1: Try all cases without blocking (fast path)
// src/runtime/select.go, lines 264-301
// pass 1 - look for something already waiting
var casi int
var cas *scase
var caseSuccess bool
for _, casei := range pollorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
if casi >= nsends {
sg = c.sendq.dequeue()
if sg != nil {
goto recv
}
if c.qcount > 0 {
goto bufrecv
}
if c.closed != 0 {
goto rclose
}
} else {
if c.closed != 0 {
goto sclose
}
sg = c.recvq.dequeue()
if sg != nil {
goto send
}
if c.qcount < c.dataqsiz {
goto bufsend
}
}
}
if !block {
selunlock(scases, lockorder)
casi = -1
goto retc
}
After locking all channels (in lock order), the function iterates through cases in the randomized poll order. For each case:
- Receive cases (
casi >= nsends): Check for a waiting sender, buffered data, or a closed channel. - Send cases: Check for close (panic), a waiting receiver, or buffer space.
If any case can proceed immediately, the function jumps to the appropriate handler, which performs the operation, unlocks all channels, and returns.
If no case is ready and there’s a default clause (!block), unlock and return -1.
Pass 2: Enqueue on all channels and park
// src/runtime/select.go, lines 309-351
// pass 2 - enqueue on all chans
nextp = &gp.waiting
for _, casei := range lockorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
sg := acquireSudog()
sg.g = gp
sg.isSelect = true
// No stack splits between assigning elem and enqueuing
// sg on gp.waiting where copystack can find it.
sg.elem.set(cas.elem)
sg.c.set(c)
// Construct waiting list in lock order.
*nextp = sg
nextp = &sg.waitlink
if casi < nsends {
c.sendq.enqueue(sg)
} else {
c.recvq.enqueue(sg)
}
}
// wait for someone to wake us up
gp.param = nil
gp.parkingOnChan.Store(true)
gopark(selparkcommit, nil, waitReason, traceBlockSelect, 1)
If no case is immediately ready, the goroutine enqueues a sudog on every channel and parks itself. Key details:
- Each sudog has
isSelect = true, signaling that waking this goroutine requires a CAS ong.selectDone. - The sudogs are chained together via
waitlinkin lock order, so pass 3 can efficiently clean up.
Pass 3: Dequeue from losers and return winner
// src/runtime/select.go, lines 354-401
sellock(scases, lockorder)
gp.selectDone.Store(0)
sg = (*sudog)(gp.param)
gp.param = nil
// pass 3 - dequeue from unsuccessful chans
// otherwise they stack up on quiet channels
// record the successful case, if any.
casi = -1
cas = nil
caseSuccess = false
sglist = gp.waiting
// Clear all elem before unlinking from gp.waiting.
for sg1 := gp.waiting; sg1 != nil; sg1 = sg1.waitlink {
sg1.isSelect = false
sg1.elem.set(nil)
sg1.c.set(nil)
}
gp.waiting = nil
for _, casei := range lockorder {
k = &scases[casei]
if sg == sglist {
// sg has already been dequeued by the G that woke us up.
casi = int(casei)
cas = k
caseSuccess = sglist.success
} else {
c = k.c
if int(casei) < nsends {
c.sendq.dequeueSudoG(sglist)
} else {
c.recvq.dequeueSudoG(sglist)
}
}
sgnext = sglist.waitlink
sglist.waitlink = nil
releaseSudog(sglist)
sglist = sgnext
}
When the goroutine wakes up:
- Re-lock all channels (in lock order).
- Find which sudog caused the wakeup (
gp.param). - Walk through all sudogs: the winning sudog has already been dequeued by the channel that woke us; dequeue the losers from their respective channels.
- Release all sudogs back to the pool.
sellock and selunlock: Careful lock management
// src/runtime/select.go, lines 34-43
func sellock(scases []scase, lockorder []uint16) {
var c *hchan
for _, o := range lockorder {
c0 := scases[o].c
if c0 != c {
c = c0
lock(&c.lock)
}
}
}
The sellock function locks channels in sorted order, skipping duplicates (when
the same channel appears in multiple cases). selunlock unlocks in reverse
order, also skipping duplicates.
9. Summary: Channel Operation Decision Tree
Send (c <- x)
c == nil? --> park forever
|
v
[lock channel]
|
c.closed? --> panic("send on closed channel")
|
v
receiver waiting? --> copy directly to receiver, wake receiver
|
v
buffer space? --> enqueue in buffer
|
v
[block: park until receiver arrives]
Receive (<-c)
c == nil? --> park forever
|
v
[lock channel]
|
c.closed && empty? --> return zero value
|
v
sender waiting? --> receive from sender/buffer, wake sender
|
v
buffer data? --> dequeue from buffer
|
v
[block: park until sender arrives]
10. OS Concepts Illustrated
| OS Concept | Channel Implementation |
|---|---|
| Bounded buffer / producer-consumer | hchan circular buffer with sendx/recvx |
| Condition variable wait/signal | gopark/goready on send/recv queues |
| Broadcast signal | closechan waking all waiters |
| Monitor | The channel lock protecting all shared state |
| Lock ordering (deadlock prevention) | select sorting channels by address |
| Fairness | select randomizing poll order |
| Wait-free fast path | Non-blocking check before acquiring lock |
Discussion Questions
-
Why does
chansendcopy data directly to a waiting receiver instead of putting it in the buffer first? What performance benefit does this provide? -
The non-blocking fast path in
chansendreadsc.closedandfull(c)without holding the lock. Why is this safe? Under what conditions could this produce a “stale” answer, and why is a stale answer acceptable here? -
In
closechan, goroutines are collected into a list before being readied. Why can’t we callgoreadywhile holding the channel lock? -
Why does
selectuse randomization for the poll order but a deterministic sort for the lock order? What would go wrong if we used the same order for both? -
Consider a select statement with 100 cases, all on the same channel. How does the lock ordering logic handle this? (Hint: look at
sellock’s duplicate detection.)
Further Reading
- Source files:
/Users/tchajed/sw/go/src/runtime/chan.go– channel implementation/Users/tchajed/sw/go/src/runtime/select.go– select implementation/Users/tchajed/sw/go/src/runtime/runtime2.go– sudog struct (line 406)
- Hoare, C.A.R. “Communicating Sequential Processes.” Communications of the ACM, 1978.
- The Go Memory Model: https://go.dev/ref/mem
Module 7: Channels and Select (hchan, chansend/chanrecv, selectgo)
Comprehension Check
Question 1 (Code Reading)
Study the hchan struct from runtime/chan.go:
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
lock mutex
}
(a) How can you determine from these fields whether a channel is unbuffered?
(b) Why does the struct need both sendx/recvx and qcount if the buffer is a circular queue?
Answer
(a) An unbuffered channel has dataqsiz == 0 (and buf may be nil or point to a zero-size allocation). An unbuffered make(chan int) sets dataqsiz = 0. You can also verify by checking buf == nil for channels with non-zero element size, though the canonical check is dataqsiz.
(b) sendx and recvx track the current write and read positions in the circular buffer, respectively. qcount tracks the actual number of elements in the buffer.
While in theory you could derive the count from sendx and recvx (using modular arithmetic: (sendx - recvx) % dataqsiz), maintaining a separate qcount provides:
- Faster full/empty checks:
qcount == 0(empty) andqcount == dataqsiz(full) are O(1) comparisons without modular arithmetic. - Correct handling of the ambiguous case: In a circular buffer,
sendx == recvxis ambiguous – it could mean the buffer is completely full or completely empty. A separate count resolves this ambiguity. - The
len()builtin:len(ch)returnsqcountdirectly.
Question 2 (Code Reading)
Consider this fast-path check in chansend:
if !block && c.closed == 0 && full(c) {
return false
}
The comment above this code explains that the reads of c.closed and full(c) are not atomic with respect to each other. Why is this safe for the non-blocking case? Under what condition would this be unsafe?
Answer
Why it is safe for non-blocking sends:
The function is checking whether a non-blocking send should immediately fail. The key insight from the comment is:
“Because a closed channel cannot transition from ‘ready for sending’ to ‘not ready for sending’, even if the channel is closed between the two observations, they imply a moment between the two when the channel was both not yet closed and not ready for sending.”
In other words, channel state transitions are monotonic in relevant ways:
- A closed channel stays closed (channels cannot be reopened).
- A full channel can only become “not full” (by someone receiving), never “more full.”
So if we observe closed == 0 then full == true, there existed a moment in time when both were true – the channel was open and full, so the send legitimately cannot proceed. The non-blocking send returning false is a valid linearization.
When it would be unsafe: This reasoning does NOT apply to blocking sends. A blocking send must actually acquire the lock and re-check conditions because:
- The channel might become available (a receiver arrives) between the check and the park.
- A lost wakeup could occur if the sender parks without being properly registered in
sendq. - For blocking operations, we need the strong guarantee that the operation either succeeds or the goroutine is properly enqueued to be woken later.
Question 3 (Short Answer)
Explain what happens during a channel send on an unbuffered channel when there is already a receiver waiting in recvq. Describe the data flow – where is the value copied?
Answer
When a sender sends on an unbuffered channel and a receiver is waiting:
- The sender acquires
c.lock. - It dequeues a
sudogfromc.recvq(the waiting receiver). - It calls
send(c, sg, ep, ...)which callssendDirect(t, sg, ep). - The value is copied directly from the sender’s stack to the receiver’s stack – it bypasses the channel buffer entirely. The
sudogcontains a pointer to the receiver’s memory location (sg.elem), andsendDirectusesmemmoveto copy the data. - The receiver goroutine is made runnable via
goready(gp), typically placing it in the waking P’srunnext. - The sender releases
c.lockand continues execution.
This direct copy is an important optimization: for unbuffered channels (and buffered channels with a waiting receiver), the data goes directly from sender to receiver without touching the circular buffer. This reduces the operation from two copies (sender->buffer, buffer->receiver) to one copy (sender->receiver).
Note: sendDirect writes to another goroutine’s stack, which is normally forbidden. It is safe here because the receiver is blocked (in _Gwaiting) and cannot be running or have its stack moved by the GC during this operation.
Question 4 (What Would Happen If…)
What would happen if selectgo did not acquire channel locks in a consistent global order (the “lock order” phase)? Describe a specific deadlock scenario.
Answer
Deadlock scenario with inconsistent lock ordering:
Consider two goroutines executing select statements:
// Goroutine 1
select {
case ch_A <- 1:
case <-ch_B:
}
// Goroutine 2
select {
case ch_B <- 2:
case <-ch_A:
}
Without consistent lock ordering:
- Goroutine 1 acquires
ch_A.lock, then tries to acquirech_B.lock. - Goroutine 2 acquires
ch_B.lock, then tries to acquirech_A.lock. - Deadlock: Each goroutine holds one lock and waits for the other.
How selectgo prevents this: The function sorts channels by their memory address (pointer value) to establish a total order. Both goroutines will lock channels in the same order (say, ch_A then ch_B, if &ch_A < &ch_B). This makes circular wait impossible.
This is the classic lock ordering solution to deadlock prevention, applied to the dynamic set of channels in a select statement. The lockorder array in selectgo is sorted by hchan pointer address before any locks are acquired.
Question 5 (Short Answer)
Describe the three passes that selectgo performs and explain why each is necessary.
Answer
selectgo performs three major phases:
Pass 1 – Poll (fast path):
Iterate through all cases in a random order (pollorder) and check if any operation can proceed immediately without blocking:
- For sends: Is there a waiting receiver, or is the buffer not full?
- For receives: Is there a waiting sender, or is the buffer not empty?
- For default: Always ready.
If any case is ready, execute it and return. This avoids the overhead of enqueueing/dequeueing the goroutine when an operation is immediately available. Randomization ensures fairness – no case is systematically favored.
Pass 2 – Enqueue:
If no case was ready in Pass 1, the goroutine must block. It creates a sudog for each case and enqueues itself on the sendq or recvq of each channel. Then it calls gopark to sleep.
This is necessary because the goroutine needs to be woken when ANY of the channels becomes ready. The goroutine is simultaneously waiting on all channels.
Pass 3 – Dequeue (after wakeup):
When the goroutine is woken (because one channel operation completed), it must remove its sudog from ALL other channels’ wait queues. It re-acquires all channel locks (in lock order) and dequeues itself from every channel except the one that triggered the wakeup.
This is necessary to prevent the goroutine from being woken multiple times. If it stayed in multiple wait queues, a second channel becoming ready would try to wake an already-running goroutine, causing corruption.
Question 6 (Short Answer)
Why does selectgo use a random permutation (pollorder) rather than checking cases in source-code order?
Answer
Random ordering prevents starvation and priority inversion among select cases.
If cases were always checked in source-code order, the first case would always be preferred when multiple cases are simultaneously ready. This would effectively create a priority system based on the order cases are written, leading to:
-
Starvation: Later cases would rarely be selected if earlier cases are frequently ready. For example, in a fan-in pattern merging two channels, the first channel would dominate.
-
Subtle ordering dependencies: Programmers would need to carefully order cases, and refactoring (reordering cases) would change program behavior.
-
Non-determinism expectations: Go’s specification states that if multiple cases are ready, one is chosen “uniformly at random.” Random pollorder implements this guarantee.
The pollorder is generated using a Fisher-Yates shuffle with cheaprand(), ensuring a uniform random permutation on each selectgo call.
Module 10: File Systems, I/O, and the Network Poller (60 min)
Background: I/O Multiplexing and the Quest for Scalable Networking
I/O is the interface between a program and the outside world – disks, networks,
terminals, pipes, and devices. The fundamental challenge of I/O is that it is
slow relative to computation. A modern CPU can execute billions of instructions
per second, but a network round-trip across the same data center takes tens of
microseconds, and a spinning disk seek takes milliseconds – an eternity in CPU
time. The simplest programming model, blocking I/O, has a thread call read()
and wait until data arrives. This works well for a single connection, but it
means one OS thread per concurrent I/O operation. Since OS threads are expensive
(each consumes kernel memory for its stack and task struct, and context-switching
between thousands of them bogs down the scheduler), blocking I/O hits a wall
when you need to handle many connections simultaneously. Unix’s
“everything-is-a-file” abstraction gives us a uniform interface (file
descriptors, read, write, close), but it does not solve the problem of
waiting on many file descriptors at once.
The history of I/O multiplexing on Unix and Linux is a story of successive
attempts to solve this scaling problem. The original select() system call,
introduced in 4.2BSD in 1983, let a process monitor multiple file descriptors
for readiness. But select has painful limitations: it uses a fixed-size
bitmask (typically limited to 1024 descriptors), and every call requires copying
the entire set of descriptors into the kernel and scanning all of them
regardless of how many are actually ready. poll() (SVR3, 1986) removed the
fixed-size limit by using an array of pollfd structs, but it still requires a
linear scan of all monitored descriptors on every call – O(n) in the total
number of descriptors, not the number of ready descriptors. For a server with
10,000 connections where only a handful have data at any moment, this is
wasteful. Linux’s epoll (2002) and BSD’s kqueue (2000, FreeBSD 4.1) finally
solved this with stateful, kernel-maintained interest sets: you register
descriptors once, and the kernel returns only the ready ones, giving O(1)
behavior with respect to the total number of monitored descriptors. More
recently, Linux’s io_uring (2019) pushes the boundary further with a
shared-memory submission/completion ring that eliminates system call overhead
entirely for batched I/O, enabling truly asynchronous operations for both
network and disk I/O.
These kernel mechanisms were developed in response to a concrete engineering
challenge articulated by Dan Kegel in his influential 1999 essay, “The C10K
Problem”: how do you build a server that handles 10,000 concurrent connections
on commodity hardware? At the time, this was considered very difficult. The
thread-per-connection model consumed too much memory and CPU on context
switches. Kegel surveyed the landscape of available approaches – non-blocking
I/O with select/poll, signal-driven I/O (SIGIO), and the then-nascent
asynchronous I/O interfaces – and argued that the OS needed better primitives.
The C10K essay directly influenced the development of epoll and kqueue, and
shaped the design of high-performance servers like Nginx (which replaced
Apache’s thread-per-connection model with a single-threaded event loop). Today,
the frontier has moved to the “C10M problem” – ten million concurrent
connections – driven by epoll, io_uring, and kernel-bypass techniques like
DPDK.
Different language runtimes have taken different approaches to giving
programmers a usable API on top of these kernel primitives. Event-driven
frameworks like libevent, libev, and libuv abstract over platform differences
(epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) and expose a
callback-based API: register interest in an event, provide a function to call
when it fires. Node.js builds on libuv and embraces this model fully – a
single-threaded event loop runs JavaScript callbacks when I/O completes, with a
thread pool for operations that lack async OS support (like file I/O on Linux).
Java took a different path: the NIO (New I/O) package introduced Selector and
non-blocking channels, but the callback-style programming was complex enough
that frameworks like Netty arose to manage it. Java 21’s virtual threads
(Project Loom) revisit the problem with lightweight user-space threads, much
like goroutines. Rust’s async ecosystem (Tokio runtime, mio for portable event
notification) uses async/await syntax compiled into state machines, offering
zero-cost abstractions but requiring the programmer to reason about pinning,
lifetimes, and Send/Sync bounds.
Go’s approach is distinctive: it hides all this complexity behind synchronous,
blocking-style I/O calls. When a goroutine calls conn.Read(), it looks like a
blocking call, and the goroutine does block – but the OS thread does not. Under
the hood, the Go runtime sets file descriptors to non-blocking mode, attempts
the I/O, and if the operation would block (EAGAIN), it parks the goroutine on
the runtime’s integrated network poller (epoll on Linux, kqueue on
macOS/BSD). The OS thread is freed to run other goroutines. When the kernel
signals that data is ready, the scheduler wakes the parked goroutine and it
retries the operation. The programmer writes simple sequential code; the runtime
provides the efficiency of an event-driven architecture. This module traces
exactly how this works, from the os.File type through the internal/poll
package down to the platform-specific poller implementations and their
integration with the scheduler.
1. The File Abstraction in Go (10 min)
The os.File Type
The public-facing file type in Go is os.File. It is defined as a pointer to
a private file struct:
// [src/os/file_unix.go, lines 55-66](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/os/file_unix.go;l=55)
// file is the real representation of *File.
// The extra level of indirection ensures that no clients of os
// can overwrite this data, which could cause the finalizer
// to close the wrong file descriptor.
type file struct {
pfd poll.FD
name string
dirinfo atomic.Pointer[dirInfo] // nil unless directory being read
nonblock bool // whether we set nonblocking mode
stdoutOrErr bool // whether this is stdout or stderr
appendMode bool // whether file is opened for appending
}
Key observations:
- The actual file descriptor is wrapped in a
poll.FD(frominternal/poll) - The
nonblockflag tracks whether Go put the descriptor into non-blocking mode - There is no direct
intfile descriptor field – it lives insidepfd.Sysfd
The io/fs.FS Interface
Go 1.16 introduced a filesystem abstraction via the io/fs package:
// [src/io/fs/fs.go, lines 40-52](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/io/fs/fs.go;l=40)
type FS interface {
// Open opens the named file.
Open(name string) (File, error)
}
And the minimal File interface:
// [src/io/fs/fs.go, lines 95-99](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/io/fs/fs.go;l=95)
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
This is a higher-level abstraction than os.File. The os.File type satisfies
this interface and much more (writing, seeking, etc.). The fs.FS interface
enables in-memory filesystems, embedded filesystems (embed.FS), and testing
without touching the real filesystem.
The Layering
The I/O path through Go’s runtime has multiple layers:
User Code
│
▼
os.File.Read() ← high-level, platform-independent
│
▼
internal/poll.FD.Read() ← non-blocking I/O with EAGAIN handling
│
▼
syscall.Read() ← raw system call wrapper
│
▼
kernel ← actual I/O
Each layer adds important functionality:
- os.File: name tracking, finalizers, safe concurrent access
- internal/poll.FD: non-blocking mode, poller integration, EAGAIN retry
- syscall: thin wrapper around the kernel interface
2. The internal/poll Package (10 min)
The FD Struct
The internal/poll.FD struct is the workhorse of Go’s I/O system. It wraps a
raw file descriptor with synchronization and poller integration:
// [src/internal/poll/fd_unix.go, lines 17-48](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/internal/poll/fd_unix.go;l=17)
// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// Platform dependent state of the file descriptor.
SysFile
// I/O poller.
pd pollDesc
// Semaphore signaled when file is closed.
csema uint32
// Non-zero if this file has been set to blocking mode.
isBlocking uint32
// Whether this is a streaming descriptor, as opposed to a
// packet-based descriptor like a UDP socket. Immutable.
IsStream bool
// Whether a zero byte read indicates EOF. This is false for a
// message based socket connection.
ZeroReadIsEOF bool
// Whether this is a file rather than a network socket.
isFile bool
}
Key fields:
fdmu: A specializedfdMutexthat serializes concurrent reads (and separately, concurrent writes) while also managing reference counting for safe close.Sysfd: The actual OS file descriptor number.pd: ApollDescthat hooks into the runtime’s network poller.isBlocking: When set, the FD bypasses the poller and uses blocking I/O (tying up the OS thread).
FD Initialization
When a file is opened, the FD.Init method registers it with the poller:
// [src/internal/poll/fd_unix.go, lines 55-73](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/internal/poll/fd_unix.go;l=55)
func (fd *FD) Init(net string, pollable bool) error {
fd.SysFile.init()
if net == "file" {
fd.isFile = true
}
if !pollable {
fd.isBlocking = 1
return nil
}
err := fd.pd.init(fd)
if err != nil {
// If we could not initialize the runtime poller,
// assume we are using blocking mode.
fd.isBlocking = 1
}
return err
}
The pd.init(fd) call ultimately invokes runtime.poll_runtime_pollOpen, which
registers the file descriptor with the platform’s event notification mechanism
(epoll/kqueue).
The Read Loop with EAGAIN Handling
The most important method is FD.Read. This is where non-blocking I/O meets
goroutine scheduling:
// [src/internal/poll/fd_unix.go, lines 140-173](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/internal/poll/fd_unix.go;l=140)
// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.readLock(); err != nil {
return 0, err
}
defer fd.readUnlock()
if len(p) == 0 {
return 0, nil
}
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
err = fd.eofError(n, err)
return n, err
}
}
The critical loop:
- Try the read via
syscall.Read(non-blocking, since the fd is in non-blocking mode) - If the read returns
EAGAIN(no data available):- Call
fd.pd.waitRead()– this parks the goroutine on the poller - When data arrives, the poller wakes the goroutine
- The goroutine retries the read (
continue)
- Call
- If the read succeeds or fails with a real error, return
This is the fundamental trick: blocking I/O semantics from the programmer’s perspective, non-blocking I/O under the hood. The goroutine blocks, but the OS thread does not.
3. Non-Blocking I/O Setup (5 min)
How Files Enter Non-Blocking Mode
When Go opens a file or socket, it puts the descriptor into non-blocking mode.
This happens in the newFile function:
// [src/os/file_unix.go, lines 194-210](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/os/file_unix.go;l=194)
clearNonBlock := false
if pollable {
if nonBlocking {
if kind == kindSock {
f.nonblock = true
}
} else if err := syscall.SetNonblock(fd, true); err == nil {
f.nonblock = true
clearNonBlock = true
} else {
pollable = false
}
}
Then the FD is registered with the poller:
// [src/os/file_unix.go, lines 219-223](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/os/file_unix.go;l=219)
if pollErr := f.pfd.Init("file", pollable); pollErr != nil && clearNonBlock {
if err := syscall.SetNonblock(fd, false); err == nil {
f.nonblock = false
}
}
Platform Limitations
Not all file descriptors work with the poller. On macOS/BSD, regular files and
directories cannot be polled with kqueue (they always report as ready). The
newFile function checks for this:
// [src/os/file_unix.go, lines 165-192](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/os/file_unix.go;l=165)
if kind == kindOpenFile {
switch runtime.GOOS {
case "darwin", "ios", "dragonfly", "freebsd", "netbsd", "openbsd":
var st syscall.Stat_t
err := ignoringEINTR(func() error {
return syscall.Fstat(fd, &st)
})
typ := st.Mode & syscall.S_IFMT
// Don't try to use kqueue with regular files on *BSDs.
if err == nil && (typ == syscall.S_IFREG || typ == syscall.S_IFDIR) {
pollable = false
}
// On Darwin, kqueue does not work properly with fifos
if (runtime.GOOS == "darwin" || runtime.GOOS == "ios") && typ == syscall.S_IFIFO {
pollable = false
}
}
}
When a descriptor cannot use the poller, it falls back to blocking I/O, which will tie up an OS thread. This is acceptable for regular files (which complete quickly) but would be problematic for network sockets (which is why network sockets always use the poller).
4. The Network Poller: Core Design (10 min)
Architecture
The network poller lives in runtime/netpoll.go and provides a platform-independent
interface. Each platform implements the actual polling mechanism:
// [src/runtime/netpoll.go, lines 16-37](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll.go;l=16)
// A particular implementation (epoll/kqueue/port/AIX/Windows)
// must define the following functions:
//
// func netpollinit()
// Initialize the poller. Only called once.
//
// func netpollopen(fd uintptr, pd *pollDesc) int32
// Arm edge-triggered notifications for fd.
//
// func netpollclose(fd uintptr) int32
// Disable notifications for fd.
//
// func netpoll(delta int64) (gList, int32)
// Poll the network. If delta < 0, block indefinitely.
// If delta == 0, poll without blocking.
// If delta > 0, block for up to delta nanoseconds.
// Return a list of goroutines built by calling netpollready.
//
// func netpollBreak()
// Wake up the network poller, assumed to be blocked in netpoll.
The pollDesc Struct
Each file descriptor registered with the poller gets a pollDesc:
// [src/runtime/netpoll.go, lines 75-115](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll.go;l=75)
type pollDesc struct {
_ sys.NotInHeap
link *pollDesc // in pollcache, protected by pollcache.lock
fd uintptr // constant for pollDesc usage lifetime
fdseq atomic.Uintptr // protects against stale pollDesc
atomicInfo atomic.Uint32 // atomic pollInfo
// rg, wg are accessed atomically and hold g pointers.
rg atomic.Uintptr // pdReady, pdWait, G waiting for read or pdNil
wg atomic.Uintptr // pdReady, pdWait, G waiting for write or pdNil
lock mutex // protects the following fields
closing bool
rrun bool // whether rt is running
wrun bool // whether wt is running
user uint32 // user settable cookie
rseq uintptr // protects from stale read timers
rt timer // read deadline timer
rd int64 // read deadline
wseq uintptr // protects from stale write timers
wt timer // write deadline timer
wd int64 // write deadline
self *pollDesc // storage for indirect interface
}
The pollDesc contains two binary semaphores (rg and wg) for parking
reader and writer goroutines respectively.
Poll States
The semaphores (rg, wg) can be in one of four states:
// [src/runtime/netpoll.go, lines 51-68](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll.go;l=51)
// pollDesc contains 2 binary semaphores, rg and wg, to park reader and writer
// goroutines respectively. The semaphore can be in the following states:
//
// pdReady - io readiness notification is pending;
// a goroutine consumes the notification by changing the state to pdNil.
// pdWait - a goroutine prepares to park on the semaphore, but not yet parked;
// the goroutine commits to park by changing the state to G pointer,
// or, alternatively, concurrent io notification changes the state to pdReady,
// or, alternatively, concurrent timeout/close changes the state to pdNil.
// G pointer - the goroutine is blocked on the semaphore;
// io notification or timeout/close changes the state to pdReady or pdNil
// and unparks the goroutine.
// pdNil - none of the above.
const (
pdNil uintptr = 0
pdReady uintptr = 1
pdWait uintptr = 2
)
State transition diagram:
┌─────────────┐
│ pdNil │ (initial / idle)
└──────┬──────┘
│ goroutine wants to read
▼
┌─────────────┐
│ pdWait │ (preparing to park)
└──────┬──────┘
│ goroutine commits to park
▼
┌─────────────┐
I/O ready │ G pointer │ goroutine is blocked
─────┤ ├───── timeout/close
│ └─────────────┘ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ pdReady │ │ pdNil │
└─────────────┘ └─────────────┘
│ goroutine wakes and
│ consumes notification
▼
┌─────────────┐
│ pdNil │
└─────────────┘
netpollready: Waking Goroutines
When the platform poller detects that a file descriptor is ready, it calls
netpollready:
// [src/runtime/netpoll.go, lines 483-510](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll.go;l=483)
// netpollready is called by the platform-specific netpoll function.
// It declares that the fd associated with pd is ready for I/O.
// The toRun argument is used to build a list of goroutines to return
// from netpoll. The mode argument is 'r', 'w', or 'r'+'w' to indicate
// whether the fd is ready for reading or writing or both.
func netpollready(toRun *gList, pd *pollDesc, mode int32) int32 {
delta := int32(0)
var rg, wg *g
if mode == 'r' || mode == 'r'+'w' {
rg = netpollunblock(pd, 'r', true, &delta)
}
if mode == 'w' || mode == 'r'+'w' {
wg = netpollunblock(pd, 'w', true, &delta)
}
if rg != nil {
toRun.push(rg)
}
if wg != nil {
toRun.push(wg)
}
return delta
}
This function unblocks the goroutine(s) waiting on the given pollDesc and adds
them to the toRun list. The caller (netpoll) returns this list to the
scheduler, which puts these goroutines back on run queues.
5. Platform Implementations (10 min)
epoll on Linux
The Linux implementation uses epoll in edge-triggered mode:
// [src/runtime/netpoll_epoll.go, lines 15-19](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_epoll.go;l=15)
var (
epfd int32 = -1 // epoll descriptor
netpollEventFd uintptr // eventfd for netpollBreak
netpollWakeSig atomic.Uint32 // used to avoid duplicate calls of netpollBreak
)
Initialization creates an epoll instance and an eventfd for waking the poller:
// [src/runtime/netpoll_epoll.go, lines 21-43](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_epoll.go;l=21)
func netpollinit() {
var errno uintptr
epfd, errno = linux.EpollCreate1(linux.EPOLL_CLOEXEC)
if errno != 0 {
println("runtime: epollcreate failed with", errno)
throw("runtime: netpollinit failed")
}
efd, errno := linux.Eventfd(0, linux.EFD_CLOEXEC|linux.EFD_NONBLOCK)
// ... register eventfd with epoll for wakeups ...
netpollEventFd = uintptr(efd)
}
Registering a file descriptor uses edge-triggered mode (EPOLLET):
// [src/runtime/netpoll_epoll.go, lines 49-55](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_epoll.go;l=49)
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
var ev linux.EpollEvent
ev.Events = linux.EPOLLIN | linux.EPOLLOUT | linux.EPOLLRDHUP | linux.EPOLLET
tp := taggedPointerPack(unsafe.Pointer(pd), pd.fdseq.Load())
*(*taggedPointer)(unsafe.Pointer(&ev.Data)) = tp
return linux.EpollCtl(epfd, linux.EPOLL_CTL_ADD, int32(fd), &ev)
}
The flags EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET mean:
- Monitor for both read and write readiness
- Detect remote hangup (
EPOLLRDHUP) - Edge-triggered (
EPOLLET): only notify when readiness changes (e.g., new data arrives), not continuously while data remains available. This contrasts with level-triggered mode (the default) where the kernel repeatedly reports readiness as long as the condition holds. Edge-triggered is more efficient for Go’s model because each fd has exactly one goroutine waiting, so there is no risk of missing events.
The main polling function calls epoll_wait:
// [src/runtime/netpoll_epoll.go, lines 99-176](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_epoll.go;l=99)
func netpoll(delay int64) (gList, int32) {
// ... convert delay to milliseconds ...
var events [128]linux.EpollEvent
retry:
n, errno := linux.EpollWait(epfd, events[:], int32(len(events)), waitms)
// ... error handling ...
var toRun gList
delta := int32(0)
for i := int32(0); i < n; i++ {
ev := events[i]
// ... skip eventfd wakeup events ...
var mode int32
if ev.Events&(linux.EPOLLIN|linux.EPOLLRDHUP|linux.EPOLLHUP|linux.EPOLLERR) != 0 {
mode += 'r'
}
if ev.Events&(linux.EPOLLOUT|linux.EPOLLHUP|linux.EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
tp := *(*taggedPointer)(unsafe.Pointer(&ev.Data))
pd := (*pollDesc)(tp.pointer())
tag := tp.tag()
if pd.fdseq.Load() == tag {
pd.setEventErr(ev.Events == linux.EPOLLERR, tag)
delta += netpollready(&toRun, pd, mode)
}
}
}
return toRun, delta
}
The tagged pointer stored in the epoll event data allows the runtime to map events
back to their pollDesc. The fdseq check guards against stale events for
file descriptors that have been closed and reused.
kqueue on macOS/BSD
The macOS/BSD implementation uses kqueue with EV_CLEAR (the kqueue equivalent
of edge-triggered mode):
// [src/runtime/netpoll_kqueue.go, lines 32-60](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_kqueue.go;l=32)
func netpollopen(fd uintptr, pd *pollDesc) int32 {
// Arm both EVFILT_READ and EVFILT_WRITE in edge-triggered mode (EV_CLEAR)
var ev [2]keventt
*(*uintptr)(unsafe.Pointer(&ev[0].ident)) = fd
ev[0].filter = _EVFILT_READ
ev[0].flags = _EV_ADD | _EV_CLEAR
ev[0].fflags = 0
ev[0].data = 0
// ... store tagged pointer in ev[0].udata ...
ev[1] = ev[0]
ev[1].filter = _EVFILT_WRITE
n := kevent(kq, &ev[0], 2, nil, 0, nil)
if n < 0 {
return -n
}
return 0
}
Key differences from epoll:
- kqueue uses two separate filters (
EVFILT_READ,EVFILT_WRITE) instead of a bitmask EV_CLEARis the kqueue equivalent ofEPOLLET(edge-triggered)- No need to explicitly unregister – closing the fd removes all kevents
- kqueue uses
kevent()for both registration and waiting
The polling function is structurally similar:
// [src/runtime/netpoll_kqueue.go, lines 90-183](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_kqueue.go;l=90)
func netpoll(delay int64) (gList, int32) {
// ...
var events [64]keventt
retry:
n := kevent(kq, nil, 0, &events[0], int32(len(events)), tp)
// ...
for i := 0; i < int(n); i++ {
ev := &events[i]
// ... skip wakeup events ...
var mode int32
switch ev.filter {
case _EVFILT_READ:
mode += 'r'
if ev.flags&_EV_EOF != 0 {
mode += 'w' // closed pipe: wake writers too
}
case _EVFILT_WRITE:
mode += 'w'
}
if mode != 0 {
// ... extract pollDesc from tagged pointer ...
delta += netpollready(&toRun, pd, mode)
}
}
return toRun, delta
}
Waking the Poller: netpollBreak
Both implementations provide a way to wake the poller from another thread:
Linux uses an eventfd:
// [src/runtime/netpoll_epoll.go, lines 67-89](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_epoll.go;l=67)
func netpollBreak() {
if !netpollWakeSig.CompareAndSwap(0, 1) {
return // already being woken
}
var one uint64 = 1
oneSize := int32(unsafe.Sizeof(one))
for {
n := write(netpollEventFd, noescape(unsafe.Pointer(&one)), oneSize)
if n == oneSize {
break
}
// ... handle EINTR, EAGAIN ...
}
}
macOS/BSD uses a platform-specific wakeup mechanism (wakeNetpoll):
// [src/runtime/netpoll_kqueue.go, lines 73-80](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/netpoll_kqueue.go;l=73)
func netpollBreak() {
if !netpollWakeSig.CompareAndSwap(0, 1) {
return
}
wakeNetpoll(kq)
}
The CAS on netpollWakeSig prevents redundant wakeup writes when multiple
goroutines try to break the poller simultaneously.
6. Integration with the Scheduler (10 min)
findRunnable() and the Poller
The scheduler’s findRunnable() function (in proc.go) checks the network
poller as part of its work-stealing loop. When there is no other work to do,
the scheduler thread blocks in the poller:
// [src/runtime/proc.go, lines 3731-3754](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/proc.go;l=3731)
// Poll network until next timer.
if netpollinited() && (netpollAnyWaiters() || pollUntil != 0) && sched.lastpoll.Swap(0) != 0 {
sched.pollUntil.Store(pollUntil)
// ...
delay := int64(-1)
if pollUntil != 0 {
if now == 0 {
now = nanotime()
}
delay = pollUntil - now
if delay < 0 {
delay = 0
}
}
// ...
list, delta := netpoll(delay) // block until new work is available
The netpoll(delay) call blocks the OS thread in epoll_wait / kevent until
either:
- A file descriptor becomes ready (I/O event)
- The delay expires (timer event)
netpollBreak()is called (new work available elsewhere)
The returned gList contains goroutines whose I/O is ready. The scheduler
injects them back into run queues.
The Complete I/O Flow
Here is the full lifecycle of a goroutine performing a network read:
1. Goroutine calls conn.Read(buf)
│
▼
2. os.File.Read → internal/poll.FD.Read
│
▼
3. syscall.Read(fd, buf)
│
├── Data available → return n, nil (fast path)
│
└── EAGAIN (no data)
│
▼
4. fd.pd.waitRead() → runtime.poll_runtime_pollWait
│
▼
5. netpollblock():
- Set rg = pdWait
- CAS rg from pdWait to G pointer (commit to park)
- gopark() — goroutine is descheduled
│
▼
6. OS thread is FREE to run other goroutines
│
▼
7. Eventually, data arrives on the socket
│
▼
8. Another thread calls netpoll() (in findRunnable or sysmon)
- epoll_wait/kevent returns the ready fd
- netpollready() extracts the parked G from pd.rg
- G is added to the run queue
│
▼
9. Scheduler picks up the goroutine
│
▼
10. Goroutine resumes in FD.Read loop, retries syscall.Read
- This time data is available → returns to caller
Why This Matters
This design achieves the best of both worlds:
| Approach | Programmer Experience | OS Thread Usage |
|---|---|---|
| Blocking I/O | Simple (synchronous) | 1 thread per connection |
| Callbacks/async | Complex (fragmented) | Efficient |
| Go’s approach | Simple (synchronous) | Efficient |
The programmer writes straightforward sequential code:
for {
n, err := conn.Read(buf)
if err != nil {
break
}
process(buf[:n])
}
But under the hood, the goroutine parks when no data is available, freeing the OS thread to run other goroutines. When data arrives, the poller wakes the goroutine and it seamlessly resumes.
7. The Big Picture: How All the Pieces Fit Together (5 min)
┌─────────────────────────────────────────────────────────────┐
│ User Code │
│ conn.Read(buf) file.Write(data) http.Get(url) │
└────────────┬────────────────┬───────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ os / net packages │
│ os.File net.Conn http.Client │
└────────────┬────────────────┬───────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ internal/poll.FD │
│ Non-blocking I/O EAGAIN retry loop fdMutex │
└────────────┬──────────────────────────────────┬─────────────┘
│ │
syscall.Read/Write fd.pd.waitRead/waitWrite
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────────────┐
│ Linux kernel │ │ runtime netpoll │
│ (actual I/O) │ │ │
└────────────────────┘ │ pollDesc (per-fd state) │
│ rg/wg semaphores │
│ │
│ ┌─────────┐ ┌──────────┐ │
│ │ epoll │ │ kqueue │ │
│ │ (Linux) │ │ (macOS) │ │
│ └─────────┘ └──────────┘ │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Scheduler (findRunnable) │
│ │
│ netpoll() returns gList │
│ → goroutines go to run queue│
│ → goroutines resume I/O │
└──────────────────────────────┘
Connections to Previous Modules
-
Module 4 (Scheduler):
findRunnable()integrates poller checks into the scheduling loop. The poller is checked both in the fast path (non-blocking poll) and as a last resort (blocking poll when no other work exists). -
Module 6 (Synchronization): The
fdMutexinpoll.FDis a specialized lock that supports concurrent readers/writers while managing close semantics. ThepollDesc.rg/wgsemaphores use atomic CAS operations. -
Module 9 (Stacks): When a goroutine parks on I/O, its stack remains allocated but can be shrunk by the GC if it sits idle long enough.
Summary
| Component | Role | File |
|---|---|---|
os.File | User-facing file type | os/file_unix.go |
fs.FS | Filesystem interface | io/fs/fs.go |
poll.FD | Non-blocking I/O wrapper | internal/poll/fd_unix.go |
pollDesc | Per-fd poller state | runtime/netpoll.go |
netpoll() | Platform polling | runtime/netpoll_epoll.go, runtime/netpoll_kqueue.go |
findRunnable() | Scheduler integration | runtime/proc.go |
Key Takeaways
-
Go files are opened in non-blocking mode so that
EAGAINcan be handled by parking the goroutine instead of blocking the OS thread. -
The EAGAIN retry loop in
poll.FD.Readis the core mechanism: try the syscall, if EAGAIN then park on the poller, wake up and retry. -
pollDesc semaphores (rg/wg) track whether a goroutine is waiting for read/write readiness on each file descriptor.
-
Edge-triggered polling (EPOLLET / EV_CLEAR) avoids redundant wakeups – the poller only notifies on state transitions.
-
The scheduler calls netpoll() to discover goroutines whose I/O is ready, seamlessly integrating I/O events with goroutine scheduling.
-
The result: programmers write simple blocking I/O code, but the runtime multiplexes thousands of connections across a small number of OS threads.
Discussion Questions
-
What happens when you pass an
os.Fileto C code via cgo? Can the poller still manage it, or does the fd revert to blocking mode? -
Regular files are always reported as ready by epoll on Linux (making epoll effectively useless for them), and regular files on macOS do not support kqueue at all. Given this, how does Go handle
os.File.Readon a regular file? (Hint: check thepollablelogic innewFile.) -
Why does the poller use edge-triggered mode instead of level-triggered? What would go wrong with level-triggered notifications in Go’s model?
-
The
netpollBreakfunction uses a CAS to avoid duplicate wakeups. Why is this optimization important? What would happen without it?
Module 10: File Systems, I/O, and the Network Poller
Comprehension Check
Question 1 (Code Reading)
Study this excerpt from runtime/netpoll.go:
// Integrated network poller (platform-independent part).
// A particular implementation (epoll/kqueue/port/AIX/Windows)
// must define the following functions:
//
// func netpollinit()
// Initialize the poller. Only called once.
//
// func netpollopen(fd uintptr, pd *pollDesc) int32
// Arm edge-triggered notifications for fd.
//
// func netpollclose(fd uintptr) int32
// Disable notifications for fd.
//
// func netpoll(delta int64) (gList, int32)
// Poll the network. If delta < 0, block indefinitely.
// If delta == 0, poll without blocking.
// If delta > 0, block for up to delta nanoseconds.
// Return a list of goroutines built by calling netpollready.
Why does the network poller use edge-triggered rather than level-triggered notifications? What would happen if it used level-triggered mode?
Answer
Edge-triggered means the poller notifies once when the state changes (e.g., data becomes available), not repeatedly while data remains available.
Why edge-triggered:
-
Fewer wakeups: With level-triggered mode, every call to
epoll_waitwould return the same file descriptors that still have data, even if the goroutine has not yet read it. In a system with thousands of connections, this causesepoll_waitto repeatedly return large batches of ready FDs, most of which are already being handled. -
Simpler goroutine model: The network poller’s design is “one goroutine per FD.” When data arrives, the goroutine for that FD is woken exactly once. With level-triggered, the poller would need to suppress duplicate wakeups (since the FD remains “ready” until the goroutine reads all data), adding complexity.
-
Efficient integration with scheduler: The poller returns a
gListof goroutines to make runnable. With edge-triggered, each FD produces at most one goroutine per state change, keeping the list small and predictable.
If level-triggered were used:
- The
netpollfunction would return the same goroutines repeatedly on every call until they fully drain their data. - The scheduler would attempt to wake goroutines that are already running, requiring additional bookkeeping.
- On a busy server with 10,000 connections, each
netpollcall might return thousands of “ready” FDs, most already being serviced, causing wasted work scanning the list.
The tradeoff is that edge-triggered requires the application to fully drain available data on each notification (otherwise data sits unprocessed until the next event). Go handles this by having goroutines read in a loop until EAGAIN.
Question 2 (Short Answer)
Describe the path a conn.Read() call takes from user code down to the kernel. Include the role of os.File, poll.FD, pollDesc, and the network poller.
Answer
The path from user code to kernel and back:
-
conn.Read(buf)- User callsReadon anet.Conn(e.g.,*net.TCPConn). -
net.(*netFD).Read()- Thenet.Connwraps anetFD, which delegates topoll.FD. -
poll.(*FD).Read()- This is ininternal/poll. It:- Calls the
read()system call (non-blocking, since the FD is set toO_NONBLOCK). - If data is available, returns immediately (fast path).
- If
EAGAINis returned (no data yet), callsfd.pd.waitRead().
- Calls the
-
poll.(*pollDesc).waitRead()- Callsruntime_pollWait(pd, 'r'). -
runtime.poll_runtime_pollWait()- In the runtime:- Checks if the FD is already ready (fast path).
- If not, sets the
pollDesc.rgfield to the current goroutine pointer. - Calls
gopark()to park the goroutine, removing it from the scheduler.
-
The goroutine is now parked. The network poller (
epoll_wait/kqueuerunning insysmonorfindRunnable) will eventually detect that the FD has data. -
netpoll()returns - Whenepoll_waitindicates readiness,netpollready()setspd.rg = pdReadyand adds the goroutine to the return list. -
Goroutine is made runnable - The scheduler (via
findRunnableorsysmon) injects the goroutine into a P’s run queue. -
poll.(*FD).Read()resumes - The goroutine wakes up and retries theread()system call, which now succeeds.
Question 3 (True/False with Explanation)
True or False: File I/O (reading from disk) uses the same network poller mechanism as network I/O in Go.
Answer
Mostly False. Regular file I/O and network I/O take different paths:
Network I/O uses the network poller (epoll/kqueue). Sockets are set to non-blocking mode, and goroutines park/unpark efficiently through pollDesc.
Regular file I/O (reading/writing files on disk) does NOT use the network poller. This is because epoll and kqueue do not support regular files – they always report regular files as “ready” since disk I/O is handled differently from socket I/O in the kernel.
Instead, regular file reads use blocking system calls (pread/read). When a goroutine does file I/O:
- It calls
entersyscall(), which detaches the P from the M. - The M blocks in the kernel waiting for disk I/O.
sysmonmay retake the P and hand it to another M.- When the I/O completes, the M calls
exitsyscall()and tries to reacquire a P.
Exception: On some platforms, certain special files (pipes, FIFOs, device files, eventfd) CAN be polled and do use the poller. Also, some platforms are adding io_uring support which could change this for regular files.
Question 4 (What Would Happen If…)
The pollDesc struct uses a state machine for its rg (read goroutine) field with states: pdReady, pdWait, nil, and a goroutine pointer. What would happen if the implementation skipped the pdWait intermediate state and went directly from nil to a goroutine pointer?
Answer
The pdWait state exists to handle a race condition between the goroutine preparing to park and the I/O becoming ready. The sequence is:
Normal flow:
1. goroutine sets rg = pdWait (preparing to park)
2. goroutine sets rg = G pointer (committed to parking)
3. netpoll sets rg = pdReady (I/O ready, wakes goroutine)
Without pdWait (going directly nil -> G pointer):
There is a window between when the goroutine decides to wait and when it actually parks. If I/O becomes ready during this window:
- Goroutine calls
runtime_pollWait, checksrg == nil(not ready). - I/O completes.
netpollreadytries to wake the goroutine butrg == nil– no goroutine to wake. The wakeup is lost. - Goroutine sets
rg = G pointerand parks. - Goroutine sleeps forever – the I/O event was already delivered and will not be re-delivered (edge-triggered).
With pdWait:
- Goroutine sets
rg = pdWait(announces intent to sleep). - If I/O completes now,
netpollreadyatomically CAS’spdWait->pdReady. The goroutine seespdReadyand does not park. - If I/O has not completed, goroutine CAS’s
pdWait->G pointerand parks. - When I/O completes,
netpollreadyCAS’sG pointer->pdReadyand wakes the goroutine.
The pdWait state makes the protocol robust against this race by allowing netpollready to signal readiness even before the goroutine has fully committed to sleeping.
Question 5 (Short Answer)
Explain the role of sysmon in network polling. Why can’t the scheduler rely solely on findRunnable calling netpoll?
Answer
sysmon periodically calls netpoll(0) (non-blocking poll) and injects any ready goroutines into the global run queue.
Why findRunnable alone is insufficient:
-
All Ps busy:
findRunnableis only called when a P has no local work. If all Ps are busy executing compute-bound goroutines,netpollis never called. Network-ready goroutines would be stuck until some P happens to enterfindRunnable.sysmonruns on its own M (without a P) and can poll regardless of P utilization. -
All Ms in syscalls: If all Ms are blocked in system calls and all Ps have been retaken but are idle (no runnable goroutines), nobody is calling
findRunnable.sysmoncan still poll and inject work, then wake an M to run it. -
Latency guarantees:
sysmonprovides a guaranteed upper bound on network polling latency (~10ms). Without it, a burst of compute work could delay network polling indefinitely. -
Timer-based blocking:
findRunnablemay callnetpoll(delta)with a timeout to block until the next timer fires. But iffindRunnableis not being called (all Ps busy), timers also needsysmonto fire them.
sysmon acts as a backstop that ensures the network poller is checked regularly regardless of scheduler activity.
Question 6 (Code Reading)
Consider this comment from runtime/netpoll.go describing pollDesc states:
// pollDesc contains 2 binary semaphores, rg and wg, to park reader and writer
// goroutines respectively. The semaphore can be in the following states:
//
// pdReady - io readiness notification is pending;
// a goroutine consumes the notification by changing the state to pdNil.
// pdWait - a goroutine prepares to park on the semaphore, but not yet parked;
// the goroutine commits to park by changing the state to G pointer,
// or, alternatively, concurrent io notification changes the state to pdReady,
// or, alternatively, concurrent timeout/close changes the state to pdNil.
// G pointer - the goroutine is blocked on the semaphore;
// io notification or timeout/close changes the state to pdReady or pdNil
// respectively and unparks the goroutine.
Draw the state transition diagram for rg. What transitions are valid, and who performs each transition?
Answer
State transitions for rg:
goroutine goroutine
pdNil -----(prepare to wait)-----> pdWait ------(commit)-----> G pointer
^ | |
| | |
| netpollready | timeout/close | netpollready
| (sets pdReady) | (sets pdNil) | (sets pdReady,
| | | unparks G)
| v v
+---------(goroutine consumes)--- pdReady <-----(io ready)------+
Transitions and who performs them:
-
pdNil -> pdWait: The goroutine, in
runtime_pollWait, sets this to announce intent to park. -
pdWait -> G pointer: The goroutine, committing to park (CAS in
netpollblock). -
pdWait -> pdReady: The network poller (
netpollready), when I/O becomes ready before the goroutine parked. Goroutine sees this and does not park. -
pdWait -> pdNil: Timeout or FD close, aborting the wait before the goroutine parked.
-
G pointer -> pdReady: The network poller (
netpollready), when I/O becomes ready. The goroutine is unparked. -
G pointer -> pdNil: Timeout or FD close. The goroutine is unparked with an error.
-
pdReady -> pdNil: The goroutine, consuming the readiness notification before retrying the I/O operation.
All transitions on rg use atomic CAS operations to handle concurrent access between the goroutine and the network poller / timer / close paths.
Module 2: System Calls
Duration: 60 minutes
Background: The User-Kernel Boundary
System calls are the fundamental interface between user programs and the operating system kernel. Every time a program opens a file, allocates memory, sends a network packet, or creates a new process, it must cross the boundary from user mode into kernel mode through a system call. This boundary is not merely a software convention – it is enforced by the CPU’s hardware privilege levels. The kernel runs in a privileged mode (ring 0 on x86, EL1 on ARM64) with full access to hardware and memory, while user programs run in a restricted mode (ring 3 / EL0) where attempts to execute privileged instructions or access protected memory trigger a processor fault. System calls are the controlled gateway through this hardware barrier.
The mechanism for crossing this boundary has evolved considerably over the decades. Early systems like the PDP-11 used a TRAP instruction to transfer control to the kernel, and this basic model – a special instruction that atomically switches privilege levels and jumps to a kernel-defined entry point – has persisted in every architecture since. On x86, the history is particularly convoluted: the original 8086 used INT 0x80 (a software interrupt), which required the CPU to look up an interrupt descriptor table entry, save state to the stack, and perform a privilege level change – a process that could take hundreds of CPU cycles on modern processors due to pipeline flushes and serialization. Intel introduced SYSENTER/SYSEXIT with the Pentium II, and AMD introduced SYSCALL/SYSRET with the K6-2, both designed to reduce this overhead by using pre-configured Model-Specific Registers (MSRs) instead of memory-based descriptor tables. On modern x86-64 Linux, SYSCALL is the standard mechanism, bringing the transition cost down to roughly 50-100 nanoseconds – fast, but far from free when called millions of times per second.
Linux today defines over 450 system calls on x86-64 (the exact count grows with each kernel release as new calls are added and old ones are rarely removed, since the syscall ABI is a stability guarantee). These calls fall into several broad categories: process management (fork, clone, execve, wait4, exit), file I/O (open, read, write, close, lseek), memory management (mmap, munmap, brk, madvise), networking (socket, bind, listen, accept, sendto, recvfrom), and inter-process communication (pipe, shmget, semop). Over the years, many syscalls have been superseded by improved versions – open by openat, epoll_wait by epoll_pwait2, clone by clone3 – reflecting the kernel community’s preference for extending the interface with new calls rather than modifying the semantics of existing ones. Newer additions like io_uring_setup (added in Linux 5.1) and landlock_add_rule (Linux 5.13) show that the syscall table continues to grow as the kernel gains capabilities.
The cost of crossing the user-kernel boundary has driven significant innovation in reducing or avoiding syscalls altogether. The vDSO (virtual Dynamic Shared Object) is a kernel-provided shared library mapped into every process’s address space, allowing frequently-called operations like clock_gettime and gettimeofday to execute entirely in user space by reading kernel-maintained data pages – turning a ~100ns syscall into a ~20ns function call. At the other extreme, frameworks like DPDK (Data Plane Development Kit) bypass the kernel entirely for network I/O, mapping NIC hardware directly into user space to achieve line-rate packet processing. Linux’s io_uring, introduced in kernel 5.1, takes a middle path: it uses shared memory ring buffers between user space and the kernel to batch and submit I/O operations asynchronously, dramatically reducing the per-operation syscall overhead for I/O-heavy workloads. These approaches highlight a recurring tension in OS design: syscalls provide safety and abstraction, but that safety comes at a performance cost that matters in high-throughput systems.
This module examines how Go navigates this landscape. Go makes an unusual choice among programming languages: on Linux, the runtime bypasses libc entirely and issues system calls directly via assembly stubs, relying on the kernel’s stable syscall ABI. Most other languages – and even Go itself on macOS and Windows – call through the C library, which serves as a portability layer and insulates programs from kernel interface changes. Go’s approach on Linux yields tighter integration between the runtime scheduler and syscall handling (as we will see with entersyscall/exitsyscall), but it also means the runtime must handle the raw kernel interface, including error conventions, signal safety, and architectural differences. We will trace this path from the raw SYSCALL instruction in assembly, through the syscall package’s Go wrappers, to the runtime’s scheduler integration that keeps goroutines running efficiently even when some are blocked in the kernel.
1. What Are System Calls? (10 min)
The User/Kernel Boundary
A modern operating system divides execution into two privilege levels:
- User mode: where application code runs. It can execute normal instructions but cannot directly access hardware, other processes’ memory, or privileged CPU features.
- Kernel mode: where the OS kernel runs. It has unrestricted access to hardware, memory, and privileged instructions.
A system call (syscall) is the mechanism by which user-mode code requests services from the kernel. On x86-64 Linux, the transition happens via the SYSCALL instruction, which:
- Saves the user-mode instruction pointer (RIP) and flags (RFLAGS)
- Loads the kernel-mode instruction pointer and stack pointer from MSRs (Model-Specific Registers — special CPU registers that the kernel configures during boot to specify where to jump when a syscall occurs)
- Switches to ring 0 (kernel privilege level — x86 CPUs define privilege “rings,” where ring 0 is the most privileged level used by the kernel, and ring 3 is the least privileged level where user code runs)
- Jumps to the kernel’s syscall entry point
The kernel looks at the value in RAX (the syscall number), dispatches to the appropriate handler, and returns to user mode via SYSRET.
The x86-64 Linux Syscall Convention
| Register | Purpose |
|---|---|
RAX | Syscall number (in) / return value (out) |
RDI | Argument 1 |
RSI | Argument 2 |
RDX | Argument 3 |
R10 | Argument 4 |
R8 | Argument 5 |
R9 | Argument 6 |
On error, RAX contains a negative errno value (e.g., -ENOENT = -2).
2. How Go Makes System Calls on Linux (15 min)
The Go runtime implements system calls directly in assembly, bypassing libc entirely on Linux. The wrappers live in src/runtime/sys_linux_amd64.s.
Syscall Number Definitions
The file begins by defining the syscall numbers as constants, matching the Linux kernel’s numbering:
src/runtime/sys_linux_amd64.s, lines 16-49:
// src/runtime/sys_linux_amd64.s, lines 16-49
#define SYS_read 0
#define SYS_write 1
#define SYS_close 3
#define SYS_mmap 9
#define SYS_munmap 11
#define SYS_brk 12
#define SYS_rt_sigaction 13
#define SYS_rt_sigprocmask 14
#define SYS_rt_sigreturn 15
#define SYS_sched_yield 24
#define SYS_madvise 28
#define SYS_nanosleep 35
#define SYS_getpid 39
#define SYS_clone 56
#define SYS_exit 60
#define SYS_kill 62
#define SYS_sigaltstack 131
#define SYS_futex 202
#define SYS_clock_gettime 228
#define SYS_exit_group 231
#define SYS_openat 257
#define SYS_pipe2 293
These numbers are stable ABI – the Linux kernel guarantees they won’t change. This is why Go can bypass libc on Linux: the syscall interface is the stable boundary, not the C library.
exit: The Simplest Syscall
src/runtime/sys_linux_amd64.s, lines 51-55:
// src/runtime/sys_linux_amd64.s, lines 51-55
TEXT runtime·exit(SB),NOSPLIT,$0-4
MOVL code+0(FP), DI
MOVL $SYS_exit_group, AX
SYSCALL
RET
Line by line:
TEXT runtime·exit(SB),NOSPLIT,$0-4– declares the function.NOSPLITmeans it won’t grow the stack (important because we’re about to leave Go’s world).$0-4means 0 bytes of local frame, 4 bytes of arguments.MOVL code+0(FP), DI– loads the Go function argumentcodeintoDI(first argument register per Linux ABI).MOVL $SYS_exit_group, AX– loads syscall number 231 (exit_group, which kills all threads in the process) intoAX.SYSCALL– traps into the kernel.RET– never reached, sinceexit_groupdoesn’t return.
Note: Go uses exit_group (not exit) because a Go process has multiple OS threads. exit would only terminate one thread; exit_group terminates the entire process.
read, write: I/O Syscalls
src/runtime/sys_linux_amd64.s, lines 93-109:
// src/runtime/sys_linux_amd64.s, lines 93-109
TEXT runtime·write1(SB),NOSPLIT,$0-28
MOVQ fd+0(FP), DI
MOVQ p+8(FP), SI
MOVL n+16(FP), DX
MOVL $SYS_write, AX
SYSCALL
MOVL AX, ret+24(FP)
RET
TEXT runtime·read(SB),NOSPLIT,$0-28
MOVL fd+0(FP), DI
MOVQ p+8(FP), SI
MOVL n+16(FP), DX
MOVL $SYS_read, AX
SYSCALL
MOVL AX, ret+24(FP)
RET
The pattern is always the same:
- Load Go function arguments into the Linux ABI registers (
DI,SI,DX,R10,R8,R9) - Load the syscall number into
AX - Execute
SYSCALL - Store the return value from
AXback into the Go return slot on the stack
open: Error Handling Pattern
src/runtime/sys_linux_amd64.s, lines 69-81:
// src/runtime/sys_linux_amd64.s, lines 69-81
TEXT runtime·open(SB),NOSPLIT,$0-20
// This uses openat instead of open, because Android O blocks open.
MOVL $AT_FDCWD, DI // AT_FDCWD, so this acts like open
MOVQ name+0(FP), SI
MOVL mode+8(FP), DX
MOVL perm+12(FP), R10
MOVL $SYS_openat, AX
SYSCALL
CMPQ AX, $0xfffffffffffff001
JLS 2(PC)
MOVL $-1, AX
MOVL AX, ret+16(FP)
RET
The error-checking pattern at lines 77-79 is interesting:
CMPQ AX, $0xfffffffffffff001checks if the return value is in the error range. On Linux, syscall returns in the range[-4095, -1]indicate errors (negative errno).0xfffffffffffff001is-4095in unsigned 64-bit representation.JLS 2(PC)– if the result is below (unsigned) this threshold, it’s a success; skip the error fixup.- On error, the function returns
-1rather than the raw negative errno.
Also note that Go uses openat with AT_FDCWD instead of open, because Android O blocked the open syscall in its seccomp filter.
3. macOS: libc Trampolines (10 min)
On macOS (Darwin), Go cannot make syscalls directly. Apple does not guarantee a stable syscall ABI – the syscall numbers and conventions can change between macOS versions. Instead, Go must call through libc.
Since macOS on Apple Silicon uses ARM64, the assembly syntax below differs from the x86-64 we saw above. Key differences: arguments go in registers R0-R7, BL is the branch-and-link (call) instruction, and RSP is the stack pointer.
The trampoline pattern is visible in src/runtime/sys_darwin_arm64.s:
src/runtime/sys_darwin_arm64.s, lines 21-29:
// src/runtime/sys_darwin_arm64.s, lines 21-29
TEXT runtime·open_trampoline(SB),NOSPLIT,$0
SUB $16, RSP
MOVW 8(R0), R1 // arg 2 flags
MOVW 12(R0), R2 // arg 3 mode
MOVW R2, (RSP) // arg 3 is variadic, pass on stack
MOVD 0(R0), R0 // arg 1 pathname
BL libc_open(SB)
ADD $16, RSP
RET
Key differences from Linux:
- Indirection through libc:
BL libc_open(SB)calls the C library’sopen()function, which internally makes the actual syscall. The runtime never issues theSVCinstruction directly. - Argument marshalling: Arguments arrive packed in a struct pointed to by
R0. The trampoline unpacks them into the ARM64 calling convention registers (R0,R1,R2, etc.). - Variadic argument handling:
open()in C is variadic (themodeargument is only present withO_CREAT). On ARM64, variadic arguments go on the stack, not in registers – hence theMOVW R2, (RSP).
write and read trampolines with error handling
src/runtime/sys_darwin_arm64.s, lines 36-62:
// src/runtime/sys_darwin_arm64.s, lines 36-62
TEXT runtime·write_trampoline(SB),NOSPLIT,$0
MOVD 8(R0), R1 // arg 2 buf
MOVW 16(R0), R2 // arg 3 count
MOVW 0(R0), R0 // arg 1 fd
BL libc_write(SB)
MOVD $-1, R1
CMP R0, R1
BNE noerr
BL libc_error(SB)
MOVW (R0), R0
NEG R0, R0 // caller expects negative errno value
noerr:
RET
TEXT runtime·read_trampoline(SB),NOSPLIT,$0
MOVD 8(R0), R1 // arg 2 buf
MOVW 16(R0), R2 // arg 3 count
MOVW 0(R0), R0 // arg 1 fd
BL libc_read(SB)
MOVD $-1, R1
CMP R0, R1
BNE noerr
BL libc_error(SB)
MOVW (R0), R0
NEG R0, R0 // caller expects negative errno value
noerr:
RET
On macOS, libc functions return -1 on error and set errno (a thread-local variable). The trampoline must:
- Check if the return is
-1 - If so, call
libc_error(SB)to get a pointer to the thread-localerrno - Load and negate the errno value (the Go runtime expects negative errno, same as Linux kernel convention)
exit trampoline
src/runtime/sys_darwin_arm64.s, lines 72-77:
// src/runtime/sys_darwin_arm64.s, lines 72-77
TEXT runtime·exit_trampoline(SB),NOSPLIT|NOFRAME,$0
MOVW 0(R0), R0
BL libc_exit(SB)
MOVD $1234, R0
MOVD $1002, R1
MOVD R0, (R1) // fail hard
The crash at the end (writing to address 1002) is a safety net – if libc_exit somehow returns, the process crashes immediately rather than continuing in an undefined state.
Linux vs. macOS: Summary
| Aspect | Linux | macOS |
|---|---|---|
| Syscall method | Direct SYSCALL instruction | Call through libc (BL libc_*) |
| ABI stability | Syscall numbers are stable ABI | Only libc API is stable |
| Error reporting | Negative return in RAX | Return -1, check errno |
| Why? | Linux guarantees syscall ABI | Apple reserves right to change syscall numbers |
4. The syscall Package: Go Wrappers (10 min)
User code doesn’t call the runtime’s raw assembly directly. Instead, the syscall package provides Go-level wrappers.
The Syscall/RawSyscall split
From src/syscall/syscall_linux.go (lines 55-90):
src/syscall/syscall_linux.go, lines 55-57:
// src/syscall/syscall_linux.go, lines 55-57
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
return RawSyscall6(trap, a1, a2, a3, 0, 0, 0)
}
src/syscall/syscall_linux.go, lines 63-68:
// src/syscall/syscall_linux.go, lines 63-68
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
var errno uintptr
r1, r2, errno = linux.Syscall6(trap, a1, a2, a3, a4, a5, a6)
err = Errno(errno)
return
}
RawSyscall goes directly to the kernel without notifying the Go scheduler. This is dangerous – if the syscall blocks, the scheduler doesn’t know, and it can’t reuse the goroutine’s P.
The safe version notifies the scheduler:
src/syscall/syscall_linux.go, lines 73-90:
// src/syscall/syscall_linux.go, lines 73-90
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
runtime_entersyscall()
r1, r2, err = RawSyscall6(trap, a1, a2, a3, 0, 0, 0)
runtime_exitsyscall()
return
}
The critical difference: Syscall wraps the raw call with runtime_entersyscall() / runtime_exitsyscall(). These are linked to the runtime’s entersyscall and exitsyscall functions:
src/syscall/syscall_linux.go, lines 29-33:
// src/syscall/syscall_linux.go, lines 29-33
//go:linkname runtime_entersyscall runtime.entersyscall
func runtime_entersyscall()
//go:linkname runtime_exitsyscall runtime.exitsyscall
func runtime_exitsyscall()
When to use which?
Syscall: For potentially blocking calls (read,write,open,connect). Notifies the scheduler so the P can be reassigned.RawSyscall: For calls guaranteed to be fast and non-blocking (getpid,getuid). Avoids the overhead of scheduler notification.
Using RawSyscall for a blocking call is a bug: it can cause the entire program to stall because the scheduler thinks the M’s P is still in use.
Errno: Error Handling
From src/syscall/syscall_unix.go (lines 94-108):
src/syscall/syscall_unix.go, lines 94-108:
// src/syscall/syscall_unix.go, lines 94-108
// An Errno is an unsigned number describing an error condition.
// It implements the error interface. The zero Errno is by convention
// a non-error, so code to convert from Errno to error should use:
//
// err = nil
// if errno != 0 {
// err = errno
// }
//
// Errno values can be tested against error values using [errors.Is].
// For example:
//
// _, _, err := syscall.Syscall(...)
// if errors.Is(err, fs.ErrNotExist) ...
type Errno uintptr
Errno is just a uintptr that implements the error interface. It maps to kernel errno values (ENOENT = 2, EACCES = 13, etc.).
The Errno.Is() method (lines 120-131) maps errno values to Go’s standard error sentinels:
src/syscall/syscall_unix.go, lines 120-131:
// src/syscall/syscall_unix.go, lines 120-131
func (e Errno) Is(target error) bool {
switch target {
case oserror.ErrPermission:
return e == EACCES || e == EPERM
case oserror.ErrExist:
return e == EEXIST || e == ENOTEMPTY
case oserror.ErrNotExist:
return e == ENOENT
case errorspkg.ErrUnsupported:
return e == ENOSYS || e == ENOTSUP || e == EOPNOTSUPP
}
return false
}
Higher-level wrappers: Read and Write
The syscall package also provides higher-level Go functions. For example:
src/syscall/syscall_unix.go, lines 182-199:
// src/syscall/syscall_unix.go, lines 182-199
func Read(fd int, p []byte) (n int, err error) {
n, err = read(fd, p)
if race.Enabled {
if n > 0 {
race.WriteRange(unsafe.Pointer(&p[0]), n)
}
if err == nil {
race.Acquire(unsafe.Pointer(&ioSync))
}
}
if msan.Enabled && n > 0 {
msan.Write(unsafe.Pointer(&p[0]), uintptr(n))
}
if asan.Enabled && n > 0 {
asan.Write(unsafe.Pointer(&p[0]), uintptr(n))
}
return
}
Note how the wrapper integrates with Go’s race detector, memory sanitizer (MSan), and address sanitizer (ASan). These instrumentation hooks are invisible to normal users but critical for development tooling.
5. VDSO: Avoiding the Kernel (5 min)
Some syscalls are so frequent that even the overhead of the SYSCALL instruction is too much. The most common example is clock_gettime – called every time the runtime needs a timestamp (scheduling decisions, timer management, time.Now()).
Linux provides the vDSO (virtual Dynamic Shared Object): a small shared library that the kernel maps into every process’s address space. It contains user-space implementations of certain syscalls that can execute without trapping into the kernel.
The Go runtime looks up the vDSO symbol at startup (src/runtime/vdso_linux.go) and uses it as a fast path:
src/runtime/sys_linux_amd64.s, lines 222-298:
// src/runtime/sys_linux_amd64.s, lines 222-298
// func nanotime1() int64
TEXT runtime·nanotime1(SB),NOSPLIT,$16-8
// We don't know how much stack space the VDSO code will need,
// so switch to g0.
...
MOVQ g_m(R14), BX
// Set vdsoPC and vdsoSP for SIGPROF traceback.
...
MOVL $1, DI // CLOCK_MONOTONIC
LEAQ 0(SP), SI
MOVQ runtime·vdsoClockgettimeSym(SB), AX
CMPQ AX, $0
JEQ fallback
CALL AX // call vDSO function (user-space, no kernel trap!)
ret:
MOVQ 0(SP), AX // sec
MOVQ 8(SP), DX // nsec
...
IMULQ $1000000000, AX
ADDQ DX, AX
MOVQ AX, ret+0(FP)
RET
fallback:
MOVQ $SYS_clock_gettime, AX
SYSCALL // fall back to real syscall if vDSO unavailable
JMP ret
The key optimization:
- Check if
runtime.vdsoClockgettimeSymis non-nil (the vDSO symbol was found at startup) - If yes,
CALL AX– this is a normal function call, no kernel transition - If no, fall back to
SYSCALLwithSYS_clock_gettime
The vDSO implementation works by having the kernel map a read-only page of time data into user space, updated by the kernel on every timer tick. The user-space code just reads from this page. This turns a ~100ns syscall into a ~20ns function call.
Note the comment: “We don’t know how much stack space the VDSO code will need, so switch to g0.” The runtime switches to the g0 stack (the OS-thread-sized stack) before calling vDSO code, because the vDSO is essentially kernel-provided C code that assumes a normal-sized stack – not the small goroutine stack (which starts at 2 KB).
6. entersyscall/exitsyscall: The Scheduler Handoff (10 min)
This is the most OS-like aspect of Go’s syscall handling. When a goroutine enters a potentially blocking syscall, the runtime must decide what to do with the goroutine’s P (logical processor).
The Problem
Consider: goroutine G7 is running on M3 with P2. G7 calls read() on a network socket. This could block for milliseconds or even seconds. If P2 stays attached to M3 during the block, no other goroutine can use that logical processor – the system loses 1/GOMAXPROCS of its scheduling capacity.
entersyscall: Preparing for the Block
src/runtime/proc.go, lines 4627-4716:
// src/runtime/proc.go, lines 4627-4716
func reentersyscall(pc, sp, bp uintptr) {
gp := getg()
// Disable preemption because during this function g is in Gsyscall status,
// but can have inconsistent g->sched, do not let GC observe it.
gp.m.locks++
// Entersyscall must not call any function that might split/grow the stack.
// Catch calls that might, by replacing the stack guard with something that
// will trip any stack check and leaving a flag to tell newstack to die.
gp.stackguard0 = stackPreempt
gp.throwsplit = true
// Copy the syscalltick over so we can identify if the P got stolen later.
gp.m.syscalltick = gp.m.p.ptr().syscalltick
...
// Leave SP around for GC and traceback.
save(pc, sp, bp)
gp.syscallsp = sp
gp.syscallpc = pc
gp.syscallbp = bp
...
// Transition to _Gsyscall.
// As soon as we switch to _Gsyscall, we are in danger of losing our P.
if gp.bubble != nil || !gp.atomicstatus.CompareAndSwap(_Grunning, _Gsyscall) {
casgstatus(gp, _Grunning, _Gsyscall)
}
...
}
Key steps:
- Save the goroutine’s registers (PC, SP, BP) so the GC and traceback can still walk the stack
- Record
syscalltickso the M can detect later if its P was stolen - Transition G status from
_Grunningto_Gsyscallvia atomic CAS - Do NOT release the P immediately – the optimistic assumption is that the syscall will return quickly
The P remains attached to the M, but the status change to _Gsyscall signals to the rest of the runtime that this P is available for stealing if necessary.
exitsyscall: Coming Back
src/runtime/proc.go, lines 4883-4953:
// src/runtime/proc.go, lines 4883-4953
func exitsyscall() {
gp := getg()
gp.m.locks++ // see comment in entersyscall
if sys.GetCallerSP() > gp.syscallsp {
throw("exitsyscall: syscall frame is no longer valid")
}
gp.waitsince = 0
...
// Check if we still have our P (fast path).
oldp := gp.m.oldp.ptr()
gp.m.oldp.set(nil)
pp := gp.m.p.ptr()
if pp != nil {
// Fast path: we still have our P.
// Transition _Gsyscall -> _Grunning and continue.
if gp.bubble != nil || !gp.atomicstatus.CompareAndSwap(_Gsyscall, _Grunning) {
casgstatus(gp, _Gsyscall, _Grunning)
}
...
} else {
// Slow path: we lost our P. Try to get another one.
// If no P available, the goroutine goes _Gsyscall -> _Grunnable
// and is placed on the global run queue.
systemstack(func() {
if pp := exitsyscallTryGetP(oldp); pp != nil {
// Got a P, install it and continue.
...
} else {
// No P available. Park this M and put G on global run queue.
exitsyscallNoP(gp)
}
})
}
...
}
Two paths:
-
Fast path (common case): The P is still attached. The syscall was fast enough that
sysmondidn’t steal the P. Transition back to_Grunningand continue executing. -
Slow path: The P was stolen (by
sysmon’sretake()function). The M must either acquire a different idle P or park the goroutine on the global run queue and park the M.
sysmon retake: Stealing Ps from Slow Syscalls
The sysmon thread periodically checks for Ps that are stuck in syscalls:
src/runtime/proc.go, lines 6630-6652:
// src/runtime/proc.go, lines 6630-6652
func retake(now int64) uint32 {
n := 0
lock(&allpLock)
for i := 0; i < len(allp); i++ {
pp := allp[i]
if pp == nil || atomic.Load(&pp.status) != _Prunning {
continue
}
pd := &pp.sysmontick
// Preempt G if it's running on the same schedtick for
// too long. This could be from a single long-running
// goroutine or a sequence of goroutines run via
// runnext, which share a single schedtick time slice.
schedt := int64(pp.schedtick)
if int64(pd.schedtick) != schedt {
pd.schedtick = uint32(schedt)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
preemptone(pp)
// If pp is in a syscall, preemptone doesn't work.
// The goroutine nor the thread can respond to a
// preemption request because they're not in Go code,
// so we need to take the P ourselves.
sysretake = true
}
...
}
}
sysmon checks the syscalltick to see how long the P has been in a syscall. If it’s been too long (the threshold is 10ms, defined by forcePreemptNS), sysmon steals the P and hands it to another M that has goroutines waiting to run.
The Full Lifecycle
Goroutine G7 on M3 with P2:
1. G7 calls syscall.Read()
→ Syscall() calls runtime_entersyscall()
→ G7 status: _Grunning → _Gsyscall
→ P2 stays attached to M3 (optimistic)
→ Raw syscall to kernel
2a. FAST: Syscall returns quickly
→ runtime_exitsyscall()
→ P2 still attached? Yes!
→ G7 status: _Gsyscall → _Grunning
→ Continue executing
2b. SLOW: Syscall blocks for a while
→ sysmon's retake() notices P2 stuck in syscall
→ sysmon steals P2, hands it to idle M (or wakes one)
→ Other goroutines can now run on P2
→ Eventually, kernel returns from syscall
→ runtime_exitsyscall()
→ P2 gone! Try to get any idle P.
→ If no P available, park G7 on global run queue, park M3
This design is elegant: the common case (fast syscall) has minimal overhead (just a status CAS), while the slow case ensures the system doesn’t lose scheduling capacity.
Summary
| Topic | Key Insight |
|---|---|
| Syscall mechanism | SYSCALL instruction on Linux; libc calls on macOS |
| Direct vs. libc | Linux syscall ABI is stable; macOS’s is not |
| Syscall vs. RawSyscall | Syscall notifies scheduler; RawSyscall does not |
| Errno | Just a uintptr implementing error; kernel returns negative values |
| VDSO | Kernel maps fast implementations into user space for time-related calls |
| entersyscall | Saves registers, transitions G to _Gsyscall, keeps P optimistically |
| exitsyscall | Fast path: P still there. Slow path: acquire new P or park. |
| sysmon retake | Steals Ps from goroutines stuck in long syscalls |
Exercises
-
Trace a syscall: Write a Go program that reads from a file. Use
strace -f -e trace=read(Linux) ordtruss(macOS) to observe the actual system calls. Compare the syscall arguments with what you see in the assembly. -
Syscall vs. RawSyscall: Write a program that makes a slow syscall (e.g.,
readfrom a pipe that takes 1 second to produce data) using bothsyscall.Syscallandsyscall.RawSyscall. WithGOMAXPROCS=1and a second goroutine printing to stdout, observe the difference in behavior. -
VDSO experiment: Write a tight loop calling
time.Now()one million times and measure elapsed time. Compare this with a tight loop callingsyscall.Syscall(SYS_clock_gettime, ...). How much faster is the vDSO path? -
Read the entersyscall code: Open
src/runtime/proc.goand readreentersyscall(line 4627) throughexitsyscall(line 5025). Draw a state diagram showing all the possible transitions for G status, P ownership, and M activity during a syscall.
Further Reading
- Linux syscall table:
arch/x86/entry/syscalls/syscall_64.tblin the Linux kernel source - The Definitive Guide to Linux System Calls – covers
SYSCALL,SYSENTER, vDSO - Go source:
src/runtime/sys_linux_amd64.s– Linux syscall assembly - Go source:
src/runtime/sys_darwin_arm64.s– macOS trampoline assembly - Go source:
src/runtime/proc.go–entersyscall/exitsyscall
Module 2: System Calls (sys_linux_amd64.s, syscall package)
Comprehension Check
Question 1 (Code Reading)
Examine this snippet from runtime/sys_linux_amd64.s:
TEXT runtime·futex(SB),NOSPLIT,$0
MOVQ addr+0(FP), DI
MOVL op+8(FP), SI
MOVL val+12(FP), DX
MOVQ ts+16(FP), R10
MOVQ addr2+24(FP), R8
MOVL val3+32(FP), R9
MOVL $SYS_futex, AX
SYSCALL
MOVL AX, ret+40(FP)
RET
Explain what each MOVQ/MOVL instruction is doing and why the function uses NOSPLIT.
Answer
The MOV instructions are loading function arguments from the Go stack frame (accessed via FP, the frame pointer pseudo-register) into the specific CPU registers that the Linux system call ABI requires:
DI= first argument (addr) – the futex addressSI= second argument (op) – the futex operationDX= third argument (val) – the expected valueR10= fourth argument (ts) – timeout (replacesRCXwhichSYSCALLclobbers)R8= fifth argument (addr2)R9= sixth argument (val3)AX= the system call number (SYS_futex)
After SYSCALL, the return value in AX is stored back to the stack frame.
NOSPLIT means this function must not trigger a stack growth check. This is essential because:
- System call wrappers are called from contexts where stack growth would be unsafe (e.g., during scheduler operations on the system stack).
- The function is written in assembly and does not follow Go’s stack growth protocol.
- It must execute on the current stack without any possibility of calling
morestack.
Question 2 (Short Answer)
The Go runtime makes system calls directly via assembly rather than using the C library (libc). What are two advantages and one disadvantage of this approach?
Answer
Advantages:
- No cgo overhead: Calling into C requires saving/restoring different calling conventions, switching signal stacks, and potentially allocating a larger stack. Direct syscalls avoid all this overhead.
- Full control over thread state: The runtime needs precise control over signal masks, thread-local storage, and stack pointers. Going through libc would mean coordinating with libc’s internal state (e.g., libc’s
errno, signal handling, thread management via pthreads), which could conflict with the runtime’s own management.
Disadvantage:
- Portability burden: The runtime must maintain per-OS, per-architecture assembly files (
sys_linux_amd64.s,sys_darwin_arm64.s, etc.). Any kernel ABI changes must be tracked manually. libc provides a stable ABI that abstracts over kernel changes.
Other valid disadvantages: no automatic DNS resolution via nsswitch/NSS, no automatic compatibility with OS security policies that hook libc functions, difficulty with some system calls that libc wraps with retry logic.
Question 3 (True/False with Explanation)
True or False: When a goroutine makes a blocking system call (like reading from a file), the P (processor) remains attached to the M (OS thread) until the syscall returns.
Answer
It depends — mostly false. The P initially remains attached to the M when entersyscall() is called; the runtime optimistically assumes the syscall will return quickly. However, entersyscall() transitions the goroutine to _Gsyscall status, which signals to the rest of the runtime that this P is available for stealing. If the syscall takes too long (>10ms), the sysmon thread’s retake() function detaches the P from the blocked M and hands it to another M so that other goroutines on that P’s run queue can continue executing.
When the syscall returns, the goroutine calls exitsyscall(). If the P is still attached (the fast path), execution resumes immediately. If the P was stolen, the goroutine tries to acquire any idle P; if none are available, it is placed on the global run queue and the M parks itself.
This is one of the key reasons for the M/P separation in the GMP model.
Question 4 (What Would Happen If…)
What would happen if the Go runtime used blocking read() system calls for network I/O instead of non-blocking I/O with epoll/kqueue? Consider the impact on a web server handling 10,000 concurrent connections.
Answer
Each blocked network read would tie up an OS thread (M). With 10,000 concurrent connections:
- Thread explosion: The runtime would need ~10,000 OS threads. Each thread consumes kernel resources (kernel stack ~8KB, scheduling overhead, TLB entries, file descriptor table entries).
- Memory overhead: Even with Go’s small goroutine stacks, each M needs a system stack (typically 8MB of virtual address space reserved). 10,000 threads would reserve ~80GB of virtual address space.
- Scheduling overhead: The OS scheduler would need to manage 10,000 threads, causing significant context-switch overhead and cache pollution.
- P starvation: Each blocking syscall detaches the P from the M. With 10,000 concurrent syscalls and a default GOMAXPROCS of, say, 8, all 8 Ps would be continuously handed off. The overhead of thread parking/unparking and P handoffs would dominate.
By using non-blocking I/O with the network poller, the runtime parks goroutines in user space (essentially just saving a pointer in a pollDesc struct) and uses a single epoll_wait call to monitor all 10,000 file descriptors. Goroutines are woken only when their I/O is ready, requiring no additional OS threads.
Question 5 (Short Answer)
Explain the difference between the syscall package and the runtime package’s internal system call mechanisms. When would a Go developer use each?
Answer
runtime internal syscalls:
- Implemented in assembly (e.g.,
sys_linux_amd64.s) - Called via
runtime·syscallor direct assembly wrappers - Used only by the runtime itself for fundamental operations (futex, mmap, clone, sigaction, etc.)
- Called from the system stack (
systemstackorgo:nosplitfunctions) - Do not go through the
entersyscall/exitsyscallprotocol (the runtime manages this internally)
syscall package:
- A higher-level Go package that wraps system calls for user code
- Uses
runtime.entersyscall()andruntime.exitsyscall()to properly notify the scheduler - Provides a Go-friendly API with error returns as
errorvalues - Generated partially from tables (e.g.,
mksyscall.go) - User programs and the standard library use this for file I/O, networking, process management, etc.
A Go developer uses the syscall (or golang.org/x/sys/unix) package when they need to make system calls not exposed by higher-level packages. They would never directly call runtime internals.
Question 6 (Code Reading)
Consider this function from runtime/os_linux.go:
func futexsleep(addr *uint32, val uint32, ns int64) {
if ns < 0 {
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, nil, nil, 0)
return
}
var ts timespec
ts.setNsec(ns)
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, &ts, nil, 0)
}
Why does the function check the value of val atomically against *addr inside the kernel? What race condition would exist without this check?
Answer
The FUTEX_WAIT operation atomically checks that *addr == val and, only if true, puts the calling thread to sleep. This atomicity is critical to avoid a lost wakeup race:
- Thread A checks a condition variable and sees it should wait.
- Between thread A’s check and its call to sleep, thread B changes the value and calls
futexwake. - If the sleep were not atomic with the value check, thread A would go to sleep and never be woken (the wakeup was already sent and lost).
By having the kernel atomically verify *addr == val at the moment of sleeping, thread A will not sleep if the value has already changed (the futex call returns immediately with EAGAIN). The caller can then re-check the condition.
This is the same fundamental problem that pthread_cond_wait solves by requiring the mutex to be held and atomically releasing it when sleeping.
Module 8: Memory Management (60 min)
Background: Memory Management from Hardware to Runtime
Memory management is a problem that spans every layer of a computer system, from the hardware up to application code. At the bottom, the CPU’s memory management unit (MMU) translates virtual addresses to physical addresses using page tables – a mechanism first pioneered by the Atlas computer at the University of Manchester in 1962, which introduced the concept of “one-level storage” where programs could address more memory than physically existed. That idea became virtual memory, and by the 1970s it was universal. Modern systems use multi-level page tables (four or five levels on x86-64) to map a 48- or 57-bit virtual address space, with the TLB caching recent translations to avoid the cost of a full page table walk on every memory access. The page – typically 4 KB – is the fundamental unit of this translation, though huge pages (2 MB or 1 GB on x86) reduce TLB pressure for large working sets.
The operating system kernel manages the page tables and physical memory.
Linux uses a buddy allocator to manage physical page frames: it maintains
free lists of power-of-two-sized page blocks and splits or coalesces them as
needed, providing O(log n) allocation with low external fragmentation. On top of
the buddy allocator sits the SLUB allocator (the successor to the original
slab allocator designed by Jeff Bonwick at Sun in 1994), which carves pages into
fixed-size object caches for frequently allocated kernel structures like inodes,
dentries, and socket buffers. SLUB maintains per-CPU freelists to minimize lock
contention – a design principle we will see repeated at every level of the
stack. The kernel exposes memory to user space through system calls like mmap,
which creates virtual address mappings that are backed by physical pages lazily,
on first access (demand paging). Transparent Huge Pages (THP) allow the kernel
to automatically promote 4 KB pages to 2 MB huge pages, improving TLB
utilization for large allocations without requiring application changes.
For user-space programs, calling mmap for every allocation would be far too
expensive – each call requires a transition to kernel mode and manipulation of
page table entries. User-space allocators like dlmalloc (Doug Lea’s malloc,
the basis of glibc’s allocator), jemalloc (developed for FreeBSD and later
adopted by Facebook), tcmalloc (Google’s Thread-Caching Malloc), and
mimalloc (Microsoft Research’s compact allocator) all solve this by
requesting large chunks of memory from the OS and then sub-dividing them
efficiently. These allocators face a common set of challenges: minimizing
fragmentation (both internal fragmentation from rounding up to size classes and
external fragmentation from non-contiguous free blocks), reducing lock
contention in multi-threaded programs (typically through per-thread or per-CPU
caches), and maintaining good cache locality. tcmalloc’s key innovation was its
hierarchy of thread-local caches, central freelists, and a global page heap –
a design that directly inspired Go’s memory allocator.
Where manual allocators require the programmer to pair every allocation with an explicit free, garbage collection automates memory reclamation by identifying objects that are no longer reachable. The idea dates back to John McCarthy’s Lisp in 1960, which used a simple stop-the-world mark-and-sweep collector. Since then, GC research has produced a rich taxonomy of approaches: reference counting (tracking how many pointers refer to each object, used by CPython and Swift), generational collection (exploiting the observation that most objects die young, pioneered by David Ungar in 1984), copying/compacting collectors (which eliminate fragmentation by relocating live objects), and concurrent/incremental collectors (which minimize pause times by doing GC work alongside the application). Java’s ecosystem showcases this diversity: its G1 collector partitions the heap into regions for incremental collection, ZGC uses colored pointers and load barriers to achieve sub-millisecond pauses on multi-terabyte heaps, and Shenandoah performs concurrent compaction using Brooks forwarding pointers. On modern NUMA systems, allocators must also be topology-aware – allocating memory on the same NUMA node as the thread that will use it, since cross-node accesses can be 2-3x slower.
Go’s runtime sits at the intersection of these ideas. Its memory allocator
descends from tcmalloc but has diverged significantly, using a hierarchy of
per-P caches (mcache), per-size-class central lists (mcentral), and a global
heap (mheap) backed by mmap. Its garbage collector is a concurrent,
tri-color mark-and-sweep design – non-generational and non-compacting, but with
sub-millisecond STW pauses achieved through concurrent marking with a hybrid
write barrier. In this module, we trace an allocation from new(T) all the way
down to mmap, then examine how the garbage collector reclaims memory
concurrently with the running program.
1. Virtual Memory Review
Pages and page tables
Modern CPUs use virtual memory to provide each process with an isolated address space. The hardware translates virtual addresses to physical addresses using page tables, with a typical page size of 4 KB (though large/huge pages of 2 MB or 1 GB exist).
Key concepts:
- Virtual address space: each process sees a flat, contiguous address space.
- Page table: a per-process data structure (managed by the OS kernel) mapping virtual page numbers to physical frame numbers.
- TLB (Translation Lookaside Buffer): a hardware cache of recent page table entries. TLB misses trigger a page table walk.
- Page fault: when a virtual page has no valid mapping, the CPU traps to the kernel, which may allocate a physical frame, read from disk, or deliver a segfault.
mmap: Requesting memory from the OS
User-space programs request virtual memory from the kernel using mmap:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
With MAP_ANONYMOUS | MAP_PRIVATE, mmap creates a new anonymous mapping –
virtual pages backed by physical memory (lazily, on first access). The Go
runtime uses this as its sole interface to the OS for heap memory.
2. Go’s Memory Allocator Architecture
The allocator is documented at the top of malloc.go:
src/runtime/malloc.go lines 5-25:
// src/runtime/malloc.go, lines 5-25
// Memory allocator.
//
// This was originally based on tcmalloc, but has diverged quite a bit.
// http://goog-perftools.sourceforge.net/doc/tcmalloc.html
// The main allocator works in runs of pages.
// Small allocation sizes (up to and including 32 kB) are
// rounded to one of about 70 size classes, each of which
// has its own free set of objects of exactly that size.
// Any free page of memory can be split into a set of objects
// of one size class, which are then managed using a free bitmap.
//
// The allocator's data structures are:
//
// fixalloc: a free-list allocator for fixed-size off-heap objects,
// used to manage storage used by the allocator.
// mheap: the malloc heap, managed at page (8192-byte) granularity.
// mspan: a run of in-use pages managed by the mheap.
// mcentral: collects all spans of a given size class.
// mcache: a per-P cache of mspans with free space.
// mstats: allocation statistics.
The allocation hierarchy
src/runtime/malloc.go lines 27-47:
// src/runtime/malloc.go, lines 27-47
// Allocating a small object proceeds up a hierarchy of caches:
//
// 1. Round the size up to one of the small size classes
// and look in the corresponding mspan in this P's mcache.
// Scan the mspan's free bitmap to find a free slot.
// If there is a free slot, allocate it.
// This can all be done without acquiring a lock.
//
// 2. If the mspan has no free slots, obtain a new mspan
// from the mcentral's list of mspans of the required size
// class that have free space.
// Obtaining a whole span amortizes the cost of locking
// the mcentral.
//
// 3. If the mcentral's mspan list is empty, obtain a run
// of pages from the mheap to use for the mspan.
//
// 4. If the mheap is empty or has no page runs large enough,
// allocate a new group of pages (at least 1MB) from the
// operating system. Allocating a large run of pages
// amortizes the cost of talking to the operating system.
Deallocation (sweeping) hierarchy
src/runtime/malloc.go lines 49-63:
// src/runtime/malloc.go, lines 49-63
// Sweeping an mspan and freeing objects on it proceeds up a similar
// hierarchy:
//
// 1. If the mspan is being swept in response to allocation, it
// is returned to the mcache to satisfy the allocation.
//
// 2. Otherwise, if the mspan still has allocated objects in it,
// it is placed on the mcentral free list for the mspan's size
// class.
//
// 3. Otherwise, if all objects in the mspan are free, the mspan's
// pages are returned to the mheap and the mspan is now dead.
//
// Allocating and freeing a large object uses the mheap
// directly, bypassing the mcache and mcentral.
Diagram: Allocation hierarchy
Goroutine calls new(T)
|
v
mallocgc(size, typ, needzero)
|
+------ size == 0? -----> return &zerobase
|
+------ size < 16 bytes, no pointers? -----> Tiny allocator
|
+------ size <= 32 KB? -----> Small object path:
| |
| v
| mcache (per-P, no lock)
| | no free slot?
| v
| mcentral (per-size-class, locked)
| | no spans available?
| v
| mheap (global, locked)
| | no pages?
| v
| mmap(OS)
|
+------ size > 32 KB? -----> Large object path:
|
v
mheap directly -> mmap if needed
3. Virtual Memory Layout: Arenas
Go organizes its heap into arenas:
src/runtime/malloc.go lines 79-94:
// src/runtime/malloc.go, lines 79-94
// The heap consists of a set of arenas, which are 64MB on 64-bit and
// 4MB on 32-bit (heapArenaBytes). Each arena's start address is also
// aligned to the arena size.
//
// Each arena has an associated heapArena object that stores the
// metadata for that arena: the heap bitmap for all words in the arena
// and the span map for all pages in the arena. heapArena objects are
// themselves allocated off-heap.
//
// Since arenas are aligned, the address space can be viewed as a
// series of arena frames. The arena map (mheap_.arenas) maps from
// arena frame number to *heapArena, or nil for parts of the address
// space not backed by the Go heap. The arena map is structured as a
// two-level array consisting of a "L1" arena map and many "L2" arena
// maps; however, since arenas are large, on many architectures, the
// arena map consists of a single, large L2 map.
The two-level arena map allows the runtime to cover the entire 48-bit virtual address space without allocating metadata for unused regions:
src/runtime/mheap.go line 150:
// src/runtime/mheap.go, line 150
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
Each heapArena stores metadata for one 64 MB region:
src/runtime/mheap.go lines 268-338:
// src/runtime/mheap.go, lines 268-338
type heapArena struct {
_ sys.NotInHeap
// spans maps from virtual address page ID within this arena to *mspan.
// For allocated spans, their pages map to the span itself.
// For free spans, only the lowest and highest pages map to the span itself.
spans [pagesPerArena]*mspan
// pageInUse is a bitmap that indicates which spans are in
// state mSpanInUse.
pageInUse [pagesPerArena / 8]uint8
// pageMarks is a bitmap that indicates which spans have any
// marked objects on them.
pageMarks [pagesPerArena / 8]uint8
// zeroedBase marks the first byte of the first page in this
// arena which hasn't been used yet and is therefore already
// zero.
zeroedBase uintptr
}
OS connection: The arena system is Go’s user-space equivalent of a page table. Just as the OS kernel uses a multi-level page table to map virtual addresses to physical frames, Go uses a two-level arena map to map heap addresses to span metadata.
4. The mheap Struct: The Central Heap
The mheap is the single global heap structure:
src/runtime/mheap.go lines 64-264:
// src/runtime/mheap.go, lines 64-264
type mheap struct {
_ sys.NotInHeap
// lock must only be acquired on the system stack, otherwise a g
// could self-deadlock if its stack grows with the lock held.
lock mutex
pages pageAlloc // page allocation data structure
sweepgen uint32 // sweep generation, see comment in mspan
// allspans is a slice of all mspans ever created.
allspans []*mspan // all spans out there
// ...
// arenas is the heap arena map.
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// central free lists for small size classes.
central [numSpanClasses]struct {
mcentral mcentral
pad [...]byte // cache line padding
}
spanalloc fixalloc // allocator for span
cachealloc fixalloc // allocator for mcache
// ...
}
var mheap_ mheap
Key fields
| Field | Purpose |
|---|---|
lock | Global lock for heap operations. Must be acquired on system stack. |
pages | Page allocator – manages free/scavenged pages. |
sweepgen | Sweep generation counter (incremented by 2 each GC cycle). |
allspans | Master list of all mspan objects ever created. |
arenas | Two-level map from address to heapArena metadata. |
central | Array of mcentral structures, one per span class. Cache-line padded. |
spanalloc | Fixed-size allocator for mspan structs (off-heap). |
Design note: The central array is padded to cache line boundaries to avoid
false sharing between different size classes being accessed by different
processors.
5. Size Classes and mspan
Size classes
Go rounds small allocations up to one of approximately 70 size classes. For example: 8, 16, 24, 32, 48, 64, 80, 96, …, 32768 bytes. Each size class has a corresponding span class (which also encodes whether the span contains pointers or not – “noscan” vs. “scan”).
This eliminates external fragmentation for small objects: all objects within a span are the same size, so any free slot can satisfy any request of that class.
The mspan struct
An mspan represents a contiguous run of pages dedicated to one size class:
src/runtime/mheap.go lines 422-516:
// src/runtime/mheap.go, lines 422-516
type mspan struct {
_ sys.NotInHeap
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
list *mSpanList // For debugging.
startAddr uintptr // address of first byte of span aka s.base()
npages uintptr // number of pages in span
manualFreeList gclinkptr // list of free objects in mSpanManual spans
// freeindex is the slot index between 0 and nelems at which to begin
// scanning for the next free object in this span.
// Each allocation scans allocBits starting at freeindex until it
// encounters a 0 indicating a free object.
freeindex uint16
nelems uint16 // number of objects in the span
freeIndexForScan uint16
// Cache of the allocBits at freeindex. allocCache is shifted
// such that the lowest bit corresponds to the bit freeindex.
// allocCache holds the complement of allocBits, thus allowing
// ctz (count trailing zero) to use it directly.
allocCache uint64
// allocBits and gcmarkBits hold pointers to a span's mark and
// allocation bits.
allocBits *gcBits
gcmarkBits *gcBits
pinnerBits *gcBits // bitmap for pinned objects
// sweep generation:
// if sweepgen == h->sweepgen - 2, the span needs sweeping
// if sweepgen == h->sweepgen - 1, the span is currently being swept
// if sweepgen == h->sweepgen, the span is swept and ready to use
// if sweepgen == h->sweepgen + 1, the span was cached before sweep
// began and is still cached, and needs sweeping
// if sweepgen == h->sweepgen + 3, the span was swept and then cached
// and is still cached
sweepgen uint32
divMul uint32 // for divide by elemsize
allocCount uint16 // number of allocated objects
spanclass spanClass // size class and noscan (uint8)
state mSpanStateBox // mSpanInUse etc; accessed atomically
needzero uint8 // needs to be zeroed before allocation
allocCountBeforeCache uint16
elemsize uintptr // computed from sizeclass or from npages
limit uintptr // end of data in span
speciallock mutex // guards specials list
specials *special // linked list of special records
largeType *_type // malloc header for large objects
}
Allocation bitmaps
Each span has two key bitmaps:
allocBits: A bit per object slot. 1 = allocated, 0 = free.gcmarkBits: A bit per object slot. 1 = marked (reachable), 0 = unmarked.
After GC, the sweep phase replaces allocBits with gcmarkBits (objects that
were not marked are now free), and gcmarkBits is reset to all zeros for the
next cycle. This is a very efficient way to free dead objects in bulk.
The allocCache field is an optimization: it caches 64 bits of the complement
of allocBits starting at freeindex, allowing the allocator to find a free
slot using a single ctz (count trailing zeros) CPU instruction.
Span states
src/runtime/mheap.go lines 386-390:
// src/runtime/mheap.go, lines 386-390
const (
mSpanDead mSpanState = iota
mSpanInUse // allocated for garbage collected heap
mSpanManual // allocated for manual management (e.g., stack allocator)
)
Transitions between states are constrained by the GC phase to maintain correctness of concurrent marking.
6. mcache: Per-P Lock-Free Allocation
The mcache is the first level of the allocation hierarchy. There is one per P
(logical processor), so allocations from it require no locking:
src/runtime/mcache.go lines 14-66:
// src/runtime/mcache.go, lines 14-66
// Per-thread (in Go, per-P) cache for small objects.
// This includes a small object cache and local allocation stats.
// No locking needed because it is per-thread (per-P).
//
// mcaches are allocated from non-GC'd memory, so any heap pointers
// must be specially handled.
type mcache struct {
_ sys.NotInHeap
// The following members are accessed on every malloc,
// so they are grouped here for better caching.
nextSample int64 // trigger heap sample after allocating this many bytes
memProfRate int // cached mem profile rate
scanAlloc uintptr // bytes of scannable heap allocated
// Allocator cache for tiny objects w/o pointers.
// See "Tiny allocator" comment in malloc.go.
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// The rest is not accessed on every malloc.
// alloc contains spans to allocate from, indexed by spanClass.
alloc [numSpanClasses]*mspan
stackcache [_NumStackOrders]stackfreelist
// flushGen indicates the sweepgen during which this mcache
// was last flushed.
flushGen atomic.Uint32
}
How mcache allocation works
- Look up
mcache.alloc[spanClass]to get the currentmspanfor this size class. - Use
allocCache+ctzto find a free slot in constant time. - If no free slots, call
mcache.nextFree()which gets a new span frommcentral.
The alloc array has numSpanClasses entries (approximately 140: ~70 size
classes x 2 for scan/noscan). Each entry points to an mspan with free slots.
OS connection: This per-CPU caching strategy is the same principle used in Linux’s SLAB/SLUB allocator, which maintains per-CPU freelists to avoid lock contention on the global heap.
7. The Tiny Allocator
For very small allocations (< 16 bytes) that do not contain pointers, Go uses a special tiny allocator that packs multiple objects into a single 16-byte block:
src/runtime/malloc.go lines 1270-1298:
// src/runtime/malloc.go, lines 1270-1298
// Tiny allocator.
//
// Tiny allocator combines several tiny allocation requests
// into a single memory block. The resulting memory block
// is freed when all subobjects are unreachable. The subobjects
// must be noscan (don't have pointers), this ensures that
// the amount of potentially wasted memory is bounded.
//
// Size of the memory block used for combining (maxTinySize) is tunable.
// Current setting is 16 bytes, which relates to 2x worst case memory
// wastage (when all but one subobjects are unreachable).
// 8 bytes would result in no wastage at all, but provides less
// opportunities for combining.
// 32 bytes provides more opportunities for combining,
// but can lead to 4x worst case wastage.
// The best case winning is 8x regardless of block size.
//
// Objects obtained from tiny allocator must not be freed explicitly.
// So when an object will be freed explicitly, we ensure that
// its size >= maxTinySize.
//
// SetFinalizer has a special case for objects potentially coming
// from tiny allocator, it such case it allows to set finalizers
// for an inner byte of a memory block.
//
// The main targets of tiny allocator are small strings and
// standalone escaping variables. On a json benchmark
// the allocator reduces number of allocations by ~12% and
// reduces heap size by ~20%.
Implementation
src/runtime/malloc.go lines 1299-1332:
// src/runtime/malloc.go, lines 1299-1332
c := getMCache(mp)
off := c.tinyoffset
// Align tiny pointer for required (conservative) alignment.
if size&7 == 0 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
if off+size <= maxTinySize && c.tiny != 0 {
// The object fits into existing tiny block.
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x, 0
}
// Allocate a new maxTinySize block.
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(tinySpanClass)
}
The tiny allocator maintains a current 16-byte block (c.tiny) and bumps a
pointer (c.tinyoffset) within it. When the block is full, a new 16-byte slot
is obtained from the regular span allocator.
Trade-off: The entire 16-byte block cannot be freed until all sub-objects within it become unreachable. This trades a small amount of memory waste for a large reduction in allocation overhead.
8. mallocgc: The Central Allocation Function
Every heap allocation in Go flows through mallocgc:
src/runtime/malloc.go lines 1119-1129:
// src/runtime/malloc.go, lines 1119-1129
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if doubleCheckMalloc {
if gcphase == _GCmarktermination {
throw("mallocgc called with gcphase == _GCmarktermination")
}
}
// Short-circuit zero-sized allocation requests.
if size == 0 {
return unsafe.Pointer(&zerobase)
}
Allocation paths
src/runtime/malloc.go lines 1186-1209:
// src/runtime/malloc.go, lines 1186-1209
if size <= maxSmallSize-gc.MallocHeaderSize {
if typ == nil || !typ.Pointers() {
// tiny allocations might be kept alive by other co-located values.
gp := getg()
if size < maxTinySize && gp.secret == 0 {
x, elemsize = mallocgcTiny(size, typ)
} else {
x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
}
} else {
if !needzero {
throw("objects with pointers must be zeroed")
}
x, elemsize = mallocgcSmallScanHeader(size, typ)
}
} else {
x, elemsize = mallocgcLarge(size, typ, needzero)
}
Three paths:
- Tiny (
size < 16, no pointers): bump allocator within 16-byte blocks. - Small (
size <= 32 KB): round to size class, allocate from mcache span. - Large (
size > 32 KB): allocate directly from mheap.
GC assist
src/runtime/malloc.go lines 1162-1166:
// src/runtime/malloc.go, lines 1162-1166
// Assist the GC if needed. (On the reuse path, we currently compensate for this;
// changes here might require changes there.)
if gcBlackenEnabled != 0 {
deductAssistCredit(size)
}
Before allocating, the goroutine may be required to do GC marking work proportional to the amount it is allocating. This is the GC assist mechanism: it ensures that goroutines that allocate heavily also contribute to GC work, preventing the collector from falling behind.
OS connection: GC assist is analogous to proportional-share scheduling – threads that consume more resources (memory) must contribute more work (marking) to maintain system-wide progress.
9. Lazy Zeroing
src/runtime/malloc.go lines 65-75:
// src/runtime/malloc.go, lines 65-75
// If mspan.needzero is false, then free object slots in the mspan are
// already zeroed. Otherwise if needzero is true, objects are zeroed as
// they are allocated. There are various benefits to delaying zeroing
// this way:
//
// 1. Stack frame allocation can avoid zeroing altogether.
//
// 2. It exhibits better temporal locality, since the program is
// probably about to write to the memory.
//
// 3. We don't zero pages that never get reused.
Rather than zeroing memory when it is freed, Go zeros memory when it is
allocated (if needed). Pages obtained fresh from the OS via mmap are
guaranteed to be zeroed by the kernel, so the runtime tracks whether a span’s
pages have been written to (needzero). This avoids redundant zeroing and
improves cache behavior.
OS connection: This is a direct consequence of the OS’s lazy page
allocation: mmap returns virtual pages that are mapped to the zero page until
first write (copy-on-write). The runtime cooperates with this by tracking which
pages are “still clean.”
10. Garbage Collection Overview
The Go garbage collector is described at the top of src/runtime/mgc.go lines 5-10:
// src/runtime/mgc.go, lines 5-10
// Garbage collector (GC).
//
// The GC runs concurrently with mutator threads, is type accurate (aka
// precise), allows multiple GC threads to run in parallel. It is a
// concurrent mark and sweep that uses a write barrier. It is
// non-generational and non-compacting. Allocation is done using size
// segregated per P allocation areas to minimize fragmentation while
// eliminating locks in the common case.
The Four Phases
src/runtime/mgc.go lines 24-82:
// src/runtime/mgc.go, lines 24-82
// 1. GC performs sweep termination.
// a. Stop the world. This causes all Ps to reach a GC safe-point.
// b. Sweep any unswept spans.
// 2. GC performs the mark phase.
// a. Prepare for the mark phase by setting gcphase to _GCmark,
// enabling the write barrier, enabling mutator assists, and
// enqueueing root mark jobs. No objects may be scanned until
// all Ps have enabled the write barrier, which is accomplished
// using STW.
// b. Start the world. From this point, GC work is done by mark
// workers started by the scheduler and by assists performed as
// part of allocation.
// c. GC performs root marking jobs. This includes scanning all
// stacks, shading all globals, and shading any heap pointers in
// off-heap runtime data structures.
// d. GC drains the work queue of grey objects, scanning each grey
// object to black and shading all pointers found in the object.
// e. Because GC work is spread across local caches, GC uses a
// distributed termination algorithm to detect when there are no
// more root marking jobs or grey objects.
// 3. GC performs mark termination.
// a. Stop the world.
// b. Set gcphase to _GCmarktermination, and disable workers and
// assists.
// c. Perform housekeeping like flushing mcaches.
// 4. GC performs the sweep phase.
// a. Prepare for the sweep phase by setting gcphase to _GCoff,
// setting up sweep state and disabling the write barrier.
// b. Start the world. From this point on, newly allocated objects
// are white, and allocating sweeps spans before use if necessary.
// c. GC does concurrent sweeping in the background and in response
// to allocation.
Phase diagram
[Normal execution -- gcphase = _GCoff, sweep concurrent]
|
v (GC trigger: heap growth exceeds GOGC target)
+---------+
| Phase 1 | Sweep Termination (STW)
| STW | - All Ps reach safe-point
| | - Sweep remaining unswept spans
+---------+
|
v
+---------+
| Phase 2 | Mark (CONCURRENT)
| | - Write barrier enabled
| | - Mark workers scan roots and heap
| | - Mutator assists: allocators do marking work
| | - Tri-color invariant maintained
+---------+
|
v
+---------+
| Phase 3 | Mark Termination (STW)
| STW | - Disable workers
| | - Flush mcaches
+---------+
|
v
+---------+
| Phase 4 | Sweep (CONCURRENT)
| | - gcphase = _GCoff
| | - Write barrier disabled
| | - Spans swept lazily on allocation or by background sweeper
+---------+
|
v
[Normal execution continues until next GC trigger]
Stop-the-world (STW) pauses
Only phases 1 and 3 require stopping the world. These are typically very short (sub-millisecond on modern Go). The bulk of the work – marking and sweeping – happens concurrently with the application.
11. Tri-Color Marking
Go uses the tri-color abstraction for its concurrent mark phase:
- White: Not yet seen. Presumed garbage at end of marking.
- Grey: Seen but not yet scanned. In the work queue.
- Black: Scanned. All pointers from this object have been traced.
The tri-color invariant
Strong tri-color invariant: No black object may point to a white object.
This invariant must be maintained even as the mutator (application code) runs concurrently with the marker. Without it, the marker might miss reachable objects.
Write barriers
When the mutator writes a pointer during the mark phase, a write barrier executes. Go uses a hybrid write barrier (Dijkstra + Yuasa) that shades both the overwritten pointer and the new pointer value:
From mgc.go, line 42:
The write barrier shades both the overwritten pointer and the new pointer value for any pointer writes.
This means:
- When
*p = qis executed during GC, both the old value of*pand the new valueqare marked grey. - This prevents both the “lost update” problem (Dijkstra barrier) and the “premature free” problem (Yuasa barrier).
OS connection: Write barriers are conceptually similar to how OS page table dirty bits track writes for copy-on-write or page-out decisions. Both intercept writes to maintain metadata about memory state. In the GC case, the barrier is implemented in software by the compiler inserting barrier code before pointer writes.
12. Concurrent Sweep
src/runtime/mgc.go lines 84-95:
// src/runtime/mgc.go, lines 84-95
// Concurrent sweep.
//
// The sweep phase proceeds concurrently with normal program execution.
// The heap is swept span-by-span both lazily (when a goroutine needs
// another span) and concurrently in a background goroutine (this helps
// programs that are not CPU bound).
//
// To avoid requesting more OS memory while there are unswept spans,
// when a goroutine needs another span, it first attempts to reclaim
// that much memory by sweeping.
Sweeping is the process of examining each span’s gcmarkBits and freeing
unmarked objects. After sweeping, the allocBits for the span are replaced with
the gcmarkBits (so marked objects become the new “allocated” set), and
gcmarkBits are zeroed for the next cycle.
Two mechanisms drive sweeping:
- Background sweeper: A dedicated goroutine that sweeps spans one-by-one.
- Lazy sweeping: When a goroutine needs a new span, it sweeps spans of the required size class first to try to reclaim memory before asking the heap.
The sweepgen field in mspan tracks the sweep state:
src/runtime/mheap.go lines 494-500:
// src/runtime/mheap.go, lines 494-500
// sweep generation:
// if sweepgen == h->sweepgen - 2, the span needs sweeping
// if sweepgen == h->sweepgen - 1, the span is currently being swept
// if sweepgen == h->sweepgen, the span is swept and ready to use
// if sweepgen == h->sweepgen + 1, the span was cached before sweep
// began and is still cached, and needs sweeping
// if sweepgen == h->sweepgen + 3, the span was swept and then cached
// and is still cached
// h->sweepgen is incremented by 2 after every GC
13. GC Triggers and Pacing
src/runtime/mgc.go lines 112-118:
// src/runtime/mgc.go, lines 112-118
// GC rate.
// Next GC is after we've allocated an extra amount of memory proportional to
// the amount already in use. The proportion is controlled by GOGC environment
// variable (100 by default). If GOGC=100 and we're using 4M, we'll GC again
// when we get to 8M (this mark is computed by the gcController.heapGoal
// method). This keeps the GC cost in linear proportion to the allocation
// cost. Adjusting GOGC just changes the linear constant
// (and also the amount of extra memory used).
The GC pacer maintains a heap goal: the target heap size at which the next
GC cycle should complete. With GOGC=100 (default), the goal is 2x the live
heap from the previous cycle. The pacer adjusts the number of GC workers and
mutator assists to hit this target.
GOMEMLIMIT (added in Go 1.19) provides a soft memory limit. If set, the GC will trigger more aggressively when approaching the limit, even if the GOGC-based trigger hasn’t fired.
OS connection: GC pacing is analogous to OS memory management policies like the Linux OOM killer threshold or the page replacement daemon (kswapd) that wakes up when free memory drops below a threshold. Both systems try to maintain a target amount of free memory relative to total usage.
14. Summary: The Complete Picture
User code: x := new(MyStruct)
|
v
Compiler: mallocgc(sizeof(MyStruct), type, true)
|
v
+-- Tiny? (<16B, no ptrs) --> bump pointer in 16B block (per-P, no lock)
|
+-- Small? (<=32KB) --> mcache.alloc[sizeClass] (per-P, no lock)
| | empty?
| v
| mcentral (per-size-class, locked)
| | empty?
| v
| mheap (global, locked)
| | no pages?
| v
| sysAlloc -> mmap(2) (kernel)
|
+-- Large? (>32KB) --> mheap directly
| no pages?
v
sysAlloc -> mmap(2) (kernel)
[GC reclaims memory concurrently]
mark phase: trace reachable objects (grey -> black)
sweep phase: free unreachable objects (swap allocBits/gcmarkBits)
Discussion Questions
-
Why does Go use per-P allocation caches (
mcache) instead of per-goroutine caches? Consider the number of goroutines vs. the number of Ps. -
The tiny allocator packs multiple objects into one 16-byte block. What pathological case could this create? How does the “noscan” requirement mitigate the damage?
-
Why does Go’s GC need two STW pauses (sweep termination and mark termination) instead of just one? What would go wrong with a single STW?
-
Compare Go’s size-class allocator with the Linux kernel’s SLAB/SLUB allocator. What structural similarities do you see? What are the key differences?
-
The
mspan.sweepgenfield uses a generation counter rather than a boolean. Why is a generation counter necessary for concurrent sweeping? -
How does lazy zeroing (
needzero) interact withmmap’s guarantee that new pages are zero-filled? What would happen if the runtime eagerly zeroed all freed memory?
Further Reading
- Source files:
/Users/tchajed/sw/go/src/runtime/malloc.go– allocator overview andmallocgc/Users/tchajed/sw/go/src/runtime/mheap.go–mheap,mspan,heapArenastructs/Users/tchajed/sw/go/src/runtime/mcache.go– per-P cache/Users/tchajed/sw/go/src/runtime/mgc.go– GC algorithm documentation/Users/tchajed/sw/go/src/runtime/mcentral.go– central span lists/Users/tchajed/sw/go/src/runtime/sizeclasses.go– size class tables
- Bonwick, Jeff. “The Slab Allocator: An Object-Caching Kernel Memory Allocator.” USENIX Summer 1994.
- TCMalloc: http://goog-perftools.sourceforge.net/doc/tcmalloc.html
- Dijkstra, E.W. et al. “On-the-fly garbage collection: an exercise in cooperation.” CACM 1978.
Module 8: Memory Management (mcache, mcentral, mheap, GC phases)
Comprehension Check
Question 1 (Short Answer)
Describe the three-level memory allocation hierarchy in Go: mcache, mcentral, and mheap. For each level, explain what it caches, who accesses it, and what synchronization is needed.
Answer
mcache (per-P cache):
- Each P has its own
mcache(pointed to byp.mcache). - Caches
mspanobjects for each size class (approximately 70 size classes from 8 bytes to 32KB). - No synchronization needed – only the P’s current M accesses it, and only one goroutine runs on a P at a time. This makes small allocations lock-free.
mcentral (per-size-class central cache):
- One
mcentralper size class, stored inmheap. - When an
mcacheruns out of spans for a size class, it requests a new span from the correspondingmcentral. - Returns a span with free objects to the
mcache, and reclaims fully-swept empty spans. - Requires a lock (
mcentral.lock) since multiple Ps may request spans concurrently. However, contention is reduced because each size class has its own lock.
mheap (global page heap):
- The single global heap that manages large allocations and provides spans to
mcentral. - Manages pages (8KB each) obtained from the OS via
mmap. - Handles allocations larger than 32KB directly (large objects).
- Uses a page allocator (
pageAlloc) with a radix tree for finding free page runs. - Requires
mheap.lockfor most operations. This is the most contended lock in the allocator, which is why the mcache and mcentral layers exist to avoid it.
Flow: Goroutine allocates -> mcache (no lock) -> mcentral (size-class lock) -> mheap (global lock) -> OS (mmap).
Question 2 (True/False with Explanation)
True or False: The Go garbage collector stops all goroutines for the entire duration of a collection cycle.
Answer
False. Go uses a concurrent, tri-color mark-sweep collector. It only stops the world (STW) briefly at two points:
-
STW Phase 1 (Sweep Termination): All goroutines are paused to finish any remaining sweeping from the previous cycle, enable the write barrier, and transition to the mark phase. This typically takes <1ms.
-
Concurrent Mark: Goroutines resume execution. GC mark workers run concurrently alongside application goroutines, tracing reachable objects. The write barrier ensures new pointer writes are tracked. This phase does most of the work.
-
STW Phase 2 (Mark Termination): All goroutines pause again briefly to finalize marking, disable the write barrier, and transition to the sweep phase. Also typically <1ms.
-
Concurrent Sweep: Spans are swept (unreachable objects freed) lazily as they are needed for allocation, or by background sweep goroutines.
The vast majority of GC work happens concurrently. STW pauses are kept short – Go targets <1ms pauses, and in practice achieves sub-millisecond pauses for most workloads.
Question 3 (Code Reading)
Consider the mheap struct:
type mheap struct {
_ sys.NotInHeap
lock mutex
pages pageAlloc // page allocation data structure
sweepgen uint32
allspans []*mspan
...
}
Why does mheap have the sys.NotInHeap marker? What would go wrong if mheap were heap-allocated?
Answer
sys.NotInHeap is a compiler directive that prevents the type from being allocated on the Go heap. mheap must not be heap-allocated because:
-
Circular dependency:
mheapIS the heap. If it were allocated on the heap, you would need the heap to allocate the heap – a chicken-and-egg problem.mheapis instead a global variable (mheap_) in the BSS segment. -
GC interaction: The GC uses
mheapto track which objects are alive. Ifmheapitself were a GC-managed object, the GC would need to scan and potentially move the data structure it depends on for scanning. This would create impossible reentrancy and correctness issues. -
mSpanListfields:mheapcontainsmSpanList(linked lists of spans) that use direct pointer manipulation. If the heap could movemheap(e.g., during a hypothetical compacting GC), all those internal pointers would become invalid. -
Lock ordering:
mheap.lockis acquired during allocation. If allocatingmheaprequired acquiringmheap.lock, you would have a deadlock.
Question 4 (What Would Happen If…)
What would happen if Go did not have the mcache (per-P cache) and all allocations went directly to mcentral?
Answer
Without mcache, every small allocation would need to:
-
Acquire a lock: Each allocation would contend on
mcentral.lockfor the relevant size class. In a program with high allocation rates across many goroutines (common in Go), this lock would become a severe bottleneck. -
Performance degradation: A typical Go program allocates millions of small objects per second. Each allocation would go from ~25ns (cache hit in mcache) to potentially microseconds (lock contention under load). This could slow allocation-heavy programs by 10-100x.
-
Cache inefficiency:
mcachekeeps recently-used spans in CPU cache. Without it, each allocation would access a centralized data structure likely in a different cache line, causing frequent cache misses. -
Scaling collapse: Performance would degrade with increasing GOMAXPROCS. On a 64-core machine, 64 goroutines simultaneously allocating 16-byte objects would all serialize on
mcentrallocks. Withmcache, all 64 allocations proceed in parallel with zero contention.
The mcache is the key to Go’s allocation performance: the fast path (allocating from a cached span with free slots) requires no locks, no atomics, and usually just a few instructions (check bitmap, bump pointer, return).
Question 5 (Short Answer)
Explain the tri-color abstraction used by Go’s garbage collector. What are white, grey, and black objects, and what invariant must the write barrier maintain?
Answer
Tri-color abstraction:
- White objects: Not yet visited by the GC. At the end of marking, white objects are garbage and can be freed.
- Grey objects: Discovered (reachable) but not yet fully scanned. Their outgoing pointers have not all been traced. Grey objects are in the mark work queue.
- Black objects: Fully scanned. The GC has traced all pointers emanating from them.
Marking process: Start with roots (stacks, globals) greyed. Repeatedly pick a grey object, scan its pointers (greying any white objects they point to), then blacken it. When no grey objects remain, all reachable objects are black and all white objects are garbage.
Write barrier invariant (tri-color invariant): A black object must never point directly to a white object without an intervening grey object. If a mutator (application goroutine) stores a pointer to a white object into a black object during concurrent marking, the GC might never discover the white object and incorrectly free it.
Go uses a hybrid write barrier (combining Dijkstra insertion barrier and Yuasa deletion barrier) that ensures:
- When a pointer is written to a black object, the pointed-to object is greyed (Dijkstra).
- When a pointer is overwritten (deleted), the old pointed-to object is greyed (Yuasa).
This combination allows stack rescanning to be eliminated (stacks are treated as always black after initial scanning), which was a major improvement for GC pause times.
Question 6 (Short Answer)
What is a “size class” in Go’s memory allocator, and why does Go use approximately 70 size classes rather than allocating exact-sized blocks?
Answer
A size class is a predetermined allocation size. When a program requests N bytes, the allocator rounds up to the next size class and allocates a block of that size. Size classes range from 8 bytes to 32,768 bytes (32KB), with approximately 70 classes.
Why fixed size classes instead of exact sizes:
-
Reduced fragmentation management: With exact sizes, the allocator would need to track millions of different free-block sizes, making coalescing and searching complex (as in traditional
mallocimplementations). Size classes group similar sizes, simplifying free-list management. -
Span efficiency: Each
mspancontains objects of a single size class. This means the span can use a simple bitmap to track which slots are free, rather than complex metadata. Allocation is just “find next free bit.” -
Cache efficiency: Objects of the same size class are densely packed in spans, improving spatial locality and reducing TLB misses.
-
Fast allocation: The allocator just needs to look up the size class from a table (indexed by allocation size), then check the corresponding mcache slot. No searching through free lists of varying sizes.
-
Bounded internal waste: The size classes are chosen so that the maximum wasted space (internal fragmentation) is ~12.5%. For example, a 17-byte allocation goes into size class 24 (wasting 7 bytes), but a 25-byte allocation goes into size class 32 (wasting 7 bytes). The classes are spaced to keep waste bounded.
Allocations >32KB are “large objects” handled directly by mheap at page granularity.
Question 7 (True/False with Explanation)
True or False: Go’s garbage collector can move objects to compact the heap, similar to Java’s G1 or ZGC collectors.
Answer
False. Go’s garbage collector is non-moving (non-compacting). Objects stay at their original memory address for their entire lifetime.
This design choice has significant implications:
Advantages of non-moving GC:
- Interior pointers are safe (you can point into the middle of an object).
unsafe.Pointerand cgo interop are simpler – C code can hold pointers to Go objects without pinning.- No need for read/write barriers to handle pointer forwarding (moved objects).
- Simpler implementation with predictable performance.
Disadvantages:
- Fragmentation can occur over time (though size classes and spans mitigate this).
- Cannot achieve the high heap density that compacting collectors provide.
- Large objects that die leave “holes” that can only be filled by same-size-class allocations.
Go mitigates fragmentation through its size-class-based span allocator and by returning unused pages to the OS via madvise(MADV_DONTNEED) (scavenging). The one exception is goroutine stacks, which ARE moved (copied) when they grow, but this is handled by copystack, not by the GC.
Module 9: Goroutine Stacks (45 min)
Background: Stack Management and the Cost of Concurrency
The call stack is one of the oldest and most fundamental abstractions in computing.
Every time a function is called, a new stack frame is pushed onto the stack,
holding local variables, the return address, saved registers, and function
arguments. When the function returns, its frame is popped. This last-in, first-out
(LIFO) discipline is elegant and efficient: allocation is a single pointer
decrement (on architectures where the stack grows downward), and deallocation is
a pointer increment. There is no fragmentation, no free-list traversal, and no
garbage collection – just a stack pointer moving up and down. This simplicity
is why nearly every compiled language uses a call stack, and why hardware provides
dedicated instructions (CALL, RET, PUSH, POP on x86) to manipulate it.
Operating systems allocate a fixed-size stack for each thread at creation time.
On Linux, the default is 8 MB (configurable via ulimit -s or
pthread_attr_setstacksize); macOS uses 512 KB for secondary threads; Windows
defaults to 1 MB. To detect overflow, the OS places a guard page at the bottom
of the stack – a page mapped with no permissions (PROT_NONE). If the stack
pointer crosses into the guard page, the hardware triggers a fault, and the OS
delivers a signal (SIGSEGV on Unix) or structured exception (on Windows). This
mechanism is simple and adds zero runtime overhead to normal execution, since the
check is performed by the MMU in hardware. But the fixed-size design forces a
difficult trade-off: set the stack too small and deep recursion crashes; set it
too large and memory is wasted. Most programs use only a few kilobytes of their
multi-megabyte stack, yet the full allocation must be reserved up front. Some
systems also deploy stack canaries – sentinel values placed between the return
address and local variables that are checked on function return to detect
buffer-overflow attacks – but these serve security rather than growth management.
This fixed-size model becomes a serious bottleneck for highly concurrent systems. A server handling 10,000 simultaneous connections with one thread per connection commits 80 GB of virtual address space to stacks alone. At a million connections, the numbers become absurd. Even though modern virtual memory systems use demand paging (physical pages are not allocated until touched), the page table entries, TLB pressure, and kernel bookkeeping for a million threads impose real costs. This is the fundamental tension that drives language runtimes to seek alternatives: how do you support millions of concurrent tasks when each one needs a stack?
Different runtimes have explored different answers. GCC’s split stacks (used by
early Go through version 1.3) and similar segmented stack designs allocate small
initial stack segments and link new segments on demand. This avoids the upfront
memory commitment, but introduces the notorious “hot split” problem: if a function
near the segment boundary is called repeatedly in a tight loop, each call allocates
a new segment and each return frees it, causing severe thrashing. Erlang’s BEAM VM
takes a different approach entirely, giving each process a tiny initial stack
(sometimes as small as 233 words) that grows incrementally, enabled by the fact
that Erlang’s immutable data model means the stack rarely holds pointers that would
complicate relocation. Rust’s async model sidesteps the problem altogether for
asynchronous code: async fn compiles to a state machine (a “stackless coroutine”)
that captures only the variables live across yield points, requiring no contiguous
stack at all. This is memory-efficient but means async and synchronous Rust code
use fundamentally different execution models.
Go’s solution, adopted in Go 1.4 and still in use today, is the contiguous growable stack: goroutines start with a 2 KB stack, and when more space is needed, the runtime allocates a new stack of double the size, copies the old stack contents to the new location, adjusts every pointer that referenced the old stack, and frees the old allocation. This approach eliminates the hot split problem (doubling provides amortized O(1) cost per frame), preserves cache locality (the stack remains a single contiguous region), and requires no hardware or OS support beyond ordinary memory allocation. The price is that the compiler must generate precise stack maps – bitmaps that identify which words in every stack frame are pointers – so the runtime knows exactly what to adjust during a copy. This module examines how Go implements this design, from the compiler-inserted prologue checks to the stack copying algorithm and the per-P allocation pools that make it fast.
1. OS Thread Stacks: The Fixed-Size Problem (10 min)
How OS Threads Allocate Stacks
When the OS creates a thread (via pthread_create on Unix or CreateThread on
Windows), it allocates a fixed-size stack. Typical defaults:
| OS | Default Stack Size |
|---|---|
| Linux | 8 MB |
| macOS | 512 KB (secondary threads), 8 MB (main) |
| Windows | 1 MB |
The stack is allocated as a contiguous block of virtual memory. At the bottom sits
a guard page – a page mapped with no permissions (PROT_NONE). If the stack
pointer reaches the guard page, the hardware triggers a segfault, which the OS
converts into a stack overflow error.
Why Fixed Stacks Are Wasteful
Consider a web server handling 10,000 concurrent connections, one thread per connection:
- At 8 MB per stack: 80 GB of virtual address space committed to stacks
- Most of that stack space is unused – typical functions use only a few KB
- Even with virtual memory (demand paging), the TLB and page table overhead is significant
- Thread creation is expensive: the kernel must set up the stack, allocate a task struct, and perform a system call
This is the fundamental reason Go uses goroutines instead of threads: you cannot have millions of OS threads, because the stack memory alone would be prohibitive.
The Goroutine Advantage
Go goroutines start with a 2 KB stack. At that size:
- 1 million goroutines = ~2 GB of stack memory (vs. 8 TB for threads)
- Stacks grow on demand, so most goroutines never allocate more than they need
- Stack allocation is a userspace operation – no system call required
2. Goroutine Stack Layout (10 min)
The stack Struct
Every goroutine’s stack bounds are tracked in the g struct. The stack itself is
described by a simple pair of pointers:
// [src/runtime/runtime2.go, lines 462-465](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=462)
type stack struct {
lo uintptr
hi uintptr
}
The stack occupies exactly the memory range [lo, hi). The stack grows downward
(toward lower addresses), so hi is the “top” (where execution starts) and lo
is the “bottom” (the limit).
Stack Fields in the G Struct
The first fields of the g struct are dedicated to stack management:
// [src/runtime/runtime2.go, lines 473-483](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/runtime2.go;l=473)
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the //go:systemstack stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
// ...
}
Key fields:
stack: The[lo, hi)bounds of the current stack allocation.stackguard0: The threshold for the stack growth check. Normally set tostack.lo + StackGuard. The compiler-inserted prologue compares SP against this value. It can also be set to the special valuestackPreemptto force a preemption check.stackguard1: Used for//go:systemstackfunctions. Set to~0on regular goroutine stacks so that any system stack growth attempt will crash (system stack code should not need to grow).
Stack Guard and StackSmall
The stack layout includes a guard region at the bottom. From the comments in
stack.go:
// [src/runtime/stack.go, lines 20-68](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=20)
/*
Stack layout parameters.
The per-goroutine g->stackguard is set to point StackGuard bytes
above the bottom of the stack. Each function compares its stack
pointer against g->stackguard to check for overflow. To cut one
instruction from the check sequence for functions with tiny frames,
the stack is allowed to protrude StackSmall bytes below the stack
guard. Functions with large frames don't bother with the check and
always call morestack. The sequences are (for amd64, others are
similar):
guard = g->stackguard
frame = function's stack frame size
argsize = size of function arguments (call + return)
stack frame size <= StackSmall:
CMPQ guard, SP
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
stack frame size > StackSmall but < StackBig
LEAQ (frame-StackSmall)(SP), R0
CMPQ guard, R0
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
stack frame size >= StackBig:
MOVQ m->morearg, $((argsize << 32) | frame)
CALL morestack(SB)
*/
The visual layout of a goroutine stack:
stack.hi ────────────────────── (top of stack, where execution begins)
│ │
│ active frames │ ← SP is somewhere in here
│ (grows downward) │
│ │
├────────────────────┤
│ │
│ unused space │
│ │
guard0 ├────────────────────┤ ← stackguard0 = stack.lo + StackGuard
│ │
│ guard area │ ← room for nosplit functions + morestack
│ (StackGuard bytes)│
│ │
stack.lo ────────────────────── (bottom of stack)
Constants
// [src/runtime/stack.go, lines 70-103](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=70)
const (
stackSystem = goos.IsWindows*4096 + goos.IsPlan9*512 + goos.IsIos*goarch.IsArm64*1024
// The minimum size of stack used by Go code
stackMin = 2048
// stackNosplit is the maximum number of bytes that a chain of NOSPLIT
// functions can use.
stackNosplit = abi.StackNosplitBase * sys.StackGuardMultiplier
// The stack guard leaves enough room for a stackNosplit chain of NOSPLIT calls
// plus one stackSmall frame plus stackSystem bytes for the OS.
stackGuard = stackNosplit + stackSystem + abi.StackSmall
)
The initial stack size is stackMin = 2048 bytes (2 KB). The stackSystem
constant adds extra space on platforms that need it (4 KB on Windows for signal
handling on the goroutine stack, for example).
Preemption via stackguard0
The stackguard0 field serves double duty. Beyond stack overflow detection, it
is used for cooperative preemption:
// [src/runtime/stack.go, lines 70-103](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=70)
const (
// Goroutine preemption request.
// 0xfffffade in hex.
stackPreempt = uintptrMask & -1314
// Thread is forking. Causes a split stack check failure.
// 0xfffffb2e in hex.
stackFork = uintptrMask & -1234
)
When the scheduler wants to preempt a goroutine, it sets stackguard0 = stackPreempt.
This special sentinel value is larger than any real SP, so the next prologue check
will always fail, causing a call to morestack, which then checks for preemption
rather than actually growing the stack. This is how Go achieves cooperative
preemption at function call boundaries (complementing the asynchronous signal-based
preemption added in Go 1.14).
3. Stack Growth Mechanism (10 min)
The Prologue Check
The Go compiler inserts a stack growth prologue at the beginning of every
function (except those marked //go:nosplit). For a small function on amd64,
the generated code is essentially:
CMPQ g.stackguard0, SP
JHI morestack_call
If SP has dropped below stackguard0, the function calls morestack, which is
an assembly trampoline that saves the current context and calls newstack().
newstack(): The Growth Entry Point
// [src/runtime/stack.go, lines 1014-1026, 1026](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1014)
// Called from runtime·morestack when more stack is needed.
// Allocate larger stack and relocate to new stack.
// Stack growth is multiplicative, for constant amortized cost.
//
// ...
func newstack() {
The newstack function (line 1026) handles both preemption and actual stack growth.
After checking for preemption (lines 1093-1146), it doubles the stack size:
// [src/runtime/stack.go, lines 1148-1151](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1148)
// Allocate a bigger segment and move the stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
It then ensures the new size is sufficient for the pending frame:
// [src/runtime/stack.go, lines 1152-1162](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1152)
// Make sure we grow at least as much as needed to fit the new frame.
if f := findfunc(gp.sched.pc); f.valid() {
max := uintptr(funcMaxSPDelta(f))
needed := max + stackGuard
used := gp.stack.hi - gp.sched.sp
for newsize-used < needed {
newsize *= 2
}
}
The goroutine transitions to _Gcopystack status (preventing the GC from scanning
it during the copy), and then calls copystack:
// [src/runtime/stack.go, lines 1183-1192](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1183)
casgstatus(gp, _Grunning, _Gcopystack)
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
gogo(&gp.sched)
copystack(): The Heart of Stack Growth
The copystack function (line 900) performs the actual stack relocation:
// [src/runtime/stack.go, lines 898-904](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=898)
// Copies gp's stack to a new stack of a different size.
// Caller must have changed gp status to Gcopystack.
func copystack(gp *g, newsize uintptr) {
if gp.syscallsp != 0 {
throw("stack growth not allowed in system call")
}
old := gp.stack
The algorithm:
-
Allocate a new stack of the requested size:
// line 916 new := stackalloc(uint32(newsize)) -
Compute the adjustment delta (difference between new and old base addresses):
// lines 925-927 var adjinfo adjustinfo adjinfo.old = old adjinfo.delta = new.hi - old.hi -
Adjust sudog pointers – goroutines blocked on channel operations may have pointers into the stack that need updating.
-
Copy the used portion of the stack to the new location:
// line 956 memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy) -
Walk all stack frames and adjust every pointer that points into the old stack:
// lines 974-978 var u unwinder for u.init(gp, 0); u.valid(); u.next() { adjustframe(&u.frame, &adjinfo) } -
Update the G’s stack fields and free the old stack:
// lines 968-971 gp.stack = new gp.stackguard0 = new.lo + stackGuard gp.sched.sp = new.hi - used
Pointer Adjustment in Detail
The adjustpointer function (line 610) checks whether a pointer falls within the
old stack bounds and, if so, adjusts it by the delta:
// [src/runtime/stack.go, lines 610-615](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=610)
func adjustpointer(adjinfo *adjustinfo, vpp unsafe.Pointer) {
pp := (*uintptr)(vpp)
p := *pp
if adjinfo.old.lo <= p && p < adjinfo.old.hi {
*pp = p + adjinfo.delta
}
}
This works because the compiler generates stack maps (pointer bitmaps) for every function at every safe point. These maps tell the runtime exactly which words on the stack are pointers, enabling precise adjustment.
4. Stack Allocation Pools (7 min)
Small Stacks: Per-P Free Lists
For small stacks (up to a few orders above the fixed stack size), the runtime maintains per-P caches for lock-free allocation:
// [src/runtime/stack.go, lines 147-168](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=147)
// Global pool of spans that have free stacks.
// Stacks are assigned an order according to size.
//
// order = log_2(size/FixedStack)
//
// There is a free list for each order.
var stackpool [_NumStackOrders]struct {
item stackpoolItem
_ [(cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
type stackpoolItem struct {
_ sys.NotInHeap
mu mutex
span mSpanList
}
// Global pool of large stack spans.
var stackLarge struct {
lock mutex
free [heapAddrBits - gc.PageShift]mSpanList
}
The allocation fast path in stackalloc (line 344) checks the per-P mcache first:
// [src/runtime/stack.go, lines 388-397](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=388)
} else {
c := thisg.m.p.ptr().mcache
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
This is the same pattern used for heap allocation (module 8): keep a per-P cache
to avoid lock contention. When the local cache is empty, stackcacherefill grabs
a batch from the global pool (holding a lock briefly).
Large Stacks: From the Heap
For large stacks (beyond the cached sizes), stackalloc allocates directly from
the memory heap:
// [src/runtime/stack.go, lines 405-430](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=405)
} else {
var s *mspan
npage := uintptr(n) >> gc.PageShift
log2npage := stacklog2(npage)
// Try to get a stack from the large stack cache.
lock(&stackLarge.lock)
if !stackLarge.free[log2npage].isEmpty() {
s = stackLarge.free[log2npage].first
stackLarge.free[log2npage].remove(s)
}
unlock(&stackLarge.lock)
if s == nil {
// Allocate a new stack from the heap.
s = mheap_.allocManual(npage, spanAllocStack)
if s == nil {
throw("out of memory")
}
osStackAlloc(s)
s.elemsize = uintptr(n)
}
v = unsafe.Pointer(s.base())
}
stackfree(): Returning to Pools
The stackfree function (line 463) mirrors stackalloc. Small stacks are returned
to the per-P cache:
// [src/runtime/stack.go, lines 501-532](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=501)
if n < fixedStack<<_NumStackOrders && n < _StackCacheSize {
// ...
if stackNoCache != 0 || gp.m.p == 0 || gp.m.preemptoff != "" {
lock(&stackpool[order].item.mu)
stackpoolfree(x, order)
unlock(&stackpool[order].item.mu)
} else {
c := gp.m.p.ptr().mcache
if c.stackcache[order].size >= _StackCacheSize {
stackcacherelease(c, order)
}
x.ptr().next = c.stackcache[order].list
c.stackcache[order].list = x
c.stackcache[order].size += n
}
Large stacks are returned differently depending on GC phase:
// [src/runtime/stack.go, lines 533-555](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=533)
} else {
s := spanOfUnchecked(uintptr(v))
if gcphase == _GCoff {
// Free the stack immediately if we're sweeping.
osStackFree(s)
mheap_.freeManual(s, spanAllocStack)
} else {
// If the GC is running, we can't return a stack span to the heap
// because it could be reused as a heap span, and this state
// change would race with GC. Add it to the large stack cache instead.
log2npage := stacklog2(s.npages)
lock(&stackLarge.lock)
stackLarge.free[log2npage].insert(s)
unlock(&stackLarge.lock)
}
}
5. Stack Shrinking (3 min)
Stacks only grow during execution, but can be shrunk by the garbage collector.
The shrinkstack function (line 1257) halves the stack if the goroutine is using
less than 1/4 of its allocated space:
// [src/runtime/stack.go, lines 1253-1306](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1253)
// Maybe shrink the stack being used by gp.
func shrinkstack(gp *g) {
// ...
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
// Don't shrink the allocation below the minimum-sized stack allocation.
if newsize < fixedStack {
return
}
// Compute how much of the stack is currently in use and only
// shrink the stack if gp is using less than a quarter of its
// current stack.
avail := gp.stack.hi - gp.stack.lo
if used := gp.stack.hi - gp.sched.sp + stackNosplit; used >= avail/4 {
return
}
// ...
copystack(gp, newsize)
}
Stack shrinking reuses the same copystack mechanism as growth – allocate a
smaller stack, copy the contents, adjust pointers. Shrinking can only happen at
safe points (not during syscalls, not at asynchronous safe points, not while
parking on a channel).
The preemptShrink flag in the G struct allows the GC to request that a goroutine
shrink its stack at the next synchronous safe point:
// [src/runtime/stack.go, lines 1130-1135](https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/runtime/stack.go;l=1130)
if gp.preemptShrink {
// We're at a synchronous safe point now, so
// do the pending stack shrink.
gp.preemptShrink = false
shrinkstack(gp)
}
6. Segmented Stacks vs. Contiguous Stacks (5 min)
The Segmented Stack Era (Go 1.0 - 1.3)
Early versions of Go used segmented stacks (also called “split stacks”). When a goroutine needed more stack space:
- A new segment was allocated (not necessarily adjacent to the old one)
- A “stack link” connected the new segment to the old one
- When the function returned, the segment was freed
Problem: the “hot split”. Consider a function right at the stack boundary that is called in a tight loop. Each call allocates a new segment; each return frees it. This thrashing destroyed performance for certain workloads.
The Contiguous Stack Solution (Go 1.4+)
Go 1.4 switched to contiguous stacks (also called “stack copying”). Instead of linking segments:
- Allocate a new, larger, contiguous stack (2x the old size)
- Copy the entire old stack to the new one
- Walk all frames and adjust every pointer into the stack
- Free the old stack
Advantages:
- No hot split problem – growth is amortized (doubling means O(log n) copies)
- Better cache locality – the entire stack is contiguous
- Simpler runtime – no need to manage linked segments or handle cross-segment returns
Requirement:
- The runtime must have precise pointer maps for every stack frame, so it knows which words are pointers and need adjustment. This is why Go compiles with full stack map information – it is not optional.
Why Not Virtual Memory?
One might ask: why not just allocate a huge virtual address range and let the OS
page in memory on demand? This is what Go’s g0 (system) stacks and OS threads do.
For goroutines, this approach fails because:
- Each goroutine would need a reserved virtual address range (e.g., 1 MB)
- With 1 million goroutines, that is 1 TB of virtual address space
- Page table entries, TLB pressure, and
mmapoverhead make this impractical - On 32-bit systems (historically relevant), address space is far too limited
Summary
| Aspect | OS Thread Stack | Goroutine Stack |
|---|---|---|
| Initial size | 1-8 MB (fixed) | 2 KB (growable) |
| Growth mechanism | Guard page + crash | Prologue check + copy |
| Maximum size | Fixed at creation | 1 GB (configurable) |
| Allocation | Kernel (mmap/VirtualAlloc) | Userspace pool |
| Shrinking | Never | GC can halve if < 1/4 used |
| Memory overhead | ~8 MB per thread | ~2 KB per goroutine |
| Pointer adjustment | N/A | Full stack walk + maps |
Key Takeaways
- 2 KB initial stacks make goroutines cheap to create – 4000x less memory than a typical OS thread stack.
- Compiler-inserted prologues check SP against stackguard0 at every function entry, making stack growth transparent to user code.
- Contiguous stack copying with pointer adjustment is the mechanism that makes growable stacks possible. It requires precise pointer maps from the compiler.
- Per-P stack caches make allocation and deallocation fast, following the same pattern as the memory allocator (module 8).
- stackguard0 serves double duty: stack overflow detection and cooperative preemption, unifying two mechanisms into one check.
Discussion Questions
- What happens if a goroutine calls a C function via cgo? Can the goroutine’s stack be moved while C code holds pointers into it?
- Why does
copystackneed to handle channel sudogs specially? What race condition could occur? - The maximum stack size defaults to 1 GB. What would happen if a goroutine legitimately needed more? Should the runtime support larger stacks?
Module 9: Goroutine Stacks (Stack Growth, Copying, Pools)
Comprehension Check
Question 1 (Code Reading)
Study this excerpt from the stack growth prologue description in runtime/stack.go:
guard = g->stackguard
frame = function's stack frame size
argsize = size of function arguments (call + return)
stack frame size <= StackSmall:
CMPQ guard, SP
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
stack frame size > StackSmall but < StackBig:
LEAQ (frame-StackSmall)(SP), R0
CMPQ guard, R0
JHI 3(PC)
MOVQ m->morearg, $(argsize << 32)
CALL morestack(SB)
Why are there different code sequences for small vs. large stack frames? What optimization does the small-frame case provide?
Answer
Small-frame case (frame <= StackSmall, typically 128 bytes):
The prologue simply compares SP against stackguard0 (CMPQ guard, SP). This works because the stackguard is set StackGuard bytes above the bottom of the stack, and StackSmall bytes of space below the guard are reserved as a safety margin. For small frames, the function’s stack usage fits within this safety margin, so a simple comparison of SP against the guard is sufficient. This is the common case and requires only one comparison instruction.
Larger-frame case (frame > StackSmall):
The prologue subtracts the frame size (minus StackSmall) from SP and compares the result against the guard. This is necessary because the function will push more than StackSmall bytes onto the stack – the safety margin is not sufficient, so we must account for the actual frame size in the comparison.
Very large frames (frame >= StackBig):
These skip the comparison entirely and always call morestack, because the frame might need more space than the remaining stack in all cases. The frame size is passed directly to morestack so it can allocate enough space.
The optimization is about instruction count on the hot path: most functions have small stack frames, so they get the cheapest possible check (one CMP + one conditional jump). The overhead of stack checking is kept to ~2 instructions for the common case.
Question 2 (Code Reading)
Consider the copystack function:
func copystack(gp *g, newsize uintptr) {
old := gp.stack
used := old.hi - gp.sched.sp
// allocate new stack
new := stackalloc(uint32(newsize))
// Compute adjustment.
var adjinfo adjustinfo
adjinfo.old = old
adjinfo.delta = new.hi - old.hi
// Adjust sudogs, synchronizing with channel ops if necessary.
ncopy := used
...
// Copy the stack to the new location
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
// Adjust pointers in the new stack.
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
adjustsudogs(gp, &adjinfo)
...
gp.stack = new
gp.stackguard0 = new.lo + stackGuard
}
What does adjinfo.delta represent, and why must pointers within the stack be adjusted after copying?
Answer
adjinfo.delta is the difference between the new stack’s high address and the old stack’s high address: new.hi - old.hi. Since stacks grow downward, this represents how much every stack-relative pointer must be shifted.
Why pointer adjustment is necessary:
When the stack is copied to a new, larger memory region, its base address changes. Any pointer that refers to a location within the old stack now points to invalid (or freed) memory. These pointers must be adjusted by delta to point to the corresponding location in the new stack.
Pointers that need adjustment include:
- Frame pointers and return addresses on the stack itself (stack frames contain pointers to parent frames).
deferrecords (_deferstructs) that reference stack-allocated arguments.panicrecords that contain stack pointers.sudogstructs that reference stack-allocated elements (e.g., the value being sent/received on a channel).- Context pointers (
gp.sched.ctxt,gp.sched.sp,gp.sched.bp).
This is also why Go switched from segmented stacks to copyable stacks in Go 1.4. Segmented stacks avoided copying but caused “hot split” problems (rapidly growing/shrinking at a segment boundary). Copyable stacks are simpler and avoid that issue, at the cost of needing pointer adjustment.
Note: only pointers into the stack need adjustment. Pointers to heap objects are unchanged because the heap does not move.
Question 3 (True/False with Explanation)
True or False: Goroutine stacks can only grow, never shrink.
Answer
False. Goroutine stacks can both grow and shrink.
Growth: When a function prologue detects insufficient stack space, it calls morestack -> newstack, which allocates a new stack (typically 2x the current size) and copies the old stack to the new one via copystack.
Shrinking: During garbage collection, the GC calls shrinkstack on goroutines whose stacks are less than 25% utilized. shrinkstack calls copystack with a smaller size (half the current size, but never below stackMin = 2048 bytes). This reclaims memory from goroutines that had deep call stacks at some point but are now at shallow call depth.
Stack shrinking is important for long-lived programs: a goroutine might briefly need a large stack (e.g., during initialization) but then spend most of its time in a shallow call chain. Without shrinking, that memory would be wasted for the goroutine’s entire lifetime.
Shrinking only happens at GC safe points (when the goroutine is stopped) because it requires the same pointer adjustment as growing.
Question 4 (What Would Happen If…)
Go starts goroutine stacks at 2KB (stackMin = 2048). What would happen if the initial stack size were instead set to 1MB (like a typical OS thread stack)?
Answer
With 1MB initial stacks:
-
Memory explosion: A program with 100,000 goroutines would require 100GB of memory just for stacks (vs. ~200MB with 2KB stacks). Most Go server programs create thousands to millions of goroutines, making this impractical.
-
Virtual address space: Even with lazy physical memory allocation (the OS does not commit all 1MB immediately if the pages are never touched), 1 million goroutines would need 1TB of virtual address space for stacks alone. On 64-bit systems this is technically possible but would strain the page table and TLB.
-
Wasted memory: Most goroutines use far less than 1MB of stack. A goroutine running a simple channel receive loop might use <4KB. The remaining 99.6% would be wasted.
-
Loss of Go’s concurrency advantage: Go’s ability to support millions of concurrent goroutines is one of its defining features, and it depends directly on cheap goroutine creation. 1MB stacks would make goroutines nearly as expensive as OS threads, eliminating this advantage.
-
No stack overflow protection improvement: Go’s growable stacks already prevent stack overflows by growing as needed. The 1MB limit on OS threads is arbitrary and CAN overflow; Go’s approach of growing to any size is strictly better.
The 2KB starting size is carefully chosen: it is large enough for most function calls without immediately triggering growth, yet small enough to allow millions of goroutines.
Question 5 (Short Answer)
Explain how the stack pool works. What are stack pools, and why does the runtime cache freed stacks instead of always returning memory to the heap?
Answer
Stack pools are per-size free lists of previously allocated goroutine stacks. When a goroutine’s stack is freed (the goroutine exits), the stack memory is returned to a pool keyed by its size. When a new goroutine needs a stack of that size, the pool is checked first before going to the allocator.
There are two pool structures:
stackpool: For small stacks (2KB, 4KB, 8KB, 16KB). Each size class has a list ofmspanobjects with free stack slots. Accessed with a lock.stackLarge: For larger stacks (>= 32KB). Organized as an array of free lists indexed bylog2(size). Also accessed with a lock.
Why cache freed stacks:
-
Allocation speed: Allocating a stack from a pool is much faster than going to
mheap(which may need to search for free pages or callmmap). Goroutine creation/destruction is very frequent in Go programs. -
Reduced fragmentation: Reusing stacks of the same size avoids fragmenting the heap with many small span allocations and deallocations.
-
OS call avoidance: Without pools, high goroutine churn would generate many
mmap/munmapsystem calls, which are expensive (page table manipulation, TLB flushes). -
Working set reuse: A reused stack page is likely still in the CPU cache or at least in physical memory. A freshly
mmap-ed page requires a page fault.
The GC clears the stack pools periodically (every other GC cycle) to prevent unbounded memory accumulation when goroutine concurrency decreases.
Question 6 (Short Answer)
What is the “hot split” problem that Go’s original segmented stacks had, and how did the switch to contiguous (copyable) stacks solve it?
Answer
The hot split problem:
With segmented stacks (Go before 1.4), when a function needed more stack space, a new segment was allocated and chained to the current segment. When the function returned, the segment was freed.
The problem occurred when a function at the segment boundary was called repeatedly in a loop:
func outer() {
for {
inner() // inner's frame crosses the segment boundary
}
}
Each call to inner():
- Detects insufficient space on the current segment.
- Allocates a new segment (involves
malloc, linked list manipulation). - Executes
inner(). - Returns and frees the segment (
free, linked list manipulation).
This cycle repeats every iteration, turning a simple function call into an expensive allocation/deallocation pair. This is the “hot split” – a frequently-called function repeatedly triggers segment creation and destruction.
How contiguous stacks solve it:
With contiguous (copyable) stacks, when growth is needed:
- A new, larger stack is allocated (2x the current size).
- The entire old stack is copied to the new one.
- The old stack is freed.
Because the new stack is 2x larger, the growth is amortized: subsequent calls do NOT trigger growth until the new, larger stack is also exhausted. This makes the amortized cost of stack growth O(1) per function call, similar to how dynamic arrays (slices) achieve amortized O(1) append via doubling.
The hot split problem is completely eliminated because there is no “boundary” to repeatedly cross.
Question 7 (Code Reading)
The constant stackMin is defined as:
const (
stackMin = 2048
)
And in newstack(), the decision to grow uses doubling:
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
Why doubling? What would be the time complexity of N nested function calls if the stack grew by a fixed additive amount (e.g., +4KB each time) instead of doubling?
Answer
Why doubling: Doubling achieves amortized O(1) cost per function call for stack growth, similar to the analysis for dynamic arrays:
- A goroutine starts with a 2KB stack.
- After k growths, the stack is 2KB * 2^k.
- The total copy work across all growths is: 2KB + 4KB + 8KB + … + 2KB*2^k = 2KB * (2^(k+1) - 1) ≈ 2 * final_size.
- So the total copy work is proportional to the final stack size, meaning each frame push costs O(1) amortized.
With additive growth (+4KB each time):
- N nested calls on a 2KB stack would trigger growth approximately every time the stack is exhausted.
- After k growths, the stack is 2KB + 4KB*k.
- Each growth copies the entire current stack: 2KB, 6KB, 10KB, 14KB, …
- Total copy work: sum of (2KB + 4KB*i) for i=0..k ≈ O(k^2).
- Since k is proportional to N (number of calls), the total cost is O(N^2) for N nested calls.
This means a deep recursion of depth 1000 with additive growth would do ~500x more copying work than with doubling. For recursive algorithms (tree traversal, parsing, etc.), this would be catastrophic.
Doubling is the standard technique for amortized-efficient resizable data structures, and its application to stacks is directly analogous to append() on slices.
Assignment 1: System Call Tracer and Scheduler Event Analyzer
Reinforces: Module 2 (System Calls), Module 3 (Processes, Threads, Goroutines)
Overview
In this assignment, you will build a tool that traces and analyzes the interaction between Go programs and the operating system. You will use Go’s runtime/trace package to capture scheduler events and system call behavior, then build an analyzer that processes the trace data to produce human-readable reports about goroutine lifecycle, system call patterns, and scheduler decisions.
This assignment gives you hands-on experience with the boundary between the Go runtime and the OS kernel, and helps you understand how goroutines map to threads and how system calls affect scheduling.
Learning Objectives
By completing this assignment, you will be able to:
- Explain how the Go runtime interacts with the kernel via system calls.
- Use
runtime/traceto capture and interpret scheduler events. - Identify the relationship between goroutines (Gs), threads (Ms), and processors (Ps) in a running program.
- Analyze how blocking system calls cause P handoffs and thread creation.
- Measure and reason about the overhead of system calls on goroutine scheduling.
Specification
Part A: Trace Capture Wrapper (30 points)
Build a package tracer that wraps a workload function and captures a runtime trace.
package tracer
import (
"os"
"runtime/trace"
)
// CaptureTrace runs the given function while recording a runtime trace
// to the specified output file. Returns an error if tracing cannot be started.
func CaptureTrace(outputFile string, workload func()) error {
// TODO: Implement
// 1. Create the output file
// 2. Start tracing with trace.Start()
// 3. Run the workload
// 4. Stop tracing with trace.Stop()
// 5. Close the file
return nil
}
You must also implement three workload functions that exercise different runtime behaviors:
// CPUBoundWorkload spawns N goroutines that each perform pure computation
// (no I/O, no syscalls) for the given duration.
func CPUBoundWorkload(numGoroutines int, duration time.Duration)
// IOBoundWorkload spawns N goroutines that each perform file I/O operations
// (creating temp files, writing, reading, deleting).
func IOBoundWorkload(numGoroutines int, numOps int)
// MixedWorkload spawns goroutines that communicate over channels,
// perform some computation, and do some file I/O.
func MixedWorkload(numProducers, numConsumers int, itemsPerProducer int)
Part B: Trace Analyzer (50 points)
Build a analyzer package that reads a trace file (produced by runtime/trace) and extracts statistics. You will use the internal/trace or golang.org/x/exp/trace package to parse trace events.
Your analyzer must produce a report with the following information:
B.1: Goroutine Statistics
- Total number of goroutines created
- Maximum number of concurrently running goroutines
- Distribution of goroutine lifetimes (min, max, median, p99)
- Number of goroutines that blocked on syscalls vs. channels vs. mutexes
B.2: Thread (M) Statistics
- Maximum number of OS threads active
- Number of thread creations during the trace
- Time spent with threads parked (idle) vs. active
B.3: Syscall Analysis
- Count of distinct syscall-induced scheduling events (goroutine entering/exiting syscall state)
- Number of P handoffs caused by long syscalls
- Average and maximum syscall durations
B.4: Scheduler Event Timeline
- For each P, produce a timeline showing which goroutines ran on it and when context switches occurred
- Identify the longest uninterrupted goroutine execution (potential scheduling latency issue)
package analyzer
// Report contains the analyzed trace statistics.
type Report struct {
Goroutines GoroutineStats
Threads ThreadStats
Syscalls SyscallStats
Timeline []PTimeline
}
// Analyze reads a trace file and produces a Report.
func Analyze(traceFile string) (*Report, error) {
// TODO: Implement
return nil, nil
}
// String returns a human-readable summary of the report.
func (r *Report) String() string {
// TODO: Implement
return ""
}
Part C: Comparative Analysis Report (20 points)
Write a short report (1-2 pages) that:
- Runs each of the three workloads from Part A with at least two different values of
GOMAXPROCS(e.g., 1, 4, and the number of CPUs). - Analyzes the traces using your Part B analyzer.
- Answers these questions with evidence from your traces:
- How does the number of OS threads compare to GOMAXPROCS for each workload type? Why?
- In the I/O-bound workload, how many P handoffs occurred? What triggered them?
- In the CPU-bound workload, did any goroutine run for more than 10ms without being preempted? If so, explain why.
- How does channel communication in the mixed workload affect goroutine scheduling (look at
runnextusage)?
Starter Code Structure
assignment1/
├── cmd/
│ └── tracer/
│ └── main.go # CLI tool: capture trace and/or analyze
├── tracer/
│ ├── capture.go # CaptureTrace function
│ └── workloads.go # Three workload functions
├── analyzer/
│ ├── analyzer.go # Trace parsing and analysis
│ ├── report.go # Report struct and formatting
│ └── analyzer_test.go # Tests with known trace files
├── go.mod
└── report/
└── analysis.md # Your Part C report
Grading Rubric
| Component | Points | Criteria |
|---|---|---|
| Part A: CaptureTrace | 10 | Correctly captures traces; handles errors |
| Part A: Workloads | 20 | Three distinct workloads that exercise different runtime paths |
| Part B: Goroutine stats | 15 | Accurate goroutine count, lifetime distribution, blocking reasons |
| Part B: Thread stats | 10 | Accurate thread count and utilization |
| Part B: Syscall analysis | 15 | Correct identification of syscall events and P handoffs |
| Part B: Timeline | 10 | Per-P timeline with context switch identification |
| Part C: Analysis report | 20 | Correct interpretation with evidence from traces |
| Total | 100 |
Hints
-
Start with
go tool traceto visually inspect your trace files before writing the analyzer. This gives you ground truth to validate against. -
The
runtime/tracepackage is straightforward for capture. The harder part is parsing. Look atgolang.org/x/exp/tracefor a stable parsing API, or study howcmd/traceparses events internally. -
For the I/O workload, use
os.CreateTemp,os.WriteFile, andos.ReadFile. These will trigger real blocking syscalls (unlike network I/O, which uses the poller). -
To detect P handoffs in your analyzer, look for events where a goroutine’s P assignment changes between entering and exiting a syscall.
-
For the mixed workload, use buffered and unbuffered channels to see different scheduling behaviors.
-
Run with
GODEBUG=schedtrace=1000alongside your tracer to cross-reference scheduler state. -
The
runtime.NumGoroutine()andruntime.GOMAXPROCS(0)functions can help instrument your workloads. -
Keep your workloads running for at least 1-2 seconds to get meaningful trace data, but not so long that trace files become unmanageably large.
Assignment 2: Simplified Goroutine Scheduler
Reinforces: Module 4 (The Go Scheduler), Module 5 (Work Stealing and Preemption)
Overview
In this assignment, you will implement a simplified version of Go’s GMP scheduler as a user-space library. You will use real OS threads as Ms, a struct with a run queue as P, and closures as Gs. Your scheduler must support local run queues, a global run queue, work stealing between Ps, and a basic preemption mechanism.
This hands-on implementation will solidify your understanding of why the GMP model exists, the tradeoffs in scheduling design, and the subtlety of concurrent data structures in the scheduler.
Learning Objectives
By completing this assignment, you will be able to:
- Implement the GMP scheduling model with correct concurrency control.
- Build a lock-free (or low-lock) work queue with work stealing.
- Implement cooperative and timer-based preemption of tasks.
- Reason about thread parking, spinning, and wakeup strategies.
- Measure and analyze the performance characteristics of your scheduler.
Specification
Core Types
package scheduler
import (
"sync"
"sync/atomic"
"time"
)
// Task represents a unit of work (analogous to a G).
type Task struct {
id int64
fn func() // The function to execute
state int32 // TaskRunnable, TaskRunning, TaskDone
// Add fields as needed for preemption, scheduling metadata, etc.
}
const (
TaskRunnable = iota
TaskRunning
TaskDone
)
// Processor represents a scheduling context (analogous to a P).
type Processor struct {
id int
runq [256]*Task // Local circular run queue
runqHead uint32
runqTail uint32
runnext atomic.Pointer[Task] // Fast-path for producer-consumer patterns
// Add fields as needed
}
// Machine represents a worker thread (analogous to an M).
// Each Machine runs on its own OS goroutine (acting as a thread).
type Machine struct {
id int
proc *Processor // Currently attached P (may be nil)
spinning bool
// Add fields as needed
}
// Scheduler is the global scheduler state (analogous to the sched struct).
type Scheduler struct {
processors []*Processor
globalRunq []*Task // Global run queue (protected by lock)
globalLock sync.Mutex
// Add fields for idle machines, spinning count, etc.
}
Required Functions
Part A: Basic Scheduling (35 points)
Implement the core scheduling loop:
// NewScheduler creates a scheduler with the given number of Ps.
// It starts one M (OS goroutine) per P.
func NewScheduler(numProcessors int) *Scheduler
// Spawn adds a new task to the scheduler. It should be placed on the
// current P's local run queue if called from a worker, or on the
// global run queue otherwise. If a local queue is full, shed half
// to the global queue (like runqputslow).
func (s *Scheduler) Spawn(fn func())
// schedule is the main scheduling loop for each M.
// It finds a runnable task and executes it, then repeats.
// It should check: (1) local run queue, (2) global run queue
// (every 61st tick), (3) try work stealing.
func (m *Machine) schedule()
// findRunnable finds the next task to run, blocking if necessary.
func (m *Machine) findRunnable() *Task
// Shutdown gracefully stops the scheduler after all tasks complete.
func (s *Scheduler) Shutdown()
Your schedule() function must:
- Check
runnextfirst (fast path for communicating tasks). - Check the local run queue.
- Check the global run queue every 61st scheduling tick.
- If no local work, attempt work stealing.
- If no work anywhere, park the machine (block on a condition variable or channel).
Part B: Work Stealing (30 points)
Implement work stealing between processors:
// runqSteal steals half the tasks from another processor's run queue
// and puts them in the caller's run queue. Returns the first stolen
// task (to execute immediately), or nil if nothing was stolen.
func (p *Processor) runqSteal(victim *Processor) *Task
// stealWork tries to steal from a random processor.
// Makes up to 4 attempts with randomized victim selection.
func (m *Machine) stealWork() *Task
Requirements for work stealing:
- Steal half the victim’s run queue (not all of it).
- Use atomic operations for the lock-free run queue (or a mutex if you prefer, but document the tradeoff).
- Randomize the starting victim to avoid contention.
- On the last attempt, also try to steal from
runnext. - Track stealing statistics (number of steal attempts, successes, tasks stolen).
Part C: Preemption (20 points)
Implement a preemption mechanism:
// Each task gets a time quantum. If a task runs longer than the quantum,
// it should be preempted and placed back on the run queue.
const TimeQuantum = 10 * time.Millisecond
Since you cannot preempt arbitrary Go code mid-execution (that would require signal handling), implement cooperative preemption with a check function:
// ShouldYield returns true if the current task has exceeded its time quantum.
// Long-running tasks should call this periodically.
func (s *Scheduler) ShouldYield() bool
// Yield voluntarily yields the current task's execution, placing it back
// on the run queue. Returns when the task is rescheduled.
func (s *Scheduler) Yield()
Also implement a monitor goroutine (analogous to sysmon) that:
- Runs on its own goroutine (not tied to a P).
- Periodically checks for tasks running longer than
TimeQuantum. - Sets a preemption flag on long-running tasks.
- Detects and reports if any processor has been idle for too long while work exists elsewhere.
Part D: Performance Evaluation (15 points)
Write benchmarks that compare your scheduler against Go’s native goroutine scheduler:
// BenchmarkFanOut creates N tasks that each spawn M subtasks.
// Compare: your scheduler vs. native goroutines.
func BenchmarkFanOut(b *testing.B)
// BenchmarkPingPong creates pairs of tasks that alternate execution
// (simulating channel-like communication).
func BenchmarkPingPong(b *testing.B)
// BenchmarkWorkStealingEfficiency creates an imbalanced workload
// (all tasks initially on one P) and measures how quickly
// work is distributed.
func BenchmarkWorkStealingEfficiency(b *testing.B)
Write a short analysis (in comments or a separate file) discussing:
- How does your scheduler’s throughput compare to Go’s? Why?
- What is the overhead of your work stealing vs. Go’s lock-free implementation?
- How effective is your preemption mechanism?
Starter Code Structure
assignment2/
├── scheduler/
│ ├── scheduler.go # Scheduler, NewScheduler, Spawn, Shutdown
│ ├── processor.go # Processor, run queue operations
│ ├── machine.go # Machine, schedule(), findRunnable()
│ ├── stealing.go # Work stealing implementation
│ ├── preemption.go # Preemption and sysmon-like monitor
│ ├── scheduler_test.go # Correctness tests
│ └── scheduler_bench_test.go # Performance benchmarks
├── cmd/
│ └── demo/
│ └── main.go # Demo program showing the scheduler in action
└── go.mod
Grading Rubric
| Component | Points | Criteria |
|---|---|---|
| Part A: Basic scheduling loop | 15 | Correct schedule/findRunnable with local+global queues |
| Part A: Spawn and run queue | 10 | Correct enqueue with overflow to global queue |
| Part A: Machine parking/wakeup | 10 | Machines park when idle and wake when work arrives |
| Part B: runqSteal | 15 | Correctly steals half; handles edge cases (empty queue, single item) |
| Part B: stealWork | 10 | Randomized victim selection; 4 attempts; runnext on last try |
| Part B: Statistics | 5 | Tracks steal attempts, successes, total tasks stolen |
| Part C: Cooperative preemption | 10 | ShouldYield/Yield work correctly |
| Part C: Monitor goroutine | 10 | Detects long-running tasks; sets preempt flag |
| Part D: Benchmarks | 10 | Three benchmarks with meaningful analysis |
| Part D: Analysis | 5 | Thoughtful comparison with Go’s scheduler |
| Total | 100 |
Hints
-
Start simple. Get Part A working with mutexes everywhere before optimizing. A correct mutex-based scheduler is worth more than a buggy lock-free one.
-
Use Go goroutines as your “OS threads.” Each Machine’s
schedule()loop runs in its own goroutine, which Go maps to an OS thread. This is a legitimate simplification – you are building a scheduler on top of Go’s scheduler, focusing on the algorithm rather than the OS interface. -
For task preemption, since you cannot interrupt a running closure, have your long-running tasks call
s.ShouldYield()in their inner loops. The monitor goroutine sets a flag;ShouldYield()checks it. This mirrors Go’s cooperative preemption at function prologues. -
Machine parking: Use
sync.Condor a channel to park idle machines. WhenSpawn()adds work, signal a parked machine to wake up. Be careful about the “lost wakeup” problem (the same issuefutexsleepsolves with its atomic check). -
For the lock-free run queue, study how
runtime/proc.goimplementsrunqput,runqget,runqgrabusingatomic.LoadAcqandatomic.StoreRel. The key insight is that the queue is single-producer (owning P) and multi-consumer (stealing Ps), which simplifies the protocol. -
The 61-tick global queue check: Use an atomic counter on each Processor. Every 61st call to
findRunnable, check the global queue first. This prevents starvation of globally-queued tasks. -
Testing concurrency: Use
go test -racereligiously. Your scheduler is a concurrent data structure and race conditions will be subtle. Write tests that spawn thousands of tasks to surface races. -
Spinning threads: Start without the spinning optimization. Once basic stealing works, add it: a machine that has no local work and is searching for work is “spinning.” Limit the number of spinning machines to avoid wasting CPU. When you
Spawn(), only wake a parked machine if there are no spinning machines.
Assignment 3: Concurrent Channel Implementation
Reinforces: Module 6 (Synchronization Primitives), Module 7 (Channels and Select)
Overview
In this assignment, you will implement a channel-like concurrent communication primitive from scratch, using only mutexes and condition variables (no Go channels allowed in the implementation). Your channel must support unbuffered and buffered modes, blocking send/receive, non-blocking try-send/try-receive, and a simplified select-like multiplexing operation.
This assignment will give you deep understanding of how channels work internally – the waiting queues, the direct-send optimization, lock ordering, and the subtleties of blocking and waking goroutines.
Learning Objectives
By completing this assignment, you will be able to:
- Implement blocking synchronization using mutexes and condition variables.
- Build a circular buffer with correct concurrent access.
- Handle the direct-send/receive optimization for unbuffered channels.
- Implement lock ordering to prevent deadlocks in multi-channel operations.
- Reason about memory visibility, lost wakeups, and spurious wakeups.
Specification
Core Type
package channel
import (
"sync"
"errors"
)
var (
ErrClosed = errors.New("send on closed channel")
ErrEmpty = errors.New("receive on empty closed channel")
)
// Chan is a typed channel supporting buffered and unbuffered operation.
// The type parameter T is the element type.
type Chan[T any] struct {
// TODO: Add fields
// Consider: mutex, circular buffer, send/recv wait queues,
// closed flag, capacity, count, etc.
}
// New creates a new channel with the given buffer capacity.
// A capacity of 0 creates an unbuffered channel.
func New[T any](capacity int) *Chan[T]
Required Operations
Part A: Buffered Channel (30 points)
// Send sends a value on the channel. Blocks if the buffer is full.
// Panics if the channel is closed.
func (c *Chan[T]) Send(val T)
// Recv receives a value from the channel. Blocks if the buffer is empty.
// Returns the value and true if successful, or the zero value and false
// if the channel is closed and empty.
func (c *Chan[T]) Recv() (T, bool)
// Close closes the channel. After closing, Send will panic.
// Recv will return remaining buffered values, then (zero, false).
// Close may be called only once; subsequent calls panic.
func (c *Chan[T]) Close()
// Len returns the number of elements currently in the buffer.
func (c *Chan[T]) Len() int
// Cap returns the channel's buffer capacity.
func (c *Chan[T]) Cap() int
Requirements:
- The buffer must be a circular queue (not a slice that grows/shrinks).
Sendmust block (not spin) when the buffer is full.Recvmust block (not spin) when the buffer is empty.- After
Close(),Recvmust drain remaining buffer contents before returning(zero, false). - Calling
Sendon a closed channel must panic (matching Go’s behavior). - Must be safe for concurrent use by multiple goroutines.
Part B: Unbuffered Channel (25 points)
Your channel with capacity == 0 must behave as an unbuffered (synchronous) channel:
Sendblocks until a correspondingRecvis ready.Recvblocks until a correspondingSendis ready.- When both are ready, the value is transferred directly from sender to receiver (the direct send optimization – no intermediate buffer copy).
- A
SendandRecvthat rendezvous must both unblock.
// For unbuffered channels, implement a handshake mechanism:
// the sender waits until a receiver is available, then transfers
// the value directly and both proceed.
You must implement waiting queues for blocked senders and receivers (analogous to sendq and recvq in the Go runtime’s hchan):
// waiter represents a blocked goroutine waiting to send or receive.
type waiter[T any] struct {
val T // For senders: the value to send.
// For receivers: where to store the received value.
done chan struct{} // Signal that the operation completed.
// (This is the ONE place you may use a Go channel --
// as a goroutine wake-up mechanism analogous to gopark/goready.
// Alternatively, use sync.Cond.)
}
Part C: Non-blocking Operations (15 points)
// TrySend attempts to send without blocking.
// Returns true if the value was sent, false if the channel is full or closed.
func (c *Chan[T]) TrySend(val T) bool
// TryRecv attempts to receive without blocking.
// Returns the value, true, true if a value was received.
// Returns zero, true, false if the channel is closed and empty.
// Returns zero, false, false if the channel is empty but open (would block).
func (c *Chan[T]) TryRecv() (val T, ok bool, received bool)
These must be truly non-blocking: they acquire the lock, check the state, and return immediately.
Part D: Select (Multiplexing) (20 points)
Implement a simplified Select that waits on multiple channel operations:
// SelectCase describes a single case in a Select operation.
type SelectCase[T any] struct {
Chan *Chan[T]
Dir SelectDir // SelectSend or SelectRecv
SendVal T // Value to send (for SelectSend)
}
type SelectDir int
const (
SelectSend SelectDir = iota
SelectRecv
)
// SelectResult contains the outcome of a Select.
type SelectResult[T any] struct {
Index int // Which case was selected
Value T // Received value (for SelectRecv cases)
Ok bool // false if channel was closed (for SelectRecv)
}
// Select waits until one of the given cases can proceed.
// If multiple cases are ready, one is chosen at random.
// Select blocks until at least one case is ready (no default case).
func Select[T any](cases []SelectCase[T]) SelectResult[T]
Requirements:
- Lock ordering: Acquire channel locks in address order to prevent deadlocks (matching
selectgo’s approach). - Random polling: Check cases in a random order to prevent starvation.
- Correct blocking: If no case is ready, enqueue the goroutine on ALL channels’ wait queues. When one operation completes, dequeue from all others.
- Handle the case where the same channel appears in multiple cases.
Part E: Testing and Verification (10 points)
Write comprehensive tests:
// TestBufferedBasic - single producer, single consumer with buffered channel.
// TestUnbufferedRendezvous - verify sender and receiver synchronize.
// TestConcurrentSendRecv - many goroutines sending and receiving simultaneously.
// TestCloseSemantics - verify close behavior matches Go's channels.
// TestSelectBasic - select with multiple channels, verify fairness.
// TestSelectDeadlock - verify select with all-blocked channels does not spin.
// TestDirectSend - verify unbuffered channel copies directly (measure allocation?).
// TestStress - 1000 goroutines, mix of operations, run with -race.
Constraints
- No Go channels in your implementation (except: you may use a
chan struct{}inside thewaiterstruct as a goroutine wake-up mechanism, since you cannot callgopark/goreadyfrom user code. Alternatively, usesync.Cond). - No busy-waiting / spinning. All blocking must use
sync.Cond.Wait()or equivalent. - No
unsafepackage. Use generics for type safety. - Must pass
go test -racewith no data races.
Starter Code Structure
assignment3/
├── channel/
│ ├── chan.go # Chan struct, New, Send, Recv, Close
│ ├── unbuffered.go # Unbuffered channel specialization
│ ├── nonblocking.go # TrySend, TryRecv
│ ├── select.go # Select implementation
│ ├── chan_test.go # Correctness tests
│ └── chan_bench_test.go # Performance benchmarks
├── cmd/
│ └── demo/
│ └── main.go # Demo: producer-consumer, fan-in, pipeline
└── go.mod
Grading Rubric
| Component | Points | Criteria |
|---|---|---|
| Part A: Buffered send/recv | 15 | Correct blocking, circular buffer, concurrent safety |
| Part A: Close semantics | 10 | Panic on send-after-close; drain buffer on recv |
| Part A: Edge cases | 5 | Zero-element types, single-capacity buffer, etc. |
| Part B: Unbuffered rendezvous | 15 | Correct synchronous handshake |
| Part B: Direct send | 10 | Value transferred without intermediate copy |
| Part C: TrySend/TryRecv | 15 | Truly non-blocking; correct return values |
| Part D: Select lock ordering | 8 | Channels locked in address order |
| Part D: Select blocking/wakeup | 12 | Correct multi-channel wait and dequeue |
| Part E: Test quality | 10 | Covers concurrency, edge cases, passes -race |
| Total | 100 |
Hints
-
Start with the buffered channel. Get
Send/Recv/Closeworking forcapacity > 0before tackling unbuffered. The buffered case is simpler because the buffer decouples senders from receivers. -
Use
sync.Condfor blocking. Create two condition variables on the same mutex: one for “buffer not full” (senders wait on this) and one for “buffer not empty” (receivers wait on this). Remember to use aforloop aroundCond.Wait()to handle spurious wakeups. -
The unbuffered case is a rendezvous. Think of it as: the sender adds itself to a “waiting senders” queue with its value, then waits. When a receiver arrives, it takes the value directly from the sender’s waiter struct, signals the sender, and both proceed. Vice versa if the receiver arrives first.
-
For direct send in unbuffered channels, study how
runtime/chan.go’ssend()function copies directly to the receiver’s memory viasendDirect. Your version will copy from thewaiter.valfield to the receiver’s return value. -
Lock ordering for Select: Sort the cases by
&c(the channel pointer converted touintptr) before acquiring locks. This is exactly whatselectgodoes withlockorder. After sorting, lock all channels, check each case in random order, and if none are ready, enqueue on all channels before releasing locks. -
The hardest bug will be lost wakeups. When a receiver arrives and finds the buffer empty:
- It must atomically (under the lock) check the buffer AND add itself to the wait queue.
- If it checks, finds empty, releases the lock, then enqueues, a sender could deposit a value and signal between the check and the enqueue, causing a lost wakeup.
-
Testing concurrent close:
Closemust wake ALL blocked senders (they should panic) and ALL blocked receivers (they should return zero, false). Usesync.Cond.Broadcast()for this. -
Benchmarking: Compare your channel against Go’s built-in channels for single-producer-single-consumer and multi-producer-multi-consumer patterns. Expect your implementation to be 5-20x slower (Go’s channels use runtime internals like
goparkand direct stack manipulation that you cannot access). Analyze why in your benchmark comments.
Final Exam: Operating Systems Through the Go Runtime
Duration: 3 hours
Total Points: 100
Open book: You may refer to the Go source code and course materials.
Part A: Concept Questions (40 points, 4 points each)
Answer each question in 3-5 sentences.
A1 (Modules 1-2)
The Go runtime makes system calls directly via assembly (e.g., sys_linux_amd64.s) rather than calling through libc. Explain one situation where this causes a compatibility problem and one situation where it provides a significant advantage.
Answer
Compatibility problem: On some systems, DNS resolution depends on NSS (Name Service Switch) modules that are loaded as shared libraries by libc’s getaddrinfo(). Since Go bypasses libc, it cannot use these modules and must implement its own DNS resolver (the “pure Go resolver”). This causes issues when systems use LDAP, mDNS, or custom NSS modules for name resolution, as Go’s resolver does not support them.
Advantage: Direct syscalls eliminate the overhead of switching to a C calling convention, managing a separate C stack, and coordinating with libc’s internal state (errno, signal masks, thread-local storage). This is critical for the Go scheduler, which makes frequent, performance-sensitive syscalls (e.g., futex for locks, epoll_wait for the network poller) and needs precise control over thread state during these calls. The overhead of a cgo call (~200ns) would be prohibitive for operations that occur millions of times per second.
A2 (Modules 1-2)
When a goroutine makes a blocking system call, the runtime calls entersyscall() before the call and exitsyscall() after it returns. What would happen if these calls were omitted? Describe the impact on both the specific goroutine and the overall scheduler.
Answer
Without entersyscall():
- The P remains attached to the M during the blocking syscall. Since the M is blocked in the kernel, the P is effectively frozen – no other goroutines on that P’s run queue can execute.
sysmonwould not know the M is in a syscall and would not retake the P.- If all Ps end up blocked in syscalls, the entire program stops executing Go code until some syscall returns.
Without exitsyscall():
- The goroutine’s status would remain
_Gsyscallafter returning from the syscall. The scheduler would not transition it to_Grunning. - If the P was retaken during the syscall, the goroutine would continue running without a P, violating the invariant that Go code requires a P. This could corrupt scheduler state.
In short, entersyscall/exitsyscall maintain the invariant that Ps are never wasted on blocked threads, and that the scheduler accurately tracks goroutine states.
A3 (Modules 3-4)
Explain why the Go scheduler uses an M:N threading model rather than a 1:1 model (one goroutine per OS thread) or an N:1 model (all goroutines on one OS thread). What specific limitations of 1:1 and N:1 does M:N overcome?
Answer
1:1 limitations: OS threads are expensive – each requires ~8KB kernel stack, a full task_struct in the kernel, and creation involves a clone syscall. Thread context switches require kernel transitions. A program with 100,000 goroutines would need 100,000 OS threads, consuming ~800MB of kernel stack alone and overwhelming the kernel scheduler. Go programs routinely create millions of goroutines, which is infeasible with 1:1.
N:1 limitations: All goroutines on a single OS thread cannot achieve parallelism on multi-core CPUs. Additionally, one blocking syscall blocks all goroutines, since there is only one thread to run them.
M:N overcomes both: Many goroutines (N) are multiplexed onto a smaller number of OS threads (M). This provides: true multi-core parallelism (multiple Ms running in parallel), lightweight concurrency (goroutines are cheap), and syscall resilience (blocking one M does not block other goroutines, because the P is handed to another M). The P abstraction limits parallelism to GOMAXPROCS, keeping OS thread count manageable.
A4 (Modules 3-4)
The g0 goroutine exists on every M and has a larger, fixed-size stack. Why can’t the scheduler run on a regular goroutine’s stack? Give two specific operations that require g0.
Answer
The scheduler cannot run on a regular goroutine’s stack because:
-
Stack growth: If the scheduler tried to grow a goroutine’s stack while running on that same stack, it would need to copy the memory it is currently executing on – a self-referential operation that would corrupt the active stack frame.
g0has a fixed, large stack that never needs growing. -
Context switching: When switching from one goroutine to another, the old goroutine’s state (including stack pointer) must be saved to its
gobuf. This requires running on a different stack – you cannot save and abandon a stack while still using it.
Two operations requiring g0:
copystack(): Allocates a new stack and copies the goroutine’s stack. Must run ong0because it is modifying the goroutine’s stack.schedule()andfindRunnable(): The scheduling loop runs ong0viamcall()orsystemstack(), because it manipulates scheduler data structures that must not be interrupted by stack growth, and because it needs a reliable stack while goroutine stacks are being swapped.
A5 (Modules 5-6)
The Go scheduler limits the number of “spinning” threads. Explain the tradeoff: what happens if no spinning threads are allowed? What happens if the limit is removed and all idle threads spin?
Answer
No spinning threads: When new work arrives (a goroutine becomes runnable), there would be no thread immediately available to pick it up. An idle thread would need to be unparked from sleep, which involves a futex wakeup syscall and OS scheduler intervention – adding potentially 10-50 microseconds of latency before the goroutine starts running. For latency-sensitive workloads, this delay is significant.
All idle threads spinning: Every idle M would be in a busy loop consuming 100% of its CPU core, checking run queues, the global queue, and the network poller repeatedly. On a 64-core machine with only a few goroutines, 60+ cores would be spinning uselessly, wasting power and competing for memory bandwidth and cache. The system would also show 100% CPU utilization even when the Go program has little actual work.
The balance: Go allows one spinning thread (approximately). This ensures at least one thread is always ready to immediately pick up new work (low latency), while all other idle threads park (low power/CPU waste). When the single spinning thread finds work, it wakes another thread to spin in its place, maintaining the invariant.
A6 (Modules 5-6)
Compare how Go’s sync.Mutex and the runtime’s internal runtime.mutex handle contention. What happens when a goroutine blocks on each? Why are two different mutex implementations necessary?
Answer
runtime.mutex (internal): When contended, blocks the entire OS thread using futexsleep(). The thread enters the kernel and sleeps until futexwakeup() is called by the unlocker. The Go scheduler cannot schedule other goroutines on this blocked M because the M itself is asleep in the kernel.
sync.Mutex (user-facing): When contended, parks the goroutine (not the thread) using runtime_SemacquireMutex, which calls gopark(). The M and P remain active and can schedule other goroutines. Only the specific goroutine is blocked. Additionally, sync.Mutex has two modes (normal and starvation) with spinning on the fast path before parking.
Why two implementations: runtime.mutex is used to protect scheduler internals (run queues, P state, etc.). Using sync.Mutex there would create a circular dependency – gopark uses the scheduler, and the scheduler uses these locks. You cannot park a goroutine while modifying the data structures that manage goroutine parking. runtime.mutex operates at a lower level (OS thread) to break this circularity.
A7 (Modules 7-8)
When sending a value on an unbuffered channel and a receiver is already waiting, Go copies the value directly from the sender’s stack to the receiver’s stack. Explain why this is safe even though writing to another goroutine’s stack is normally forbidden.
Answer
This direct copy is safe because of several guarantees at the point it occurs:
-
The receiver is in
_Gwaitingstate: It is parked and will not be running or modifying its own stack untilgoreadyis called for it. Its stack is stable. -
The receiver’s stack cannot be moved: Stack growth only happens at function prologues when the goroutine is running. Since the receiver is parked, its stack cannot grow or be copied to a new location during the direct send.
-
The channel lock is held: The sender holds
c.lock, ensuring no other goroutine can concurrently operate on this channel, and the receiver’ssudog(which contains the pointer to the receiver’s stack location,sg.elem) is stable. -
GC safety: The GC will not scan or shrink the receiver’s stack during this operation because it must acquire the channel lock (or observe the goroutine’s status atomically) before manipulating it.
The key invariant is: the receiver goroutine is guaranteed to be completely stopped with a stable stack at the moment of the copy.
A8 (Modules 7-8)
Explain why Go’s garbage collector must use a write barrier during the concurrent mark phase. Give a concrete example of an object that would be incorrectly freed without it.
Answer
During concurrent marking, application goroutines (mutators) run simultaneously with GC mark workers. The write barrier is needed to maintain the tri-color invariant: no black object may point to a white object without an intervening grey object.
Concrete example:
- Object A (black, already scanned) has a pointer to object B (grey).
- Object B has a pointer to object C (white, not yet discovered).
- The mutator executes:
A.ptr = C; B.ptr = nil(moves C’s only reference from B to A). - Without a write barrier: The GC scans B (greying it -> black), but B no longer points to C. A is already black and will not be rescanned. C remains white.
- Marking finishes: C is white (unreachable by the GC’s reckoning) and is freed.
- Bug: A still points to C, which has been freed. A has a dangling pointer.
With the write barrier, step 3 would:
- Grey C when the pointer
A.ptr = Cis written (Dijkstra insertion barrier), ensuring C is discovered; AND/OR - Grey C when
B.ptr = nildeletes the reference (Yuasa deletion barrier), preserving the snapshot.
Go’s hybrid barrier combines both, allowing stacks to be treated as always-black after initial scanning.
A9 (Modules 9-10)
A goroutine’s stack starts at 2KB and doubles when it needs to grow. Explain the amortized cost argument for why this is efficient. What is the total copy work done for a goroutine that ultimately needs a 64KB stack?
Answer
The doubling strategy ensures amortized O(1) cost per stack frame push, analogous to dynamic array resizing:
When a 2KB stack is full, a 4KB stack is allocated and 2KB is copied. When 4KB is full, 8KB is allocated and ~4KB is copied. And so on.
For a goroutine needing 64KB:
| Growth step | Old size | New size | Bytes copied |
|---|---|---|---|
| 1 | 2KB | 4KB | 2KB |
| 2 | 4KB | 8KB | 4KB |
| 3 | 8KB | 16KB | 8KB |
| 4 | 16KB | 32KB | 16KB |
| 5 | 32KB | 64KB | 32KB |
Total bytes copied: 2 + 4 + 8 + 16 + 32 = 62KB. In general, total copy work is bounded by 2 * final_size = 2 * 64KB = 128KB, and indeed 62KB < 128KB.
In general, the total copy work is bounded by 2 * final_size. Since the goroutine uses final_size bytes of stack over its lifetime, the amortized overhead is at most 2x – a constant factor. Each function call contributes O(1) amortized copying cost.
With additive growth (e.g., +4KB each time), the total copying would be O(n^2), making deep recursion catastrophically expensive.
A10 (Modules 9-10)
Explain why regular file I/O in Go does not use the network poller (epoll/kqueue), while socket I/O does. What is the scheduling cost of this difference?
Answer
Technical reason: epoll and kqueue do not support regular files. These multiplexing interfaces are designed for file descriptors that have meaningful “readiness” states (data available, connection accepted, write buffer space). Regular files on local filesystems are always reported as “ready” by epoll – the kernel always allows the read/write call, even though the actual I/O may block waiting for disk. There is no mechanism for the kernel to notify when a disk read completes asynchronously via epoll.
Scheduling cost: Network I/O parks the goroutine in user space (gopark via pollDesc) – the M and P remain available for other goroutines. File I/O blocks the entire M in a kernel syscall. The runtime must:
- Detach the P from the blocked M (via
entersyscall/sysmonretake). - Potentially create a new M to run goroutines on the freed P.
- When the I/O completes, the M must reacquire a P.
This means file I/O is more expensive from a scheduling perspective: it causes M creation (OS thread allocation), P handoffs, and context switches. A server doing 10,000 concurrent file reads might create 10,000 OS threads, whereas 10,000 concurrent socket reads need only GOMAXPROCS threads.
Modern solutions like io_uring on Linux could eventually allow file I/O to use a poller-like mechanism.
Part B: Code Reading (36 points, 12 points each)
For each question, read the provided Go runtime code and answer the questions.
B1: Channel Send with Waiting Receiver
The following is a simplified version of code from runtime/chan.go in the chansend function. This is the path taken when sending on a channel that has a goroutine waiting to receive.
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// ... (nil check, fast-path checks omitted) ...
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value directly to
// the receiver, bypassing the channel buffer (if any).
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
// ... (buffered path, blocking path omitted) ...
}
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if sg.elem != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem = nil
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
goready(gp, skip+1)
}
(a) (4 points) Why does send() call sendDirect before unlockf() but call goready after unlockf()? What would go wrong if these were reordered?
(b) (4 points) The function passes unlockf as a closure rather than calling unlock(&c.lock) directly inside send(). What design flexibility does this provide?
(c) (4 points) Explain what gp.param = unsafe.Pointer(sg) accomplishes. How does the receiver goroutine use this value when it wakes up?
Answer
(a) sendDirect copies data to the receiver’s stack (sg.elem). This must happen while the lock is held because:
- The lock prevents the receiver from being woken by another send or a close operation.
- It ensures
sg.elemis still valid (pointing to the receiver’s stack location). - If
unlockf()were called first, another goroutine could modify the channel state or even wake the receiver before the data is copied, leading to a data race or the receiver seeing uninitialized data.
goready is called after unlockf() because:
goreadymakes the goroutine runnable, which involves modifying scheduler state. The comment inhchansays “Do not change another G’s status while holding this lock… as this can deadlock with stack shrinking.”- Holding
c.lockwhile callinggoreadycould cause lock ordering violations if the woken goroutine immediately tries to operate on the same channel.
(b) Passing unlockf as a closure allows send() to be called from different contexts with different cleanup actions. For example, selectgo may need to perform additional cleanup (dequeuing from multiple channels) in the unlock function. The closure pattern lets send() be agnostic about what cleanup is needed – the caller specifies it. It also allows the unlock to happen at precisely the right point in the send sequence (after data copy, before goready).
(c) gp.param = unsafe.Pointer(sg) passes the sudog pointer to the receiver goroutine as a “return value” from gopark. When the receiver was parked in chanrecv, it called gopark and saved its sudog. When it wakes up, it reads gp.param to find the sudog that completed the operation. This tells the receiver:
- That the send completed successfully (vs. being woken by a channel close, where
sg.successwould be false). - Which
sudogwas involved (important forselectstatements where a goroutine may have multiple pendingsudogs). The receiver checkssg.successto determine if it received a valid value or if the channel was closed.
B2: Work Stealing
The following code is from runtime/proc.go:
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
// ... (timer stealing on last iteration) ...
// Don't bother to attempt to steal if p2 is idle.
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, newWork
}
}
}
}
// ... (return nil if nothing found) ...
}
(a) (4 points) stealOrder.start(cheaprand()) returns an enumerator that visits all Ps in a pseudo-random order. Why is this preferable to iterating allp[0], allp[1], ..., allp[N-1] in order?
(b) (4 points) The code checks !idlepMask.read(enum.position()) before attempting to steal. Why is it safe to skip idle Ps? Could this check cause the stealer to miss work?
(c) (4 points) The function returns inheritTime = false for stolen goroutines, unlike goroutines from runnext which return inheritTime = true. Explain the scheduling consequence of this difference.
Answer
(a) If all stealers iterate in the same order, they all try to steal from P0 first, then P1, etc. This causes:
- Contention: Multiple stealers simultaneously CAS on P0’s run queue, causing atomic operation retries.
- Unfair draining: P0 would lose goroutines disproportionately, while higher-numbered Ps retain them.
- Correlated behavior: Stealers would cluster on the same victims, reducing the effective parallelism of the stealing phase.
Random starting positions spread stealers across different victims, minimizing contention and distributing the load of being stolen from more evenly.
(b) If a P is idle (idlepMask bit is set), it has no M running goroutines on it. An idle P’s run queue is expected to be empty (work would have been taken when the P went idle). Skipping it avoids a wasted runqsteal call (which involves atomic operations on the victim’s queue).
This check COULD theoretically cause a miss: a goroutine could be added to an idle P’s run queue (e.g., by runqput from another P via timer firing) between the idle check and the steal attempt. However, this is benign because:
- The P going from idle to having work will trigger
wakep(), which starts a new M. - The stealer will retry up to 4 times. The idle mask is re-read on each iteration.
- Even if missed, the goroutine will be found on the next scheduling round.
(c) inheritTime = true means the goroutine inherits the remaining time slice of the previously running goroutine. This is used for runnext because that goroutine was readied by the current goroutine (e.g., a channel send waking a receiver) and should run as part of the same “scheduling unit” to preserve locality.
inheritTime = false means the goroutine gets a fresh time slice. Stolen goroutines should not inherit time because:
- The stealer’s P may have had its time slice mostly consumed by a different goroutine.
- Inheriting a nearly-expired time slice would cause the stolen goroutine to be immediately preempted.
- The stolen goroutine is running on a different P than it was created for, so time slice inheritance has no locality benefit.
A fresh time slice ensures stolen goroutines get fair CPU time on their new P.
B3: Stack Growth
The following code is from runtime/stack.go (simplified):
func newstack() {
thisg := getg()
gp := thisg.m.curg
// ... (preemption check omitted) ...
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
// Make sure we grow at least as much as needed.
if f := findfunc(gp.sched.pc); f.valid() {
max := uintptr(funcMaxSPDelta(f))
needed := max + stackGuard
used := gp.stack.hi - gp.sched.sp
for newsize-used < needed {
newsize *= 2
}
}
if newsize > maxstacksize {
throw("stack overflow")
}
casgstatus(gp, _Grunning, _Gcopystack)
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
}
(a) (4 points) Why does the function change the goroutine status to _Gcopystack during the copy? What would go wrong if it stayed in _Grunning?
(b) (4 points) The code calculates needed based on funcMaxSPDelta and may grow by more than 2x. Under what circumstances would doubling not be sufficient?
(c) (4 points) After copystack returns, all pointers within the stack have been adjusted. But what about pointers from the heap into the stack (e.g., a heap object containing a pointer to a stack-allocated variable)? How does Go handle this?
Answer
(a) The _Gcopystack status serves as a synchronization signal to other parts of the runtime:
- GC safety: The garbage collector checks goroutine status before scanning stacks. If a goroutine is in
_Gcopystack, the GC knows the stack is being moved and must not scan it (the pointers are being adjusted and would be inconsistent). - Stack shrinking: The GC’s
shrinkstackalso checks status and skips goroutines in_Gcopystack. - Channel operations:
sendDirect/recvDirectwrite to another goroutine’s stack. If that goroutine’s stack is being copied, the write would go to the old (soon-to-be-freed) memory. The_Gcopystackstatus prevents concurrent channel operations from writing to the stack.
If the goroutine stayed in _Grunning, the GC or channel operations could access the stack during the copy, reading inconsistent pointer values or writing to the wrong memory location.
(b) Doubling may not be sufficient when a function has a very large stack frame. funcMaxSPDelta returns the maximum stack pointer delta for the function at gp.sched.pc – this is the stack space the function will need. If the function has a local variable like var buf [65536]byte, it needs 64KB of stack space in a single frame.
If the current stack is 4KB and the function needs 64KB, doubling to 8KB is insufficient. The for loop keeps doubling (8KB -> 16KB -> 32KB -> 64KB -> 128KB) until newsize - used >= needed. This ensures the new stack can accommodate the function that triggered the growth, even if it has an unusually large frame.
(c) Go does NOT allow pointers from the heap into the stack. This is a key invariant enforced by the compiler’s escape analysis:
- If a variable’s address escapes (is stored in a heap object, returned from a function, captured by a goroutine), the compiler allocates it on the heap instead of the stack.
- The escape analysis rule ensures that stack-allocated variables are only referenced by stack frames above them in the call chain (which are also on the same stack and will be adjusted by
copystack).
This invariant is what makes stack copying feasible. If heap-to-stack pointers existed, copystack would need to find and update every heap object pointing into the stack, which would be prohibitively expensive (it would require scanning the entire heap).
The one exception is sudog.elem in channel operations, which is explicitly handled by adjustsudogs during stack copy.
Part C: Design Questions (24 points, 12 points each)
Answer each question in approximately one page (300-500 words). Credit will be given for clarity of reasoning, identification of tradeoffs, and supporting evidence from the Go runtime.
C1: Alternative Memory Allocator Design
Go’s current memory allocator uses a three-level hierarchy: mcache (per-P, lock-free) -> mcentral (per-size-class, locked) -> mheap (global, locked). A colleague proposes replacing this with a simpler two-level design:
- Level 1: Per-P free lists for each size class (similar to
mcachebut with more capacity – each P caches up to 1MB of free objects per size class). - Level 2: A global allocator that uses
mmapdirectly whenever a per-P cache is empty.
Analyze this proposal. Consider:
- Performance on allocation-heavy workloads with many goroutines.
- Memory overhead and waste when some Ps are busy with one size class and others are idle.
- Interaction with the garbage collector’s sweep phase.
- What problems does
mcentralsolve that this design would reintroduce?
Answer
Performance on allocation-heavy workloads: The fast path (allocating from the per-P cache) would be similar in performance to the current design – no lock contention. However, when a P’s cache is empty, it must call mmap, which is a system call (thousands of nanoseconds) compared to mcentral’s lock acquisition (tens of nanoseconds in the uncontended case). Under allocation-heavy workloads, cache misses would cause frequent mmap calls, which are orders of magnitude slower than getting a span from mcentral. The current mcentral acts as a fast “refill” mechanism that avoids syscalls.
Memory overhead and waste: With 1MB per size class per P, and ~70 size classes, each P would cache up to 70MB. With GOMAXPROCS=64, that is 4.5GB of cached free objects. Most of this would be wasted because real programs use only a few size classes heavily. The current mcache keeps only one active span per size class (typically 8-64KB), totaling ~1-4MB per P. The mcentral acts as a shared pool that redistributes spans between Ps based on demand – if P1 needs lots of 48-byte objects and P2 needs 128-byte objects, mcentral directs spans accordingly.
GC interaction: The sweep phase marks spans as free. Currently, swept spans return to mcentral, where any P can claim them. With the proposed design, swept memory would need to go directly to a specific P’s cache or to the global allocator. If it goes to a specific P, that P may not need it (wasting memory). If it goes to the global allocator, subsequent allocations of that size class would require mmap-level operations to reclaim it.
What mcentral solves: It provides cross-P memory redistribution without syscalls. When P1 frees many 32-byte objects and P2 needs them, mcentral facilitates this transfer with just a lock acquisition. Without it, P1’s freed memory would either sit in P1’s oversized cache (waste) or be returned via munmap and re-obtained via mmap for P2 (expensive). mcentral also provides natural flow control: when a P’s span is exhausted, it gets another span (not individual objects), amortizing the lock acquisition cost over hundreds of allocations.
The three-level design exists precisely because the extremes (fully local = memory waste, fully global = lock contention) are both bad. mcentral provides a balanced middle ground.
C2: Channel vs. Shared Memory for Concurrency
Go’s motto “Do not communicate by sharing memory; instead, share memory by communicating” promotes channels as the primary concurrency primitive. However, the standard library also provides sync.Mutex, sync.RWMutex, sync.Map, and sync/atomic.
Analyze the tradeoffs between channels and shared-memory primitives for the following scenarios, using your knowledge of their internal implementations:
- High-frequency counter (millions of increments per second from many goroutines).
- Request routing (distributing incoming requests to a pool of worker goroutines).
- Cache with read-heavy workload (95% reads, 5% writes).
For each scenario, recommend the best primitive and justify your choice based on implementation-level costs (lock acquisition, goroutine parking, memory allocation, cache coherence).
Answer
1. High-frequency counter:
Recommendation: sync/atomic.AddInt64
Channels are the worst choice here. Each channel send involves: acquiring c.lock, checking for receivers, copying the value to the buffer or directly, potentially calling goready. This is ~100-200ns per operation. A mutex-protected counter requires sync.Mutex.Lock() (futex-based semaphore, ~50-100ns under contention), increment, unlock.
atomic.AddInt64 is a single LOCK XADD instruction (~5-20ns), with no goroutine parking, no lock acquisition, and no memory allocation. For millions of increments per second, the 10-40x performance difference is decisive.
2. Request routing to worker pool:
Recommendation: Buffered channel
This is the ideal channel use case. A buffered chan *Request serves as a work queue. Implementation costs are well-amortized:
- Producers
Sendto the channel (~50-100ns with buffer space available: lock, enqueue, unlock). - Workers
Recvfrom the channel, parking when idle (no CPU consumption while waiting). - The channel’s
runnextoptimization ensures a worker that just finished a request (and is likely “warm” with request-handling code in cache) gets the next request quickly.
Alternatives like a sync.Mutex-protected queue require busy-polling or sync.Cond signaling, which channels already encapsulate more elegantly. The channel also provides natural backpressure (senders block when the buffer is full).
3. Read-heavy cache:
Recommendation: sync.RWMutex protecting a map
With 95% reads, sync.RWMutex excels: RLock allows concurrent readers, so read throughput scales linearly with cores. Each RLock/RUnlock is an atomic add/subtract on readerCount (~10-20ns), with no goroutine parking needed in the common case.
Using a channel for a cache would require a “cache server” goroutine pattern: all reads and writes are sent as messages to a single goroutine that owns the map. This serializes ALL operations (reads and writes) through one goroutine, destroying read parallelism. A read that would take 20ns with RWMutex would take 200+ns through a channel (send request + receive response, with goroutine scheduling overhead).
sync.Map is another option, optimized for the case where keys are stable (few writes). It uses a read-only map with atomic access (zero contention for reads) and a dirty map for writes. For truly read-heavy workloads with stable key sets, sync.Map can outperform RWMutex by avoiding even the atomic increment of readerCount. However, for workloads with non-trivial write rates, sync.Map’s promotion cost (copying dirty map to read-only map) can cause latency spikes.
Summary: The choice depends on the communication pattern. Channels excel when goroutines need to coordinate sequentially (pipelines, fan-out/fan-in). Shared memory primitives excel when the operation is simple (counter), read-dominated (cache), or when goroutine scheduling overhead would dominate the actual work.
End of Exam
Glossary
Key terms used throughout this course, organized alphabetically.
Arena — A large contiguous block of virtual memory (64MB on 64-bit systems) that
the Go runtime reserves for heap allocations. The heap is composed of many arenas,
managed by the mheap struct. See Module 8.
Atomic operation — A CPU instruction that completes indivisibly, without the possibility of interruption. Examples: compare-and-swap (CAS), atomic load/store. The foundation for lock-free data structures and synchronization primitives. See Module 6.
Channel (chan) — Go’s primary communication primitive, implemented by the hchan
struct in runtime/chan.go. Channels provide synchronized message passing between
goroutines, with optional buffering. See Module 7.
Compare-and-swap (CAS) — An atomic instruction that updates a memory location only if it currently holds an expected value. Returns whether the swap succeeded. Used extensively in the scheduler’s lock-free run queues.
Context switch — Saving the state of one execution context and restoring another. Goroutine context switches happen in user space and are much cheaper than OS thread context switches, which require entering the kernel.
Edge-triggered — A mode of I/O notification (used by epoll with EPOLLET) where
the kernel notifies only when the state changes (e.g., data arrives), not while
data remains available. Contrast with level-triggered. See Module 10.
epoll — Linux’s scalable I/O multiplexing mechanism. The Go runtime uses epoll in
edge-triggered mode to monitor thousands of file descriptors efficiently.
See runtime/netpoll_epoll.go and Module 10.
Futex — “Fast userspace mutex.” A Linux mechanism that combines an atomic integer
in user space with a kernel wait queue. Avoids system calls in the uncontended case.
See runtime/lock_futex.go and Module 6.
G (goroutine) — The Go runtime’s representation of a goroutine. Defined as the g
struct in runtime/runtime2.go. Contains the goroutine’s stack, saved registers
(gobuf), status, and scheduling metadata. See Modules 3-4.
GMP model — The Go scheduler’s three-part design: Goroutines (units of work), Machines (OS threads), and Processors (execution contexts with run queues). See Module 4.
gobuf — The struct that saves a goroutine’s register state (stack pointer, program
counter, etc.) when it is descheduled. Defined in runtime/runtime2.go. See Module 4.
gopark — The runtime function that blocks a goroutine by transitioning it from
_Grunning to _Gwaiting and calling schedule() to find other work. Used by
channels, locks, timers, and I/O. See Module 4.
goready — The runtime function that wakes a blocked goroutine by transitioning it
from _Gwaiting to _Grunnable and placing it on a run queue. See Module 4.
Goroutine — A lightweight concurrent execution context in Go, multiplexed onto OS threads by the runtime scheduler. Initial stack size: 2KB. See Modules 3-4.
Heap — The region of memory used for dynamically allocated objects. Managed by Go’s multi-level allocator (mcache → mcentral → mheap). See Module 8.
kqueue — BSD/macOS’s I/O event notification mechanism, analogous to Linux’s epoll.
See runtime/netpoll_kqueue.go and Module 10.
M (machine) — The Go runtime’s representation of an OS thread. Defined as the m
struct in runtime/runtime2.go. Each M has a system stack (g0) and may or may not
be associated with a P. See Module 3.
mcache — A per-P cache of memory spans, enabling lock-free allocation for small
objects. Defined in runtime/mcache.go. See Module 8.
mcentral — A shared pool of spans for a specific size class. When an mcache runs out of a size class, it refills from the corresponding mcentral. See Module 8.
mheap — The central heap manager that allocates spans from the OS (via mmap) and
distributes them to mcentrals. Defined in runtime/mheap.go. See Module 8.
mmap — A system call that maps virtual memory pages into a process’s address space. The Go runtime uses mmap to request memory from the OS. See Modules 2 and 8.
mspan — A contiguous run of memory pages dedicated to a single size class.
The fundamental unit of the Go memory allocator. Defined in runtime/mheap.go.
See Module 8.
Mutex — A mutual exclusion lock. The Go runtime has its own internal mutex
(runtime.mutex) distinct from sync.Mutex. The runtime mutex is implemented
using futex (Linux) or semaphores (macOS). See Module 6.
Network poller (netpoller) — The runtime subsystem that monitors file descriptors for I/O readiness using epoll/kqueue. Integrates with the scheduler so goroutines can block on I/O without blocking OS threads. See Module 10.
P (processor) — A logical execution context required to run Go code. Each P has
a local run queue of goroutines (capacity 256) and an mcache for memory allocation.
The number of Ps equals GOMAXPROCS. Defined in runtime/runtime2.go. See Module 4.
Page — The fundamental unit of virtual memory (typically 4KB or 8KB). The Go runtime uses 8KB pages internally. See Module 8.
Preemption — Involuntarily stopping a running goroutine to let others run. Go uses signal-based preemption (SIGURG) for goroutines in tight loops without function calls. See Module 5.
Run queue — A queue of runnable goroutines waiting to be scheduled. Each P has a local run queue (fixed-size circular buffer of 256 entries), and there is a global run queue protected by a mutex. See Module 4.
schedt — The global scheduler state struct, containing idle M and P lists, the
global run queue, and scheduling statistics. Defined in runtime/runtime2.go.
See Module 4.
Select — Go’s mechanism for waiting on multiple channel operations. Implemented
by selectgo() in runtime/select.go using a 3-pass algorithm. See Module 7.
Semaphore — A synchronization primitive with acquire (decrement, possibly block)
and release (increment, possibly wake) operations. The Go runtime implements
semaphores with a treap-based wait queue in runtime/sema.go. See Module 6.
Size class — One of ~70 predefined object sizes used by the Go allocator. Each size class has its own pool of spans. Reduces fragmentation by rounding allocations up to the nearest size class. See Module 8.
Span — See mspan.
Spinning thread — An M that is actively looking for work (checking run queues, stealing from other Ps) but hasn’t found any yet. Spinning threads consume CPU but reduce latency for newly created goroutines. See Module 5.
Stack copying — Go’s mechanism for growing goroutine stacks: allocate a new stack at 2x the size, copy all contents, and update pointers. Replaced the earlier “segmented stacks” approach. See Module 9.
Stack guard — A sentinel value stored in g.stackguard0 that triggers stack
growth when the stack pointer gets too close to the stack boundary. The compiler
inserts checks against this value in function prologues. See Module 9.
STW (stop-the-world) — A phase where all goroutines are paused. The Go GC has two brief STW phases: sweep termination and mark termination. See Module 8.
sudog — “Pseudo-goroutine.” A struct that represents a goroutine waiting on a
synchronization event (channel send/receive, semaphore acquire). Defined in
runtime/runtime2.go. See Modules 6-7.
System call (syscall) — A request from user-space code to the kernel for a privileged operation (file I/O, memory mapping, process creation, etc.). See Module 2.
Treap — A balanced binary search tree where nodes are ordered by key (BST
property) and by a random priority (heap property). Used in runtime/sema.go for
the semaphore wait queue. See Module 6.
VDSO — “Virtual Dynamic Shared Object.” A mechanism where the kernel maps
frequently-used code (like clock_gettime) into user space, avoiding the overhead
of a real system call. Go uses VDSO for time operations on Linux. See Module 2.
Work stealing — A distributed scheduling strategy where idle processors steal goroutines from busy processors’ run queues. The Go scheduler steals half of the victim’s queue. See Module 5.
Write barrier — Code inserted by the compiler before pointer writes during GC marking. Ensures the concurrent garbage collector doesn’t miss reachable objects. See Module 8.
Guided Code Walkthrough: Life of a Goroutine
This walkthrough traces the complete lifecycle of a goroutine through the Go runtime source code, connecting every module in the course. Use this as a capstone exercise or a guided self-study activity.
Setup
Open the Go source tree to src/runtime/. You’ll need these files:
proc.go— schedulerruntime2.go— data structureschan.go— channelsstack.go— stack managementmalloc.go— memory allocation
We’ll trace what happens when you write:
func main() {
ch := make(chan int, 1)
go func() {
ch <- 42
}()
fmt.Println(<-ch)
}
Phase 1: Goroutine Creation (go func())
Start at: proc.go — search for func newproc(
When the compiler sees go func() { ... }, it emits a call to newproc.
Step 1.1: newproc
proc.go:newproc()
- Gets the caller’s PC (program counter)
- Calls
newproc1()with the function value and caller info
Step 1.2: newproc1
proc.go:newproc1()
- Tries to reuse a dead goroutine from the P’s free list (
gfget) - If none available, allocates a new
gstruct withmalg(stackMin)- This allocates a 2KB stack (see Module 9)
stackalloc()instack.gohandles the allocation from per-P pools
- Initializes the goroutine:
- Sets up the
gobuf(saved registers) so that when the goroutine is scheduled, it starts executing the target function - Sets
gp.startpcto the function’s address - Assigns a new goroutine ID (
goidgen) - Sets status to
_Grunnable
- Sets up the
- Puts the new goroutine on the current P’s run queue via
runqput()
Step 1.3: runqput
proc.go:runqput()
- If
next=true: stores the goroutine inpp.runnext(priority slot)- Any goroutine already in
runnextgets pushed to the regular queue
- Any goroutine already in
- Otherwise: appends to the circular buffer
pp.runq[tail] - If the local queue is full (256 entries): calls
runqputslow()to move half the queue to the global run queue
Step 1.4: wakep
proc.go:wakep()
- Called after adding work to ensure an M is available to run it
- If there’s an idle P and no spinning threads, starts or wakes an M
Connection to Module 4: This is the goroutine creation lifecycle. Connection to Module 5: wakep triggers the spinning thread mechanism.
Phase 2: Scheduling
Start at: proc.go — search for func schedule(
An idle M (or one that just finished running a goroutine) calls schedule().
Step 2.1: schedule
proc.go:schedule()
- Calls
findRunnable()to find a goroutine to run - This call blocks until work is available
Step 2.2: findRunnable
proc.go:findRunnable()
The priority order for finding work:
- Check for trace reader goroutines
- Check for GC work
- Every 61st tick: check the global run queue (fairness)
- Check for finalizer goroutines
- Check the local run queue via
runqget() - Check the global run queue via
globrunqget() - Check the network poller via
netpoll() - Steal from other Ps via
stealWork() - If nothing found: park the P and block the M
Step 2.3: execute
proc.go:execute()
Once a runnable goroutine is found:
- Sets
mp.curg = gpandgp.m = mp(link M and G) - Transitions status from
_Grunnableto_Grunning - Calls
gogo(&gp.sched)— an assembly function that restores the goroutine’s saved registers and jumps to its code
The goroutine is now running. It executes on the M’s OS thread, using the P’s resources (mcache for allocation, local run queue for spawning children).
Connection to Module 5: Work stealing in step 2.2. Connection to Module 10: Network poller integration in step 2.2.
Phase 3: Channel Send (Blocking)
Start at: chan.go — search for func chansend(
Our goroutine executes ch <- 42.
Step 3.1: chansend
chan.go:chansend()
Since the channel is buffered (size 1) and empty:
- Acquires
c.lock - Checks if any receiver is waiting on
c.recvq— none yet - Checks if buffer has space (
c.qcount < c.dataqsiz) — yes - Copies the value into the circular buffer at
c.buf[c.sendx] - Increments
c.sendxandc.qcount - Releases
c.lock - Returns (goroutine is not blocked in this case)
If the buffer were full, the goroutine would:
- Create a
sudogstruct referencing itself - Enqueue on
c.sendq - Call
gopark()to block
Step 3.2: gopark (when blocking occurs)
proc.go:gopark()
- Saves the unlock function and lock pointer to the M
- Calls
mcall(park_m)to switch from user goroutine to g0
Step 3.3: park_m
proc.go:park_m()
- Transitions goroutine from
_Grunningto_Gwaiting - Calls the unlock function (releases the channel lock)
- Calls
schedule()to find other work
Connection to Module 7: Channel send/receive mechanics. Connection to Module 4: gopark/schedule interaction.
Phase 4: Channel Receive (Waking)
Start at: chan.go — search for func chanrecv(
The main goroutine executes <-ch.
Step 4.1: chanrecv
chan.go:chanrecv()
- Acquires
c.lock - Checks if any sender is waiting on
c.sendq— no (sender already completed) - Checks if buffer has data (
c.qcount > 0) — yes - Copies value from
c.buf[c.recvx]into the receiver’s variable - Decrements
c.qcount - Releases
c.lock - Returns the value
If the buffer were empty and a sender were parked:
- Dequeues the sender’s
sudogfromc.sendq - Copies the value directly from sender to receiver (bypassing buffer)
- Calls
goready()on the sender’s goroutine
Step 4.2: goready (waking a parked goroutine)
proc.go:goready()
- Transitions goroutine from
_Gwaitingto_Grunnable - Puts it on the current P’s local run queue via
runqput() - Calls
wakep()to ensure an M picks it up
Connection to Module 7: Direct send optimization. Connection to Module 4: goready/runqput interaction.
Phase 5: Goroutine Exit
Start at: proc.go — search for func goexit1(
After ch <- 42 completes, the anonymous function returns. The runtime has
arranged for the return address to be goexit, which calls goexit1.
Step 5.1: goexit1
proc.go:goexit1()
- Calls
mcall(goexit0)to switch to g0
Step 5.2: goexit0
proc.go:goexit0()
- Transitions goroutine to
_Gdead - Clears the goroutine’s fields (stack, M link, etc.)
- Calls
gdestroy()to clean up - Places the dead goroutine on the P’s free list for reuse (
gfput) - Calls
schedule()to find new work
Connection to Module 3: Goroutine lifecycle states. Connection to Module 9: Stack is returned to the pool.
Phase 6: Stack Growth (if it happens)
Start at: stack.go — search for func newstack(
If at any point during execution, a function prologue detects that the stack
pointer is below stackguard0:
Step 6.1: morestack (assembly)
- Saves current state
- Switches to g0 stack
- Calls
newstack()
Step 6.2: newstack
stack.go:newstack()
- Checks if this is a preemption signal (
stackPreempt) — if so, handle preemption - Otherwise: allocates a new stack at 2x the current size
- Calls
copystack()to copy the old stack contents - Adjusts all pointers that reference the old stack
- Resumes execution on the new stack
Connection to Module 9: Contiguous stack design. Connection to Module 5: Preemption check piggybacks on stack check.
Summary: The Complete Picture
newproc → newproc1 → runqput → wakep
↓
schedule ← findRunnable (local/global/steal/netpoll)
↓
execute → gogo → [user code runs]
↓
gopark ← channel/lock/IO blocked
↓
park_m → schedule (find other work)
...
goready ← channel/lock/IO ready
↓
runqput → schedule picks it up again
...
goexit1 → goexit0 → gfput → schedule
Every arrow in this diagram is a function call you can find in the source code. The scheduler is not a separate thread — it runs on g0 whenever a goroutine voluntarily yields (gopark), is preempted, or exits.
Exercise
Trace the following program through the runtime, identifying which functions are called and in what order. Note where goroutines block and wake up.
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // blocks: unbuffered channel, no receiver yet
}()
msg := <-ch // wakes sender, receives directly
fmt.Println(msg)
}
Hint: with an unbuffered channel, the send will block (gopark) because there’s
no receiver. When the main goroutine does <-ch, it will find the sender in
c.sendq, copy the value directly (bypassing the buffer), and call goready
on the sender.
Runtime Experiments
Hands-on experiments to observe Go runtime behavior. These complement the lecture materials by letting you see the runtime in action.
Experiment 1: Observing the Scheduler
1.1: schedtrace
Run any Go program with scheduler tracing enabled:
GODEBUG=schedtrace=1000 go run myprogram.go
Output looks like:
SCHED 0ms: gomaxprocs=8 idleprocs=6 threads=4 spinningthreads=1
idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
What to observe:
gomaxprocs: number of Ps (= GOMAXPROCS)idleprocs: Ps with no workthreads: total OS threads (Ms) createdspinningthreads: Ms looking for workrunqueue: global run queue length[0 0 ...]: per-P local run queue lengths
Try: Run a program that creates 1000 goroutines doing CPU work. Watch how the run queues fill up and balance across Ps.
1.2: scheddetail
For more detail:
GODEBUG=schedtrace=1000,scheddetail=1 go run myprogram.go
This shows the state of every G, M, and P.
1.3: Execution Tracer
package main
import (
"os"
"runtime/trace"
"sync"
)
func main() {
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
// do some work
sum := 0
for j := 0; j < 1000000; j++ {
sum += j
}
_ = sum
}(i)
}
wg.Wait()
}
View with:
go tool trace trace.out
What to observe:
- Goroutine creation and scheduling on Ps
- Work stealing events
- GC pauses
- System call blocking
Experiment 2: System Call Overhead
2.1: Measuring syscall cost
package main
import (
"fmt"
"os"
"syscall"
"time"
)
func main() {
// Measure getpid (a trivial syscall)
n := 1000000
start := time.Now()
for i := 0; i < n; i++ {
syscall.Getpid()
}
elapsed := time.Since(start)
fmt.Printf("syscall.Getpid: %v per call\n", elapsed/time.Duration(n))
// Measure file read (1 byte from /dev/null)
f, _ := os.Open("/dev/null")
buf := make([]byte, 1)
start = time.Now()
for i := 0; i < n; i++ {
f.Read(buf)
}
elapsed = time.Since(start)
fmt.Printf("os.File.Read: %v per call\n", elapsed/time.Duration(n))
f.Close()
}
2.2: Tracing system calls
# Linux
strace -c go run myprogram.go
# macOS
sudo dtruss go run myprogram.go 2>&1 | tail -20
What to observe:
- Which system calls Go programs actually make
- The ratio of runtime syscalls vs application syscalls
- How many
futex/psynchcalls the scheduler makes
Experiment 3: Goroutine Stack Growth
3.1: Observing stack growth
package main
import (
"fmt"
"runtime"
)
func recurse(depth int) {
var buf [64]byte // force some stack usage
_ = buf
if depth > 0 {
recurse(depth - 1)
}
if depth == 0 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Stack in use: %d bytes\n", m.StackInuse)
}
}
func main() {
fmt.Println("Initial stack size: 2KB (minimum)")
recurse(10)
fmt.Println("After shallow recursion:")
recurse(10)
fmt.Println("\nAfter deep recursion:")
recurse(10000)
}
3.2: Stack size limits
package main
import (
"fmt"
"runtime/debug"
)
func main() {
// Default max stack size is 1GB
// You can set it lower to observe stack overflow
debug.SetMaxStack(1 << 20) // 1MB max
var f func(int)
f = func(n int) {
if n > 0 {
f(n - 1)
}
}
f(1000000) // will hit the 1MB limit
fmt.Println("done")
}
Experiment 4: Channel Performance
4.1: Buffered vs unbuffered throughput
package main
import (
"fmt"
"time"
)
func benchmark(name string, ch chan int) {
n := 1000000
start := time.Now()
go func() {
for i := 0; i < n; i++ {
ch <- i
}
}()
for i := 0; i < n; i++ {
<-ch
}
elapsed := time.Since(start)
fmt.Printf("%s: %v per op\n", name, elapsed/time.Duration(n))
}
func main() {
benchmark("unbuffered", make(chan int))
benchmark("buffered-1", make(chan int, 1))
benchmark("buffered-100", make(chan int, 100))
benchmark("buffered-10000", make(chan int, 10000))
}
What to observe:
- Unbuffered channels require goroutine synchronization on every send
- Buffered channels amortize the cost
- Very large buffers don’t help much (cache effects)
4.2: Select with many cases
package main
import (
"fmt"
"time"
)
func main() {
for nch := 2; nch <= 64; nch *= 2 {
channels := make([]chan int, nch)
for i := range channels {
channels[i] = make(chan int, 1)
}
n := 100000
start := time.Now()
for i := 0; i < n; i++ {
// Send on a random channel
channels[i%nch] <- 1
// Select across all channels (using reflect for dynamic select)
// In practice, measure compile-time selects with fixed case counts
<-channels[i%nch]
}
elapsed := time.Since(start)
fmt.Printf("select with %d channels: %v per op\n", nch, elapsed/time.Duration(n))
}
}
Experiment 5: Observing GC
5.1: GC trace output
GODEBUG=gctrace=1 go run myprogram.go
Output looks like:
gc 1 @0.012s 2%: 0.11+1.2+0.063 ms clock, 0.89+0.45/1.0/0+0.50 ms cpu, 4->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 8 P
Fields:
gc 1: GC number@0.012s: time since program start2%: percentage of CPU used by GC0.11+1.2+0.063 ms clock: STW sweep term + concurrent mark + STW mark term4->4->1 MB: heap before → heap after → live data8 P: number of processors
5.2: Allocation pressure
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
var m runtime.MemStats
// Allocate lots of small objects
start := time.Now()
for i := 0; i < 10000000; i++ {
_ = make([]byte, 64)
}
elapsed := time.Since(start)
runtime.ReadMemStats(&m)
fmt.Printf("Time: %v\n", elapsed)
fmt.Printf("Total allocs: %d\n", m.Mallocs)
fmt.Printf("GC cycles: %d\n", m.NumGC)
fmt.Printf("Total GC pause: %v\n", time.Duration(m.PauseTotalNs))
fmt.Printf("Avg GC pause: %v\n", time.Duration(m.PauseTotalNs/uint64(m.NumGC)))
}
Experiment 6: Preemption
6.1: Non-preemptible goroutine (pre-Go 1.14)
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
// Tight loop with no function calls
// In Go 1.14+, this will be preempted via SIGURG
// (asynchronous signal-based preemption)
// In older Go, this would monopolize the P
for {
// Go 1.14+ uses signal-based async preemption (SIGURG),
// so even tight loops without function calls are preemptible
}
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("Main goroutine ran — preemption works!")
}
6.2: Observing sysmon
The sysmon thread runs independently and preempts goroutines that run for >10ms. You can observe its effects with the execution tracer.
Experiment 7: Network Poller Integration
7.1: Many concurrent connections
package main
import (
"fmt"
"net"
"runtime"
"sync"
"time"
)
func main() {
// Start a simple echo server
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
buf := make([]byte, 1024)
for {
n, err := c.Read(buf)
if err != nil {
return
}
c.Write(buf[:n])
}
}(conn)
}
}()
// Open many concurrent connections
nConns := 1000
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < nConns; i++ {
wg.Add(1)
go func() {
defer wg.Done()
conn, _ := net.Dial("tcp", addr)
defer conn.Close()
msg := []byte("hello")
conn.Write(msg)
buf := make([]byte, len(msg))
conn.Read(buf)
}()
}
wg.Wait()
elapsed := time.Since(start)
ln.Close()
fmt.Printf("%d connections in %v\n", nConns, elapsed)
fmt.Printf("OS threads used: %d\n", runtime.GOMAXPROCS(0))
fmt.Println("(Each connection used a goroutine, not an OS thread)")
}
What to observe:
- 1000 concurrent connections handled by GOMAXPROCS threads
- The network poller (epoll/kqueue) multiplexes all the I/O
- Goroutines park on I/O and wake when data arrives
Tips for Exploration
-
Read the source with an editor, not a browser. IDE “go to definition” makes it easy to follow function calls through the runtime.
-
Set breakpoints in the runtime with Delve:
dlv debug myprogram.go (dlv) break runtime.schedule (dlv) continue -
Use
go build -gcflags='-S'to see the compiler output, including stack growth checks and function prologues. -
Read the comments first. The Go runtime has excellent documentation in source comments, especially at the top of
proc.go,malloc.go,mgc.go, andchan.go.
Further Reading
Recommended resources for deeper exploration of the topics covered in this course.
General Operating Systems
-
Operating Systems: Three Easy Pieces (Remzi & Andrea Arpaci-Dusseau) Free online textbook. Excellent coverage of virtualization, concurrency, and persistence. ostep.org
-
Linux Kernel Development, 3rd Edition (Robert Love) Practical guide to the Linux kernel internals. Good companion for understanding the kernel side of the system calls Go makes.
-
The Design and Implementation of the FreeBSD Operating System (McKusick et al.) Comprehensive reference for BSD internals, relevant for understanding kqueue and the macOS kernel (XNU/Darwin).
Go Runtime Internals
-
The Go runtime scheduler source code comments
src/runtime/proc.golines 24-116 contain an excellent design document explaining the GMP model and the rationale behind spinning threads. -
Go scheduler design document (Dmitry Vyukov) The original proposal for the current GMP scheduler. docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw
-
Go memory allocator design (based on TCMalloc)
src/runtime/malloc.golines 1-100 describe the allocator architecture. The design is based on TCMalloc (Thread-Caching Malloc) by Google. -
Getting to Go: The Journey of Go’s Garbage Collector (Rick Hudson) Talk describing the evolution of Go’s GC from STW to concurrent. blog.golang.org/ismmkeynote
-
Go GC design document
src/runtime/mgc.golines 1-110 describe the four GC phases in detail. -
A Guide to the Go Garbage Collector (official Go documentation) tip.golang.org/doc/gc-guide
Scheduling
-
Scheduling: Introduction (OSTEP Chapter 7) Covers basic scheduling algorithms: FIFO, SJF, STCF, Round Robin.
-
Multi-CPU Scheduling (OSTEP Chapter 10) Covers work stealing, per-CPU run queues, and load balancing — directly relevant to the Go scheduler design.
-
Work-Stealing scheduling (Blumofe & Leiserson, 1999) The foundational paper on work-stealing for parallel computation. “Scheduling Multithreaded Computations by Work Stealing”, JACM 46(5).
-
Contention-Aware Scheduling — Background on why the Go scheduler uses spinning threads and careful spinning→non-spinning transitions.
Concurrency and Synchronization
-
Futexes Are Tricky (Ulrich Drepper) Essential reading for understanding how futex-based mutexes work. Covers the subtleties of the futex API that Go’s
lock_futex.gonavigates. -
The Little Book of Semaphores (Allen Downey) Free online book with dozens of synchronization problems and solutions. greenteapress.com/semaphores
-
Communicating Sequential Processes (Tony Hoare, 1978) The theoretical foundation for Go’s channel-based concurrency model. usingcsp.com
-
Share Memory by Communicating (Go blog) Explains Go’s concurrency philosophy. go.dev/blog/codelab-share
Memory Management
-
TCMalloc: Thread-Caching Malloc (Google) The allocator design that inspired Go’s mcache/mcentral/mheap hierarchy. google.github.io/tcmalloc
-
Virtual Memory (OSTEP Chapters 13-23) Comprehensive coverage of address spaces, page tables, TLBs, and memory management policies.
-
Garbage Collection Handbook (Jones, Hosking, Moss) Authoritative reference on GC algorithms. Covers tri-color marking, write barriers, and concurrent collection.
I/O and File Systems
-
The epoll API (Linux man pages)
man 7 epoll— explains edge-triggered vs level-triggered semantics. -
kqueue and kevent (FreeBSD man pages)
man 2 kqueue— BSD’s event notification interface. -
The C10K Problem (Dan Kegel) Classic article on handling 10,000+ concurrent connections, motivating the design of epoll/kqueue and event-driven architectures.
-
I/O Multiplexing (OSTEP Chapter 36) Covers the evolution from blocking I/O to select/poll/epoll.
Go Source Code Exploration
The following files are the most rewarding to read in full:
| File | Lines | Topic |
|---|---|---|
runtime/proc.go | 8125 | Scheduler (start with lines 24-116) |
runtime/chan.go | 970 | Channel implementation |
runtime/select.go | 806 | Select statement |
runtime/sema.go | 542 | Semaphore with treap |
runtime/stack.go | 1430 | Stack management |
runtime/netpoll.go | 733 | Network poller interface |
runtime/netpoll_epoll.go | 177 | epoll implementation |
runtime/netpoll_kqueue.go | 184 | kqueue implementation |
runtime/malloc.go | 2553 | Memory allocator |
runtime/mgc.go | 2333 | Garbage collector |
runtime/runtime2.go | 1520 | Core data structures |
Tools for Exploration
-
go tool trace— Visualize goroutine scheduling, GC events, and system calls. Generate a trace withruntime/tracepackage, then view in browser. -
GODEBUG=schedtrace=1000— Print scheduler state every second. Shows number of goroutines, threads, and per-P run queue lengths. -
GODEBUG=gctrace=1— Print GC statistics after each collection. Shows pause times, heap sizes, and CPU utilization. -
strace/dtruss— Trace system calls made by a Go program. Useful for seeing the raw syscalls behind Go’s abstractions. -
perf(Linux) — Profile CPU usage, cache misses, and context switches. Can reveal scheduler overhead and synchronization contention.