The M:N Concurrent Model — A Complete Guide. From First Principles to Production Schedulers
Table of Contents
- The Three Threading Models
- Core Concepts and Terminology
- User-Space Context Switching
- The Scheduler: Policies and Data Structures
- Work Stealing
- Building an M:N Scheduler from Scratch in C
- Go's Goroutine Scheduler (GMP Model)
- Rust and Tokio: Async M:N
- Erlang/BEAM: Process-Based M:N
- Java Project Loom: Virtual Threads
- Channels and Communication
- Synchronization Primitives in M:N Runtimes
- I/O Integration and Event Loops
- Debugging and Observability
- Performance Analysis and Tuning
- Design Patterns for M:N Systems
Chapter 1: The Three Threading Models
To understand M:N threading, you must first understand what came before it and why each model was invented to solve the problems of its predecessor.
1.1 The N:1 Model — Green Threads
The earliest form of user-space concurrency mapped N user threads onto exactly 1 kernel thread. The programmer writes code with multiple logical threads; the runtime schedules them cooperatively on a single OS thread.
User space: [Thread A] [Thread B] [Thread C] [Thread D]
\ | | /
\ | | /
[ Runtime Scheduler ]
|
Kernel space: [Kernel Thread 1]
|
[CPU Core 0]
Advantages:
- Extremely cheap context switching (no kernel involvement)
- No race conditions on shared state between user threads (single-threaded kernel thread)
- Low memory overhead (stack can be small)
- Simple to implement
Disadvantages:
- Blocking syscalls block everything: If Thread A calls
read()on a socket with no data, the single kernel thread blocks. Thread B, C, and D all starve. This is catastrophic. - True parallelism is impossible: Even on a 128-core machine, you use one core.
- Cooperative scheduling hazards: A thread that never yields starves all others.
Early Java (1.1–1.2) used green threads. Early Ruby (MRI) used a similar model with the Global Interpreter Lock.
1.2 The 1:1 Model — Native Threads
The natural response to N:1's limitations: map every user thread to exactly one kernel thread.
User space: [Thread A] [Thread B] [Thread C] [Thread D]
| | | |
Kernel space: [KThread 1] [KThread 2] [KThread 3] [KThread 4]
| | | |
[CPU Core 0] [CPU Core 1] [CPU Core 2] [CPU Core 3]
Advantages:
- Blocking syscalls block only one thread — others continue
- True parallelism across cores
- OS scheduler handles preemption automatically
Disadvantages:
- Kernel thread is expensive: Each kernel thread requires a kernel stack (8 KB on Linux by default, but can grow to MB), kernel data structures, and scheduler bookkeeping.
- Context switching is expensive: A context switch requires entering kernel mode, saving/restoring kernel registers, TLB flushing on some architectures, and potentially cache disruption.
- Scale ceiling: Creating 10,000 threads for 10,000 concurrent connections consumes gigabytes of stack memory and brings the scheduler to its knees. The classic C10K problem.
- Thread creation latency:
pthread_createis not free. Creating thousands of threads per second is impractical.
Linux pthreads, Windows threads, and Java threads (post-1.2) all use 1:1.
1.3 The M:N Model — The Synthesis
The M:N model maps M user-space threads (often called green threads, goroutines, fibers, or tasks) onto N kernel threads, where N is typically bounded by the number of CPU cores.
User space: [G1][G2][G3][G4][G5][G6][G7][G8]...[G10000]
\ | | / \ | | /
[ Scheduler ] [ Scheduler ]
| |
Kernel space: [KThread 1] [KThread 2]
| |
[CPU Core 0] [CPU Core 1]
The key insight: N (kernel threads) matches hardware parallelism; M (green threads) matches logical concurrency.
With 4 cores, N=4. M can be 10,000. Each kernel thread runs a scheduler loop that picks a green thread from a run queue and executes it. When a green thread needs to block (I/O, mutex), instead of blocking the kernel thread, the runtime intercepts the operation, parks the green thread, and lets the kernel thread run another green thread.
Advantages:
- True parallelism: N kernel threads run on N cores simultaneously
- Massive concurrency: M can be 10^6 goroutines/tasks without memory explosion (each green thread starts with a tiny stack, e.g., 2 KB in Go)
- Cheap green thread creation: No kernel syscall, just allocate a small stack and push to a run queue
- Non-blocking I/O transparent to the programmer: The runtime automatically converts blocking I/O calls to async operations with green-thread parking
Disadvantages:
- Runtime complexity: The scheduler is a sophisticated piece of engineering
- Debugging difficulty: Stack traces, deadlock detection, and profiling are harder
- FFI complications: Calling C code that blocks can pin a kernel thread
- Signal handling complexity: Signals are delivered to kernel threads, not green threads
1.4 Comparing the Models
| Property | N:1 | 1:1 | M:N |
|---|---|---|---|
| True parallelism | ✗ | ✓ | ✓ |
| Cheap creation | ✓ | ✗ | ✓ |
| Cheap context switch | ✓ | ✗ | ✓ |
| Blocking I/O safe | ✗ | ✓ | ✓ |
| Scales to millions | ✓ | ✗ | ✓ |
| Preemptive scheduling | ✗ (usually) | ✓ | Both |
| Implementation complexity | Low | Low | High |
Chapter 2: Core Concepts and Terminology
2.1 The Taxonomy of Lightweight Concurrency
The ecosystem around M:N concurrency has an unfortunately inconsistent vocabulary. Different communities use different words for the same concepts. Let's establish precise definitions.
Green Thread
A user-space thread — a unit of execution with its own stack and register state, scheduled by a user-space runtime rather than the OS kernel. The term comes from Java's early green thread implementation. Often used interchangeably with "fiber" or "coroutine," though there are subtle distinctions.
Fiber
A cooperatively scheduled green thread. Fibers explicitly yield control; they do not preempt each other. C++ 20's std::coroutine, Windows Fibers, and many game engines use this model. The key property: a fiber switch only happens at explicit yield points.
Goroutine
Go's term for its lightweight concurrent functions. Goroutines are M:N scheduled, preemptible (since Go 1.14 via signal-based preemption), and start with a 2KB stack that grows dynamically.
Coroutine
A generalisation of functions that can suspend and resume execution. Coroutines can be symmetric (coroutines transfer control to each other directly) or asymmetric (a coroutine yields to its caller). Python's async def, Kotlin's suspend fun, and C++20 coroutines are asymmetric. Coroutines are the mechanism; green threads are the abstraction.
Task / Future / Promise
In async/await runtimes (Tokio, Node.js, Python asyncio), the schedulable unit is a task — a state machine compiled from async functions. Tasks are polled (pulled) rather than pushed by the scheduler.
Virtual Thread
Java's Project Loom terminology for its M:N green threads, mounted on carrier threads (OS threads). The distinction from goroutines is mostly marketing; the concept is identical.
Process (Erlang-style)
Erlang uses "process" for its lightweight concurrent units, which have no shared memory — communication is exclusively via message passing. Each Erlang process has its own heap; GC is per-process.
2.2 The Anatomy of a Green Thread
A green thread needs to maintain enough state to be suspended and resumed. Minimally:
struct GreenThread {
// Execution context
ucontext_t context; // Saved registers + stack pointer
void* stack; // Stack memory
size_t stackSize;
// Scheduling state
ThreadState state; // RUNNABLE, RUNNING, WAITING, DEAD
uint64_t id;
// Run queue linkage
GreenThread* next; // Intrusive linked list for run queue
// Blocking information
WaitChannel* waitChan; // What is this thread waiting for?
uint64_t wakeTime; // For sleep/timer-based waking
// For work stealing
int preferredWorker; // Locality hint
};
The ucontext_t (or an equivalent hand-rolled structure) stores:
- Instruction pointer (where to resume)
- Stack pointer
- Callee-saved general-purpose registers
- Floating point / SIMD state (sometimes lazily saved)
- Signal mask (sometimes)
2.3 Stack Management
Stack management is one of the most interesting engineering challenges in M:N runtimes.
Fixed Stacks
The simplest approach: allocate a fixed-size stack (e.g., 64 KB) per green thread. Fast and simple, but wastes memory for threads that use little stack, and overflows for threads with deep recursion.
// Fixed stack allocation
void* stack = mmap(NULL, STACK_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK,
-1, 0);
// Guard page at the bottom to catch stack overflow
mprotect(stack, PAGE_SIZE, PROT_NONE);
Segmented Stacks (Go ≤1.3, GCC split stacks)
The stack is divided into segments. When the stack is about to overflow, a new segment is allocated and linked to the old one. The function epilogue checks remaining stack space on every call.
; x86-64 segmented stack check (simplified)
; At the top of every function:
cmp rsp, [fs:thread_local_stack_limit]
jb .stack_overflow_handler ; Branch if we'd overflow
; ... function body ...
.stack_overflow_handler:
call __morestack ; Allocate new segment, re-call function
Problem: The "hot split" performance issue. If a function is called in a tight loop near a segment boundary, it repeatedly allocates and deallocates segments, causing catastrophic slowdown (10-100×).
Copying / Growing Stacks (Go ≥1.4)
Go's current approach: when the stack is about to overflow, copy the entire stack to a new, larger allocation. This requires that all stack pointers within the stack are updated (Go's garbage collector handles this since it knows all pointer locations).
Benefits: no hot-split problem, amortized O(1) growth. Costs: all interior stack pointers must be updated, complex interaction with the GC.
Stack Pools
Pre-allocate stacks and recycle them. Most production runtimes do this to amortize allocation cost:
struct StackPool {
Stack* freelist[NUM_SIZE_CLASSES]; // Size classes: 4K, 8K, 16K, ...
SpinLock lock;
};
Stack* stackGet(size_t minSize) {
int sizeClass = sizeToClass(minSize);
lock(&pool.lock);
Stack* s = pool.freelist[sizeClass];
if (s) pool.freelist[sizeClass] = s->next;
unlock(&pool.lock);
return s ? s : stackAlloc(classToSize(sizeClass));
}
2.4 The Run Queue
The run queue holds green threads that are ready to execute but not currently running. Design choices:
Global queue (simple): One queue for all N kernel threads. Simple but causes contention — every thread must lock the queue to push/pop.
Per-worker local queues (most production systems): Each kernel thread ("worker" or "P" in Go) has its own local queue. Green threads are pushed locally first. Only when the local queue is empty does the worker attempt to steal from other workers or pull from a global overflow queue.
// Per-worker run queue (lock-free ring buffer)
#define LOCAL_QUEUE_CAP 256
struct LocalQueue {
_Atomic uint32_t head; // Producers write here (work stealer)
_Atomic uint32_t tail; // Owner writes here
GreenThread* buf[LOCAL_QUEUE_CAP];
};
// Owner pushes to tail (only owner does this, no lock needed)
bool localQueuePush(LocalQueue* q, GreenThread* g) {
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
uint32_t head = atomic_load_explicit(&q->head, memory_order_acquire);
if (tail - head >= LOCAL_QUEUE_CAP) return false; // Full
q->buf[tail % LOCAL_QUEUE_CAP] = g;
atomic_store_explicit(&q->tail, tail + 1, memory_order_release);
return true;
}
// Owner pops from tail (LIFO for cache locality)
GreenThread* localQueuePop(LocalQueue* q) {
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
if (tail == atomic_load_explicit(&q->head, memory_order_relaxed))
return NULL; // Empty
tail--;
atomic_store_explicit(&q->tail, tail, memory_order_relaxed);
GreenThread* g = q->buf[tail % LOCAL_QUEUE_CAP];
if (atomic_load_explicit(&q->head, memory_order_acquire) > tail) {
// Stolen! Undo
atomic_store_explicit(&q->tail, tail + 1, memory_order_relaxed);
return NULL;
}
return g;
}
Chapter 3: User-Space Context Switching
The fundamental operation of any M:N scheduler is switching execution context from one green thread to another without involving the kernel. This chapter explores exactly how that works at the hardware level.
3.1 What Is a Context Switch?
A context switch saves the current thread's register state to memory and loads another thread's register state. For a kernel context switch, this is done by the OS scheduler in a privileged mode transition. For a user-space context switch, we do it entirely in user code.
The registers we must save depend on the calling convention. On x86-64 (System V ABI):
Caller-saved (scratch) registers — the function being switched away from is responsible for saving these if it needs them. The switch function doesn't need to save them:
rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11xmm0–xmm15(SSE/AVX)
Callee-saved registers — the function being switched to must preserve these. The switch function must save and restore these:
rbx, rbp, r12, r13, r14, r15rsp(stack pointer)
The instruction pointer (rip) is implicitly saved as the return address on the stack when we call the switch function.
3.2 The ucontext API (POSIX)
POSIX provides ucontext_t for portable context manipulation:
#include <ucontext.h>
#include <stdlib.h>
#include <stdio.h>
#define STACK_SIZE (64 * 1024) // 64 KB
ucontext_t main_ctx, fiber_ctx;
void fiber_function() {
printf("Fiber: Hello from fiber!\n");
swapcontext(&fiber_ctx, &main_ctx); // Yield back to main
printf("Fiber: Resumed!\n");
swapcontext(&fiber_ctx, &main_ctx); // Done, back to main
}
int main() {
char* stack = malloc(STACK_SIZE);
// Set up the fiber context
getcontext(&fiber_ctx);
fiber_ctx.uc_stack.ss_sp = stack;
fiber_ctx.uc_stack.ss_size = STACK_SIZE;
fiber_ctx.uc_link = &main_ctx; // Return to main when done
makecontext(&fiber_ctx, fiber_function, 0);
printf("Main: Switching to fiber\n");
swapcontext(&main_ctx, &fiber_ctx); // Switch to fiber
printf("Main: Back from first yield\n");
swapcontext(&main_ctx, &fiber_ctx); // Resume fiber
printf("Main: Fiber finished\n");
free(stack);
return 0;
}
Output:
Main: Switching to fiber
Fiber: Hello from fiber!
Main: Back from first yield
Fiber: Resumed!
Main: Fiber finished
swapcontext saves the current context (registers + stack pointer) into the first argument and loads the context from the second argument. It's essentially:
- Push all callee-saved registers onto the current stack
- Save the current
rspintoctx_from->uc_mcontext.gregs[REG_RSP] - Save the return address (
rip) as well - Load
rspfromctx_to->uc_mcontext.gregs[REG_RSP] - Pop all callee-saved registers from the new stack
- Return (which jumps to the saved
rip)
3.3 Hand-Rolled Context Switching in Assembly
ucontext has overhead from saving signal masks (a syscall!). Production runtimes implement their own context switch in assembly. Here's a minimal x86-64 implementation:
; context.asm — Minimal x86-64 context switch
; Calling convention: switch_context(from_ctx, to_ctx)
; rdi = from_ctx (pointer to Context struct)
; rsi = to_ctx (pointer to Context struct)
;
; Context layout (offsets must match C struct):
; 0: rbx
; 8: rbp
; 16: r12
; 24: r13
; 32: r14
; 40: r15
; 48: rsp
; 56: rip (return address)
section .text
global switch_context
switch_context:
; ── Save current context into *from_ctx ──
mov [rdi + 0], rbx
mov [rdi + 8], rbp
mov [rdi + 16], r12
mov [rdi + 24], r13
mov [rdi + 32], r14
mov [rdi + 40], r15
; Save stack pointer
mov [rdi + 48], rsp
; Save return address (the instruction after the call to switch_context)
mov rax, [rsp] ; rax = return address (top of stack)
mov [rdi + 56], rax
; ── Load new context from *to_ctx ──
mov rbx, [rsi + 0]
mov rbp, [rsi + 8]
mov r12, [rsi + 16]
mov r13, [rsi + 24]
mov r14, [rsi + 32]
mov r15, [rsi + 40]
; Restore stack pointer
mov rsp, [rsi + 48]
; Jump to saved return address (push onto new stack, then ret)
mov rax, [rsi + 56]
push rax
ret ; "Return" to the saved rip of to_ctx
The matching C struct:
typedef struct {
uint64_t rbx, rbp, r12, r13, r14, r15;
uint64_t rsp;
uint64_t rip;
} Context;
extern void switch_context(Context* from, Context* to);
// Initialize a new context to start at 'func' with the given stack
void context_init(Context* ctx, void (*func)(void), void* stack, size_t stack_size) {
// Stack grows downward; place the initial stack pointer at the top
uint64_t* sp = (uint64_t*)((char*)stack + stack_size);
// The "return address" for the initial call — if the function returns,
// it returns to thread_exit (which cleans up)
*--sp = (uint64_t)thread_exit;
// The first "return" from switch_context jumps here
*--sp = (uint64_t)func;
ctx->rsp = (uint64_t)sp;
ctx->rip = (uint64_t)func;
// Callee-saved registers: initialize to 0
ctx->rbx = ctx->rbp = ctx->r12 = ctx->r13 = ctx->r14 = ctx->r15 = 0;
}
This context switch is ~10 nanoseconds on modern hardware — compared to ~1–5 microseconds for a kernel context switch. That's 100–500× faster.
3.4 ARM64 Context Switch
For completeness, the ARM64 (AArch64) equivalent:
// context_arm64.S — AArch64 context switch
// Callee-saved registers on AArch64: x19-x28, x29 (fp), x30 (lr), d8-d15
.global switch_context
.type switch_context, %function
switch_context:
// Save to *x0 (from_ctx)
stp x19, x20, [x0, #0]
stp x21, x22, [x0, #16]
stp x23, x24, [x0, #32]
stp x25, x26, [x0, #48]
stp x27, x28, [x0, #64]
stp x29, x30, [x0, #80] // x29=frame pointer, x30=link register
mov x2, sp
str x2, [x0, #96] // Save stack pointer
// Optionally save float regs (d8-d15)
stp d8, d9, [x0, #104]
stp d10, d11, [x0, #120]
stp d12, d13, [x0, #136]
stp d14, d15, [x0, #152]
// Load from *x1 (to_ctx)
ldp x19, x20, [x1, #0]
ldp x21, x22, [x1, #16]
ldp x23, x24, [x1, #32]
ldp x25, x26, [x1, #48]
ldp x27, x28, [x1, #64]
ldp x29, x30, [x1, #80]
ldr x2, [x1, #96]
mov sp, x2
ldp d8, d9, [x1, #104]
ldp d10, d11, [x1, #120]
ldp d12, d13, [x1, #136]
ldp d14, d15, [x1, #152]
ret // Return to x30 (link register) = saved rip equivalent
3.5 The Cost of Context Switches
Even user-space context switches are not free. What makes them slow:
Register save/restore: ~10–20 cycles for the minimal set (no FPU). With FPU/SIMD state: ~50–100 cycles.
Cache effects: When thread A yields and thread B runs, thread B's data is likely not in L1/L2 cache. The first few memory accesses will be cache misses. This cache pollution cost is often larger than the mechanical register swap. This is why LIFO scheduling (run the most recently suspended thread next) can improve cache utilization.
TLB effects: If threads share the same address space (which they do in M:N runtimes), TLB entries remain valid. This is a significant advantage over process context switches.
Branch predictor pollution: The switch displaces branch predictor history. Minor but non-zero.
Practical measurements on a modern x86-64 machine:
- Bare
switch_contextcall: ~8–15 ns - Cold cache miss after switch: adds ~50–200 ns depending on working set
- Go's
runtime.Gosched(): ~100–200 ns total pthreadcontext switch (voluntary): ~2–5 µspthreadcontext switch (involuntary): ~5–20 µs
Chapter 4: The Scheduler — Policies and Data Structures
The scheduler is the heart of an M:N runtime. It decides which green thread runs next, how to distribute work across kernel threads, and how to handle blocking operations.
4.1 Scheduler Topology
A production M:N scheduler has several components:
┌─────────────────────────────────────────────────────────┐
│ Global State │
│ globalQueue (overflow) │ allThreads (for GC/debug) │
│ idleWorkers │ newThreadChan │
└───────────────────────────┴─────────────────────────────┘
│ │
┌─────┴──────┐ ┌──────┴─────┐
│ Worker 0 │ │ Worker 1 │
│ (OS Thread)│ │ (OS Thread)│
│ │ │ │
│ localQueue │ │ localQueue │
│ [G3][G7] │ │ [G1][G5] │
│ │ │ │
│ current: │ │ current: │
│ G9 │ │ G2 │
└────────────┘ └────────────┘
Key components:
Worker (P in Go): A kernel thread actively running green threads. There are exactly GOMAXPROCS workers (Go) or tokio::runtime::Builder::worker_threads (Tokio).
Local run queue: Per-worker, holds green threads ready to run. Accessed without locks by the owning worker; stolen from by other workers using lock-free operations.
Global run queue: Overflow queue when local queues are full. Protected by a mutex.
Idle list: Workers that have found no work sleep here. New green threads wake idle workers.
Network poller: Integration with epoll/kqueue/io_uring that wakes parked threads when I/O is ready.
4.2 Scheduling Policies
FIFO (First In, First Out)
The simplest policy. Runnable threads go to the back of the queue; the scheduler picks from the front.
Enqueue: [G1] → [G2] → [G3] → [G4] (newest at back)
Dequeue: G1 (oldest first)
Good for fairness (no starvation), bad for cache locality (a thread's data is cold by the time it runs again).
LIFO (Last In, First Out)
The most recently suspended thread runs next. Used by Go's local queues (push/pop from the same end).
Enqueue: push G5 onto top: [G5][G4][G3][G2][G1]
Dequeue: pop G5 from top
Excellent cache locality — the thread that just ran likely has warm caches. Risk of starvation if a thread always yields immediately.
Work Stealing
Each worker prefers its local queue (LIFO). When empty, it steals from another worker's queue (FIFO, taking from the opposite end to reduce contention). This is the de-facto standard policy and is covered in detail in the next chapter.
Priority Scheduling
Some runtimes (e.g., real-time systems) assign priorities to green threads. Not common in general-purpose M:N runtimes due to priority inversion complexity.
4.3 Scheduling Points
A scheduling point is a moment where the scheduler may switch to a different green thread. In a purely cooperative scheduler, these must be explicit. In a preemptive scheduler, they can happen at any time.
Cooperative scheduling points:
- Explicit
yield()call - Channel send/receive
- Mutex lock (when already locked)
- I/O operation
sleep()- Green thread creation (sometimes)
Preemptive scheduling points (added in Go 1.14):
- Signal delivery (Go uses
SIGURG) - Function prologues (checked by the compiler)
- Safe points inserted by the compiler/JIT
Preemption solves a critical problem: a tight compute loop with no I/O or yield calls would otherwise starve other green threads. Here's the problem:
// This goroutine would starve all others without preemption
go func() {
for {
sum := 0
for i := 0; i < 1_000_000; i++ {
sum += i // No function call = no preemption point (old Go)
}
}
}()
Go's solution since 1.14: the signal handler for SIGURG checks if a goroutine has been running too long. If so, it sets a flag in the goroutine's stack, and the goroutine is preempted at the next safe point (a point where the GC can scan the stack).
4.4 The Scheduler Loop
The main scheduler loop on each worker thread:
void scheduler_loop(Worker* w) {
while (true) {
GreenThread* g = NULL;
// 1. Try local queue first (LIFO for cache locality)
g = localQueuePop(&w->localQueue);
if (g) goto execute;
// 2. Try to drain some from global queue
// (check every ~61 iterations to ensure fairness)
if (w->schedTick % 61 == 0) {
g = globalQueuePop();
if (g) goto execute;
}
// 3. Check network poller (non-blocking)
g = netpollCheck(0); // timeout=0: don't block
if (g) goto execute;
// 4. Try to steal from another worker
g = stealFromOther(w);
if (g) goto execute;
// 5. Block on network poller (now we really have no work)
g = netpollCheck(-1); // timeout=-1: block until event
if (g) {
globalQueuePush(g); // Others may have stolen work; don't assume local
continue;
}
// 6. Park this worker (sleep, waiting for work)
workerPark(w);
// When woken, go back to top
continue;
execute:
w->current = g;
g->state = RUNNING;
switch_context(&w->schedulerCtx, &g->context);
// After switch_context returns: g has yielded/finished
w->current = NULL;
postSchedule(w, g); // Put g back in queue or mark as dead
}
}
The %61 check for the global queue is a technique borrowed from Go. If workers only checked local queues, newly created goroutines pushed to the global queue might never run. The prime number 61 spreads the global checks to avoid synchronisation bursts.
4.5 Goroutine State Machine
A green thread's lifecycle:
spawn()
│
▼
┌──────────┐
│ RUNNABLE │◄──────────────────────────────┐
└────┬─────┘ │
│ scheduler picks it │
▼ │
┌──────────┐ yield() / │
│ RUNNING │──── I/O / mutex ──► WAITING │
└────┬─────┘ │ │
│ returns │ event │
▼ └─────────┘
┌──────────┐
│ DEAD │
└──────────┘
State transitions:
RUNNABLE → RUNNING: Scheduler picks this thread and switches to itRUNNING → RUNNABLE:yield()or preemption (goes back to run queue)RUNNING → WAITING: Blocks on I/O, mutex, channel, sleepWAITING → RUNNABLE: I/O ready, mutex unlocked, channel has data, timer firesRUNNING → DEAD: Function returns
Chapter 5: Work Stealing
Work stealing is the scheduling algorithm that makes M:N runtimes efficient on multi-core machines. Without it, the naive approach would have workers sitting idle while other workers are overloaded.
5.1 The Problem: Load Imbalance
Imagine 4 workers and 8 green threads, initially:
Worker 0: [G1][G2][G3][G4][G5][G6][G7][G8]
Worker 1: (empty)
Worker 2: (empty)
Worker 3: (empty)
Without work stealing, Worker 0 does all the work while 1, 2, 3 are idle. We have 4 cores but use 1.
5.2 The Work Stealing Algorithm
The algorithm, originally from Cilk (MIT, 1994):
- Each worker has a deque (double-ended queue)
- Owner (the worker that owns the deque) pushes and pops from the bottom (LIFO)
- Stealers pop from the top (FIFO) — opposite end from the owner
Why opposite ends? Reduces contention. The owner accesses the bottom without needing CAS operations in the common case. Stealers access the top with a CAS.
Owner pushes/pops here: Stealers steal from here:
▼ ▼
[Bottom] ←───────────────────────► [Top]
G8 G7 G6 G5 G4 G3 G2 G1
The stealing policy:
- A thief picks a victim (usually at random, or round-robin)
- Steals half the victim's deque (not just one item — amortises the overhead)
5.3 Lock-Free Work-Stealing Deque (Chase-Lev)
The Chase-Lev deque (2005) is the standard lock-free implementation used by Go, Tokio, and many other runtimes:
// Chase-Lev work-stealing deque
// "Dynamic Circular Work-Stealing Deque" — Chase & Lev, 2005
#define INITIAL_CAP 64
typedef struct {
_Atomic(int64_t) top; // Stealers access this
_Atomic(int64_t) bottom; // Owner accesses this
_Atomic(GreenThread**) buf; // The circular buffer
int64_t capacity;
} ChaseLevDeque;
// Owner pushes a task (single producer)
void deque_push(ChaseLevDeque* d, GreenThread* g) {
int64_t b = atomic_load_explicit(&d->bottom, memory_order_relaxed);
int64_t t = atomic_load_explicit(&d->top, memory_order_acquire);
GreenThread** buf = atomic_load_explicit(&d->buf, memory_order_relaxed);
if (b - t > d->capacity - 1) {
// Grow the deque
buf = deque_grow(d, buf, b, t);
}
buf[b % d->capacity] = g;
atomic_thread_fence(memory_order_release);
atomic_store_explicit(&d->bottom, b + 1, memory_order_relaxed);
}
// Owner pops its own task (single consumer from bottom)
GreenThread* deque_pop(ChaseLevDeque* d) {
int64_t b = atomic_load_explicit(&d->bottom, memory_order_relaxed) - 1;
GreenThread** buf = atomic_load_explicit(&d->buf, memory_order_relaxed);
atomic_store_explicit(&d->bottom, b, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst);
int64_t t = atomic_load_explicit(&d->top, memory_order_relaxed);
GreenThread* g = NULL;
if (t <= b) {
// Non-empty
g = buf[b % d->capacity];
if (t == b) {
// Last item: race with stealers
int64_t expected = t;
if (!atomic_compare_exchange_strong_explicit(
&d->top, &expected, t + 1,
memory_order_seq_cst, memory_order_relaxed)) {
g = NULL; // Stealer got it
}
atomic_store_explicit(&d->bottom, b + 1, memory_order_relaxed);
}
} else {
// Empty
atomic_store_explicit(&d->bottom, b + 1, memory_order_relaxed);
}
return g;
}
// Stealer takes a task from the top (multiple producers possible)
GreenThread* deque_steal(ChaseLevDeque* d) {
int64_t t = atomic_load_explicit(&d->top, memory_order_acquire);
atomic_thread_fence(memory_order_seq_cst);
int64_t b = atomic_load_explicit(&d->bottom, memory_order_acquire);
if (t >= b) return NULL; // Empty
GreenThread** buf = atomic_load_explicit(&d->buf, memory_order_consume);
GreenThread* g = buf[t % d->capacity];
int64_t expected = t;
if (!atomic_compare_exchange_strong_explicit(
&d->top, &expected, t + 1,
memory_order_seq_cst, memory_order_relaxed)) {
return NULL; // Lost the race to another stealer
}
return g;
}
5.4 Stealing Strategy
When should a worker steal, and from whom?
Random victim: Pick a random other worker. Simple, avoids hot-spots. Used by Go.
Round-robin: Try workers in order. Predictable, good when workers have similar load.
Steal half: When stealing, take half the victim's queue. This is more efficient than stealing one at a time — it amortises the CAS overhead and reduces future stealing.
// Steal up to half from victim's local queue
int stealHalf(LocalQueue* dst, LocalQueue* src) {
int32_t head = atomic_load_explicit(&src->head, memory_order_acquire);
int32_t tail = atomic_load_explicit(&src->tail, memory_order_relaxed);
int32_t n = tail - head;
int32_t steal = n - n/2; // Steal half, round up
if (steal <= 0) return 0;
for (int32_t i = 0; i < steal; i++) {
GreenThread* g = src->buf[(head + i) % LOCAL_QUEUE_CAP];
dst->buf[(dst->tail + i) % LOCAL_QUEUE_CAP] = g;
}
int32_t expected = head;
if (!atomic_compare_exchange_strong_explicit(
&src->head, &expected, head + steal,
memory_order_acq_rel, memory_order_acquire)) {
return 0; // Another stealer raced us
}
atomic_fetch_add_explicit(&dst->tail, steal, memory_order_release);
return steal;
}
5.5 Work Stealing and Cache Topology
On NUMA (Non-Uniform Memory Access) systems, stealing from a remote NUMA node is more expensive than stealing locally. Some runtimes use NUMA-aware stealing:
- First try to steal from workers on the same socket
- Only if that fails, steal from remote sockets
GreenThread* stealFromOther(Worker* w) {
int numWorkers = getNumWorkers();
// Phase 1: Try workers on the same NUMA node first
for (int i = 0; i < numWorkers; i++) {
Worker* victim = &workers[(w->id + i) % numWorkers];
if (victim == w) continue;
if (victim->numaNode != w->numaNode) continue; // Skip remote NUMA
GreenThread* g = deque_steal(&victim->deque);
if (g) return g;
}
// Phase 2: Try remote NUMA nodes
for (int i = 0; i < numWorkers; i++) {
Worker* victim = &workers[(w->id + i) % numWorkers];
if (victim == w) continue;
GreenThread* g = deque_steal(&victim->deque);
if (g) return g;
}
return NULL;
}
Chapter 6: Building an M:N Scheduler from Scratch in C
Theory is best understood through implementation. In this chapter we build a minimal but complete M:N scheduler in C: multiple OS threads, a shared run queue, user-space context switching, and basic I/O integration.
6.1 Data Structures
// scheduler.h
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdatomic.h>
#include <pthread.h>
#include <ucontext.h>
#define MAX_WORKERS 16
#define STACK_SIZE (128 * 1024) // 128 KB per green thread
#define LOCAL_QUEUE_CAP 256
#define GLOBAL_QUEUE_CAP 4096
// ── Green thread states ──────────────────────────────
typedef enum {
GT_RUNNABLE,
GT_RUNNING,
GT_WAITING,
GT_DEAD,
} GTState;
// ── Green thread (G) ─────────────────────────────────
typedef struct GreenThread GreenThread;
struct GreenThread {
ucontext_t ctx; // Execution context
void* stack; // Stack memory
GTState state;
uint64_t id;
GreenThread* next; // Intrusive list link
int workerHint; // Preferred worker (-1 = any)
// For blocking
int waitFd; // fd we're waiting on (-1 = none)
short waitEvents; // POLLIN / POLLOUT
// Function + arg
void (*fn)(void*);
void* arg;
};
// ── Per-worker local queue (ring buffer) ─────────────
typedef struct {
_Atomic uint32_t head;
_Atomic uint32_t tail;
GreenThread* buf[LOCAL_QUEUE_CAP];
} LocalQueue;
// ── Worker (M — one per OS thread) ──────────────────
typedef struct {
pthread_t tid;
int id;
ucontext_t schedulerCtx; // Scheduler coroutine context
GreenThread* current; // Currently running green thread
LocalQueue localQ;
uint64_t schedTick;
} Worker;
// ── Global scheduler state ────────────────────────────
typedef struct {
// Workers
Worker workers[MAX_WORKERS];
int numWorkers;
// Global run queue (protected by lock)
pthread_mutex_t globalLock;
GreenThread* globalHead;
GreenThread* globalTail;
int globalLen;
// Green thread ID counter
_Atomic uint64_t nextID;
// Stack pool
pthread_mutex_t stackLock;
void* stackPool[64];
int stackPoolLen;
// Idle workers (sleeping, waiting for work)
pthread_mutex_t idleLock;
pthread_cond_t idleCond;
int idleCount;
// Shutdown flag
_Atomic bool shutdown;
} Scheduler;
extern Scheduler sched;
extern __thread Worker* currentWorker; // Thread-local current worker
// ── Public API ────────────────────────────────────────
void scheduler_init(int numWorkers);
void scheduler_run(void);
void scheduler_shutdown(void);
uint64_t go(void (*fn)(void*), void* arg);
void yield(void);
void sleep_ms(int ms);
6.2 Implementation
// scheduler.c
#include "scheduler.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <time.h>
#include <assert.h>
Scheduler sched;
__thread Worker* currentWorker = NULL;
// ── Stack pool ────────────────────────────────────────
static void* stackAlloc(void) {
pthread_mutex_lock(&sched.stackLock);
if (sched.stackPoolLen > 0) {
void* s = sched.stackPool[--sched.stackPoolLen];
pthread_mutex_unlock(&sched.stackLock);
return s;
}
pthread_mutex_unlock(&sched.stackLock);
// Allocate with guard page
void* mem = mmap(NULL, STACK_SIZE + 4096,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) {
perror("mmap stack");
abort();
}
// Guard page at the bottom
mprotect(mem, 4096, PROT_NONE);
return (char*)mem + 4096;
}
static void stackFree(void* stack) {
pthread_mutex_lock(&sched.stackLock);
if (sched.stackPoolLen < 64) {
sched.stackPool[sched.stackPoolLen++] = stack;
pthread_mutex_unlock(&sched.stackLock);
return;
}
pthread_mutex_unlock(&sched.stackLock);
munmap((char*)stack - 4096, STACK_SIZE + 4096);
}
// ── Green thread entry trampoline ─────────────────────
static void greenThreadEntry(void) {
Worker* w = currentWorker;
GreenThread* g = w->current;
// Call the user function
g->fn(g->arg);
// Done — mark dead and yield back to scheduler
g->state = GT_DEAD;
// Switch back to scheduler context
swapcontext(&g->ctx, &w->schedulerCtx);
// Should never reach here
abort();
}
// ── Green thread creation ─────────────────────────────
GreenThread* greenThreadCreate(void (*fn)(void*), void* arg) {
GreenThread* g = calloc(1, sizeof(GreenThread));
if (!g) abort();
g->id = atomic_fetch_add(&sched.nextID, 1);
g->fn = fn;
g->arg = arg;
g->state = GT_RUNNABLE;
g->stack = stackAlloc();
g->waitFd = -1;
g->workerHint = -1;
// Set up the ucontext
getcontext(&g->ctx);
g->ctx.uc_stack.ss_sp = g->stack;
g->ctx.uc_stack.ss_size = STACK_SIZE;
g->ctx.uc_link = NULL; // We handle return manually
makecontext(&g->ctx, greenThreadEntry, 0);
return g;
}
// ── Local queue operations ────────────────────────────
static bool localQueuePush(LocalQueue* q, GreenThread* g) {
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
uint32_t head = atomic_load_explicit(&q->head, memory_order_acquire);
if (tail - head >= LOCAL_QUEUE_CAP) return false;
q->buf[tail % LOCAL_QUEUE_CAP] = g;
atomic_store_explicit(&q->tail, tail + 1, memory_order_release);
return true;
}
static GreenThread* localQueuePop(LocalQueue* q) {
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
uint32_t head = atomic_load_explicit(&q->head, memory_order_relaxed);
if (tail == head) return NULL;
tail--;
atomic_store_explicit(&q->tail, tail, memory_order_relaxed);
GreenThread* g = q->buf[tail % LOCAL_QUEUE_CAP];
if (head > tail) {
// Lost to a stealer — undo
atomic_store_explicit(&q->tail, tail + 1, memory_order_relaxed);
return NULL;
}
return g;
}
static GreenThread* localQueueSteal(LocalQueue* src, LocalQueue* dst) {
// Steal half from src into dst
uint32_t head = atomic_load_explicit(&src->head, memory_order_acquire);
uint32_t tail = atomic_load_explicit(&src->tail, memory_order_relaxed);
uint32_t n = tail - head;
uint32_t steal = n - n / 2;
if (steal == 0) return NULL;
for (uint32_t i = 0; i < steal; i++) {
GreenThread* g = src->buf[(head + i) % LOCAL_QUEUE_CAP];
uint32_t dtail = atomic_load_explicit(&dst->tail, memory_order_relaxed);
dst->buf[dtail % LOCAL_QUEUE_CAP] = g;
atomic_store_explicit(&dst->tail, dtail + 1, memory_order_release);
}
uint32_t expected = head;
if (!atomic_compare_exchange_strong_explicit(
&src->head, &expected, head + steal,
memory_order_acq_rel, memory_order_relaxed)) {
// Race: undo our dst writes by rolling back tail
uint32_t dtail = atomic_load_explicit(&dst->tail, memory_order_relaxed);
atomic_store_explicit(&dst->tail, dtail - steal, memory_order_relaxed);
return NULL;
}
// Return the first stolen item to run now
uint32_t dtail = atomic_load_explicit(&dst->tail, memory_order_relaxed) - 1;
atomic_store_explicit(&dst->tail, dtail, memory_order_relaxed);
return dst->buf[dtail % LOCAL_QUEUE_CAP];
}
// ── Global queue ──────────────────────────────────────
static void globalQueuePush(GreenThread* g) {
g->next = NULL;
pthread_mutex_lock(&sched.globalLock);
if (sched.globalTail) sched.globalTail->next = g;
else sched.globalHead = g;
sched.globalTail = g;
sched.globalLen++;
pthread_mutex_unlock(&sched.globalLock);
}
static GreenThread* globalQueuePop(void) {
pthread_mutex_lock(&sched.globalLock);
GreenThread* g = sched.globalHead;
if (g) {
sched.globalHead = g->next;
if (!sched.globalHead) sched.globalTail = NULL;
sched.globalLen--;
g->next = NULL;
}
pthread_mutex_unlock(&sched.globalLock);
return g;
}
// ── Enqueue a green thread for execution ──────────────
void enqueue(GreenThread* g) {
g->state = GT_RUNNABLE;
Worker* w = currentWorker;
// Prefer local queue if we're on a worker
if (w && localQueuePush(&w->localQ, g)) {
// Wake an idle worker if available
pthread_mutex_lock(&sched.idleLock);
if (sched.idleCount > 0) {
pthread_cond_signal(&sched.idleCond);
}
pthread_mutex_unlock(&sched.idleLock);
return;
}
// Overflow to global queue
globalQueuePush(g);
// Wake idle worker
pthread_mutex_lock(&sched.idleLock);
if (sched.idleCount > 0) pthread_cond_signal(&sched.idleCond);
pthread_mutex_unlock(&sched.idleLock);
}
// ── Find next green thread to run ────────────────────
static GreenThread* findRunnable(Worker* w) {
GreenThread* g;
// 1. Local queue (LIFO)
g = localQueuePop(&w->localQ);
if (g) return g;
// 2. Global queue (every 61 ticks for fairness)
if (w->schedTick % 61 == 0) {
g = globalQueuePop();
if (g) return g;
}
// 3. Steal from another worker
for (int i = 1; i < sched.numWorkers; i++) {
int victim = (w->id + i) % sched.numWorkers;
g = localQueueSteal(&sched.workers[victim].localQ, &w->localQ);
if (g) return g;
}
// 4. Global queue (last resort)
g = globalQueuePop();
return g;
}
// ── The scheduler loop ────────────────────────────────
static void schedulerLoop(Worker* w) {
while (!atomic_load(&sched.shutdown)) {
w->schedTick++;
GreenThread* g = findRunnable(w);
if (!g) {
// No work — park this worker
pthread_mutex_lock(&sched.idleLock);
sched.idleCount++;
// Double check while holding lock
g = globalQueuePop();
if (g) {
sched.idleCount--;
pthread_mutex_unlock(&sched.idleLock);
goto run;
}
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 10 * 1000000; // 10ms timeout
if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; }
pthread_cond_timedwait(&sched.idleCond, &sched.idleLock, &ts);
sched.idleCount--;
pthread_mutex_unlock(&sched.idleLock);
continue;
}
run:
w->current = g;
g->state = GT_RUNNING;
// Switch to the green thread
swapcontext(&w->schedulerCtx, &g->ctx);
// We're back — the green thread yielded or finished
w->current = NULL;
if (g->state == GT_DEAD) {
// Free resources
stackFree(g->stack);
free(g);
}
// If GT_RUNNABLE: already re-enqueued by yield()
// If GT_WAITING: will be re-enqueued when its event fires
}
}
// ── Worker OS thread entry ────────────────────────────
static void* workerThread(void* arg) {
Worker* w = (Worker*)arg;
currentWorker = w;
schedulerLoop(w);
return NULL;
}
// ── Public API ────────────────────────────────────────
void scheduler_init(int numWorkers) {
if (numWorkers > MAX_WORKERS) numWorkers = MAX_WORKERS;
memset(&sched, 0, sizeof(sched));
sched.numWorkers = numWorkers;
pthread_mutex_init(&sched.globalLock, NULL);
pthread_mutex_init(&sched.stackLock, NULL);
pthread_mutex_init(&sched.idleLock, NULL);
pthread_cond_init (&sched.idleCond, NULL);
for (int i = 0; i < numWorkers; i++) {
Worker* w = &sched.workers[i];
w->id = i;
memset(&w->localQ, 0, sizeof(w->localQ));
}
}
void scheduler_run(void) {
// Start worker threads (all but the current thread)
for (int i = 1; i < sched.numWorkers; i++) {
pthread_create(&sched.workers[i].tid, NULL,
workerThread, &sched.workers[i]);
}
// Main thread becomes worker 0
currentWorker = &sched.workers[0];
schedulerLoop(&sched.workers[0]);
}
void scheduler_shutdown(void) {
atomic_store(&sched.shutdown, true);
pthread_mutex_lock(&sched.idleLock);
pthread_cond_broadcast(&sched.idleCond);
pthread_mutex_unlock(&sched.idleLock);
for (int i = 1; i < sched.numWorkers; i++) {
pthread_join(sched.workers[i].tid, NULL);
}
}
uint64_t go(void (*fn)(void*), void* arg) {
GreenThread* g = greenThreadCreate(fn, arg);
enqueue(g);
return g->id;
}
void yield(void) {
Worker* w = currentWorker;
if (!w || !w->current) return;
GreenThread* g = w->current;
g->state = GT_RUNNABLE;
enqueue(g);
// Switch back to scheduler
swapcontext(&g->ctx, &w->schedulerCtx);
}
void sleep_ms(int ms) {
// Simple busy-wait sleep (real impl would use timerfd/kqueue)
struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L};
// A real impl would park the goroutine and register a timer callback
nanosleep(&ts, NULL);
}
6.3 Using the Scheduler
// example.c
#include "scheduler.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
// Fibonacci with cooperative concurrency
void fib_worker(void* arg) {
long n = (long)arg;
long a = 0, b = 1;
for (long i = 0; i < n; i++) {
long c = a + b;
a = b;
b = c;
if (i % 10000 == 0) yield(); // Cooperative yield
}
printf("fib(%ld) last = %ld\n", n, a);
}
void counter(void* arg) {
int id = (int)(intptr_t)arg;
for (int i = 0; i < 5; i++) {
printf("Counter %d: tick %d\n", id, i);
yield();
}
}
void spawn_many(void* arg) {
int n = (int)(intptr_t)arg;
for (int i = 0; i < n; i++) {
go(counter, (void*)(intptr_t)i);
}
printf("Spawned %d goroutines\n", n);
}
int main(void) {
// 4 OS threads, each with work-stealing
scheduler_init(4);
go(spawn_many, (void*)8);
go(fib_worker, (void*)100000L);
go(fib_worker, (void*)200000L);
scheduler_run(); // Run until all work is done
scheduler_shutdown();
return 0;
}
Compile and run:
gcc -O2 -pthread -o scheduler scheduler.c example.c && ./scheduler
Chapter 7: Go's GMP Model
Go's scheduler is the most widely studied M:N implementation. Understanding it deeply will give you intuition applicable to any M:N runtime.
7.1 The Three Actors: G, M, P
Go's scheduler operates with three types of entities:
G (Goroutine): The green thread. Starts with a 2KB stack that can grow (via stack copying) to 1GB by default. Contains the goroutine's function, arguments, deferred functions, stack, and scheduler metadata.
M (Machine): An OS thread. There can be more Ms than Ps when goroutines block in cgo or syscalls. Ms execute goroutines.
P (Processor): The logical CPU — a scheduling context. There are exactly GOMAXPROCS Ps. A P holds the local run queue and other per-P resources. An M must hold a P to run goroutines. If M blocks in a syscall, it releases its P so another M can use it.
┌──────────────────────────────────────────────────────────┐
│ Global Run Queue: [G12][G13][G14] │
│ Network Poller: [G7(reading socket), G9(writing socket)]│
└──────────────────────────────────────────────────────────┘
P0 (local queue: [G3][G5]) P1 (local queue: [G8][G11])
│ │
M0 (running G1) M1 (running G6)
│ │
[OS Thread] [OS Thread]
│ │
[CPU Core 0] [CPU Core 1]
M2 (blocked in syscall, no P)
│
[OS Thread] ← parked, waiting for syscall return
7.2 The GMP State Machine
Key state transitions:
Goroutine creation (go statement):
- Runtime calls
newproc() - A
Gstruct is acquired from a pool (or allocated) - Stack is assigned (from stack cache or pool)
- G is pushed to current P's local queue
- If there are idle Ps, wake an M to run it
Goroutine scheduling:
- M calls
schedule()in a loop - Checks P's local queue (every ~61 iterations, also check global queue for fairness)
- If no local work, tries to steal from another P
- If still no work, blocks until signaled
Goroutine blocking (e.g., channel receive):
gopark()is called — changes G state to waiting- Records what G is waiting for (channel, mutex, timer, etc.)
- Switches to
schedule()viamcall()— moves execution to the M's stack and callsschedule() - M finds a new G to run
Goroutine unblocking:
- Event fires (channel has data, timer expired)
goready()is called, G moves to RUNNABLE- G is pushed to local queue (or global if local is full)
- If an M is idle, wake it
Syscall entry:
// Before syscall: runtime.entersyscall()
// 1. Save goroutine state (stack, PC)
// 2. Set G state to Gsyscall
// 3. Detach P from M (handoff P to another M)
// After syscall: runtime.exitsyscall()
// 1. Try to get P back
// 2. If no P available, park M and put G in global queue
7.3 Work Stealing in Go
Go's stealing implementation in findRunnable() (simplified from src/runtime/proc.go):
func findRunnable() (gp *g, inheritTime bool) {
_p_ := getg().m.p.ptr()
top:
// 1. Check for GC work (high priority)
if gcBlackenEnabled != 0 {
gp := gcController.findRunnableGCWorker(_p_)
if gp != nil { return gp, false }
}
// 2. Check local run queue
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}
// 3. Check global run queue (every 61 ticks)
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 1)
unlock(&sched.lock)
if gp != nil { return gp, false }
}
// 4. Check the network poller
if netpollinited() && atomic.Load(&netpollWaiters) > 0 &&
atomic.Load64(&sched.lastpoll) != 0 {
if list := netpoll(0); !list.empty() {
gp := list.pop()
injectglist(&list) // Put the rest in global queue
return gp, false
}
}
// 5. Try to steal from another P
procs := uint32(gomaxprocs)
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
if i == stealTries-1 {
// Last try: include timers
ranTimer = checkTimers(pp, 0)
}
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
p2 := allp[enum.position()]
if _p_ == p2 { continue }
// Try stealing from p2
if gp, tnow, w, newVal := runqsteal(_p_, p2, i == stealTries-1); gp != nil {
return gp, false
}
}
}
// 6. Park this M
// ... (complex logic involving race-free parking)
}
Key insights from the real code:
- GC worker goroutines have highest priority
- Timer checking is interleaved with stealing
- The steal order is randomised each time to avoid hot-spots
- Network poller is checked non-blockingly first, then blockingly when parking
7.4 Goroutine Preemption
Pre-Go 1.14, goroutines were only preempted at function call boundaries. A tight loop like:
func spin() {
for {} // Never preempted in old Go!
}
Would prevent GC and starve other goroutines on that P.
Since Go 1.14, Go uses signal-based preemption:
- The
sysmongoroutine (a special OS-level goroutine running without a P) monitors all Ps - If a P has been running the same G for >10ms, it sends
SIGURGto the M running that P - The signal handler checks if the goroutine can be safely preempted at its current PC
- If safe, it injects an
asyncPreemptcall — the goroutine "calls" this function which yields to the scheduler
// The injected preemption (runtime/preempt.go, simplified)
func asyncPreempt() {
// Save all registers (must save caller-saved too, unlike normal context switch)
var regs asm.Regs
asm.SaveAllRegs(®s)
// Check if we should preempt
gp := getg()
if gp.preempt {
mcall(preemptPark) // Park this goroutine
}
// Restore registers and return
asm.RestoreAllRegs(®s)
}
7.5 The sysmon Thread
sysmon (system monitor) is a special goroutine that runs on its own OS thread (without a P) and performs maintenance:
- Retract P from blocked goroutines: If a goroutine is in a syscall and its P hasn't been retracted, do it after 20µs
- Preempt long-running goroutines: Send
SIGURGto M if G has run >10ms - Poll network: Ensure the netpoller is checked even when all workers are busy
- Force GC: Trigger GC if too much time has passed
- Scavenge heap: Return unused memory to the OS
func sysmon() {
for {
delay := usleep_lo // Start with 20µs sleep
if idle > 50 { // Increase to 10ms if consistently idle
delay = 10 * 1000
}
usleep(delay)
// Retract Ps from goroutines stuck in syscalls
lastpoll := int64(atomic.Load64(&sched.lastpoll))
now := nanotime()
if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
list := netpoll(0) // non-blocking
if !list.empty() {
injectglist(&list)
}
}
// Retract Ps and preempt goroutines
if retake(now) != 0 {
idle = 0
} else {
idle++
}
}
}
7.6 Goroutine Stacks: The Copying Collector
When a goroutine's stack is about to overflow, Go copies the entire stack to a larger allocation. This requires updating all interior stack pointers.
The garbage collector already tracks all pointers — it uses stack maps (bitmaps generated by the compiler) that identify which words on the stack are pointers. When copying a stack:
- Allocate a new stack (twice as large)
- Copy old stack contents to new stack
- Adjust all frame pointers (using stack maps)
- Adjust all Goroutine-internal pointers (e.g., deferred function arguments)
- Update the goroutine's stack bounds
Before copy:
Stack: [low_addr ..................... high_addr]
frame0 frame1 frame2 frame3
After copy (2× size):
Stack: [new_low ....................................... new_high]
frame0 frame1 frame2 frame3 (new free space)
All pointers within stack updated to new_low + original_offset
This is why Go can't have references from the heap to goroutine stacks — such references would become dangling after a stack copy. (Go's escape analysis ensures that objects that would be stack-addressed but escape to the heap are allocated on the heap instead.)
Chapter 8: Rust and Tokio — Async M:N
Rust's M:N story is different from Go's. Instead of runtime goroutines, Rust uses zero-cost async/await — the compiler transforms async functions into state machines, and a runtime (Tokio, async-std, smol) executes them.
8.1 Async Functions as State Machines
When you write:
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
let response = reqwest::get(url).await?;
let body = response.text().await?;
Ok(body)
}
The compiler generates roughly:
// Conceptual desugaring (actual codegen is more complex)
enum FetchDataState {
Start { url: String },
AwaitingGet { future: reqwest::RequestFuture },
AwaitingText { future: reqwest::TextFuture },
Done,
}
impl Future for FetchDataStateMachine {
type Output = Result<String, reqwest::Error>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match &mut *self {
FetchDataState::Start { url } => {
// Start the GET request
let fut = reqwest::get(url);
*self = FetchDataState::AwaitingGet { future: fut };
// Fall through to poll the new state
}
FetchDataState::AwaitingGet { future } => {
match Pin::new(future).poll(cx) {
Poll::Pending => return Poll::Pending, // Not ready yet
Poll::Ready(r) => {
let response = r?;
let fut = response.text();
*self = FetchDataState::AwaitingText { future: fut };
}
}
}
FetchDataState::AwaitingText { future } => {
match Pin::new(future).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(r) => {
*self = FetchDataState::Done;
return Poll::Ready(Ok(r?));
}
}
}
FetchDataState::Done => panic!("polled after completion"),
}
}
}
}
Key insight: no stack is allocated. The state machine holds only the variables that need to survive across await points. A goroutine-style runtime keeps an actual stack per goroutine; Rust's async keeps a tiny enum. This is why Rust's async is called "zero-cost" — the overhead is exactly what the explicit state machine would cost, nothing more.
8.2 The Waker Mechanism
How does a task know when to be re-polled? The Waker:
use std::sync::Arc;
use std::task::{RawWaker, RawWakerVTable, Waker};
// A Waker is a reference-counted pointer to a task handle
// When called, it tells the runtime to re-schedule the task
struct TaskWaker {
task: Arc<Task>,
}
impl TaskWaker {
fn wake(self: Arc<Self>) {
// Re-enqueue the task for polling
self.task.runtime.enqueue(self.task.clone());
}
}
// The Future::poll signature:
// fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
//
// Context holds the Waker. When a future can't make progress, it:
// 1. Registers the Waker with the event source (epoll, channel, timer)
// 2. Returns Poll::Pending
//
// When the event fires, the event source calls waker.wake()
// Which enqueues the task back onto the runtime's run queue
8.3 Tokio's Architecture
Tokio uses a multi-threaded M:N runtime with work stealing:
┌─────────────────────┐
│ tokio::runtime │
│ │
│ io_driver (epoll) │
│ timer_wheel │
│ global_queue │
└──────────┬──────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────┴──────┐ ┌───────┴─────┐ ┌────────┴────┐
│ Worker 0 │ │ Worker 1 │ │ Worker 2 │
│ │ │ │ │ │
│ local_queue │ │ local_queue │ │ local_queue │
│ [T2][T5] │ │ [T1][T3] │ │ [T4][T6] │
│ │ │ │ │ │
│ running: T8 │ │ running: T7 │ │ running: T9 │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
[OS Thread] [OS Thread] [OS Thread]
Building a Tokio application:
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Semaphore;
use std::sync::Arc;
// Tokio handles 10,000 concurrent connections with ~4 OS threads
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("0.0.0.0:8080").await?;
let semaphore = Arc::new(Semaphore::new(10_000)); // Max 10K concurrent conns
println!("Listening on :8080");
loop {
let (stream, addr) = listener.accept().await?;
let permit = semaphore.clone().acquire_owned().await?;
tokio::spawn(async move {
let _permit = permit; // Released when this task ends
if let Err(e) = handle_connection(stream).await {
eprintln!("Error from {}: {}", addr, e);
}
});
}
}
async fn handle_connection(mut stream: TcpStream) -> Result<(), std::io::Error> {
let mut buf = vec![0u8; 4096];
loop {
let n = stream.read(&mut buf).await?; // Non-blocking via epoll
if n == 0 { break; } // EOF
// Process request (could await other things here)
let response = process(&buf[..n]).await;
stream.write_all(&response).await?;
}
Ok(())
}
async fn process(data: &[u8]) -> Vec<u8> {
// Could call database, other services, etc.
// All non-blocking, all cooperative
data.to_vec() // Echo for simplicity
}
8.4 Blocking Tasks and spawn_blocking
A crucial concern in async Rust: CPU-bound or blocking work must not run on async worker threads, because it prevents the event loop from making progress. Tokio provides spawn_blocking:
use tokio::task;
async fn mixed_workload() {
// CPU-bound work: run on a separate thread pool
let result = task::spawn_blocking(|| {
// This can block as long as it wants — it's on a blocking thread
let mut sum = 0u64;
for i in 0..100_000_000 {
sum += i;
}
sum
}).await.unwrap();
println!("Sum: {}", result);
// I/O-bound work: stays on async worker
let content = tokio::fs::read_to_string("file.txt").await.unwrap();
println!("File length: {}", content.len());
}
Tokio maintains two thread pools:
- Async workers: Small, fixed count (defaults to number of CPUs). Must never block.
- Blocking pool: Grows on demand (up to 512 threads by default). Used for
spawn_blocking.
8.5 Rust vs Go: Philosophy Comparison
| Aspect | Go | Rust/Tokio |
|---|---|---|
| Stack per task | Yes (2KB–1GB, copying) | No (state machine, stack only during poll) |
| Memory per idle task | ~8KB typical | 64–256 bytes (state machine size) |
| Blocking safety | Runtime detects + handles | Compiler/programmer responsibility |
| Syntax | go func() / natural |
async/await |
| Startup cost | Fork-and-forget | Must build future, spawn |
| Preemption | Signal-based (Go 1.14+) | No (must use yield_now()) |
| FFI blocking | Handled via handoff | Must use spawn_blocking |
Chapter 9: Erlang/BEAM — Process-Based M:N
Erlang's BEAM virtual machine represents a radical approach: millions of isolated processes with no shared memory, communicating only through message passing. This is M:N scheduling taken to its logical extreme for fault tolerance.
9.1 The BEAM Process Model
% Spawn 1 million processes — each gets ~300 bytes overhead
lists:foreach(fun(I) ->
spawn(fun() ->
receive
{ping, From} -> From ! pong
end
end)
end, lists:seq(1, 1_000_000)).
BEAM processes:
- Have their own heap (no shared memory between processes)
- Are scheduled by the BEAM scheduler, not the OS
- Have bounded reduction counts — each process gets 2000 "reductions" (roughly, function calls) before being preempted
- Communicate via message passing through a per-process mailbox
9.2 BEAM's Scheduler Architecture
BEAM uses N scheduler threads (one per CPU core by default) plus a small pool of I/O threads:
┌─────────────────────────────────────────────────────────────┐
│ BEAM VM │
│ │
│ Scheduler 1 Scheduler 2 Scheduler 3 │
│ [Run Queue] [Run Queue] [Run Queue] │
│ P1 P3 P7 P2 P5 P9 P4 P6 P8 │
│ │ │ │ │
│ [OS Thread] [OS Thread] [OS Thread] │
│ │
│ Async I/O Pool (separate OS threads for file/DNS ops) │
│ [Thread][Thread][Thread][Thread] │
│ │
│ Timer Thread │
│ [Thread] │
└─────────────────────────────────────────────────────────────┘
Key BEAM scheduler properties:
Reduction-based preemption: A "reduction" is roughly one function call. Each process gets 2000 reductions per scheduling slice. When reductions are exhausted, the process is preempted and sent to the back of the run queue. This ensures no process can starve others regardless of what it's computing.
Per-process GC: Because processes have isolated heaps, GC happens per-process. No global stop-the-world GC — each process is collected individually when its heap is full. This is a major factor in BEAM's legendary low-latency characteristics.
Work stealing: BEAM schedulers steal work from each other similarly to Go and Tokio.
9.3 OTP and Supervision Trees
Erlang's M:N model enables its famous fault-tolerance architecture:
% Supervisor: monitors workers, restarts on failure
-module(my_supervisor).
-behaviour(supervisor).
init([]) ->
Children = [
#{id => worker_1,
start => {my_worker, start_link, [arg1]},
restart => permanent, % Always restart
shutdown => 5000,
type => worker},
#{id => worker_2,
start => {my_worker, start_link, [arg2]},
restart => transient, % Restart only on abnormal exit
shutdown => 5000,
type => worker}
],
{ok, {#{strategy => one_for_one, % Restart only failed child
intensity => 5, % Max 5 restarts
period => 60}, % Per 60 seconds
Children}}.
When worker_1 crashes, the supervisor spawns a fresh process. The isolation guarantee (no shared memory) means the crash cannot corrupt worker_2's state. The restart is fast — spawning a new BEAM process takes microseconds.
9.4 The receive Pattern and Mailboxes
Each Erlang process has a mailbox. receive blocks the process (not the OS thread — it parks the process and lets the scheduler run others):
% A simple stateful server process
-module(counter).
-export([start/0, increment/1, get/1]).
start() ->
spawn(fun() -> loop(0) end).
loop(Count) ->
receive
{increment, N, From} ->
From ! {ok, Count + N},
loop(Count + N);
{get, From} ->
From ! {count, Count},
loop(Count);
stop ->
ok % Process terminates
end.
% Client API
increment(Pid) ->
Pid ! {increment, 1, self()},
receive {ok, NewCount} -> NewCount end.
get(Pid) ->
Pid ! {get, self()},
receive {count, C} -> C end.
Under the hood, receive checks the mailbox (O(n) in mailbox size — a known weak point), and if empty, parks the process in a wait state. When a message arrives, the process is moved to the run queue.
9.5 Comparison: Erlang vs Go vs Rust
| Property | Erlang/BEAM | Go | Rust/Tokio |
|---|---|---|---|
| Isolation | Full (per-process heap) | None (shared heap) | None (shared heap) |
| Communication | Message passing only | Channels + shared mem | Channels + shared mem |
| GC | Per-process, incremental | Stop-the-world (short) | None (ownership) |
| Fault tolerance | Built-in supervision | Manual | Manual |
| Max processes | ~134 million tested | Millions | Millions (tasks) |
| Preemption | Reduction-based (hard) | Signal-based (Go 1.14+) | None (cooperative) |
Chapter 10: Java Project Loom — Virtual Threads
Java's traditional model was 1:1: every Java thread was a platform (OS) thread. The C10K problem hit Java servers hard — 10,000 concurrent HTTP connections meant 10,000 OS threads.
Project Loom, released in Java 21 as a production feature, adds virtual threads: lightweight, M:N scheduled threads managed by the JVM.
10.1 Virtual Thread Basics
import java.util.concurrent.*;
// Old way: 10,000 OS threads (memory-intensive, slow)
ExecutorService old = Executors.newFixedThreadPool(200); // Must limit!
// New way: 10,000 virtual threads (each ~1KB overhead)
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
// Spawn 100,000 virtual threads — works fine
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int id = i;
executor.submit(() -> {
// This can block! Virtual thread parks; carrier thread continues.
Thread.sleep(1000);
System.out.println("Task " + id + " done on " + Thread.currentThread());
return null;
});
}
}
Output shows tasks running on a small pool of carrier threads:
Task 0 done on VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1
Task 1 done on VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
Task 2 done on VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1
10.2 Carrier Threads and Mounting
Virtual threads run on carrier threads (platform threads). The JVM scheduler mounts a virtual thread onto a carrier thread when it's runnable, and unmounts it when it blocks:
Virtual Thread lifecycle:
NEW → RUNNABLE → MOUNTED (running on carrier) → UNMOUNTED (parked) → RUNNABLE → ...
Mounting event: virtual thread selected from run queue, copied onto carrier's stack
Unmounting event: virtual thread calls blocking operation (I/O, sleep, synchronized)
The JVM intercepts blocking operations:
Thread.sleep()→ park virtual thread, let carrier run anotherInputStream.read()→ park virtual thread, register with I/O pollerLock.lock()(java.util.concurrent) → park virtual thread, add to lock wait queuesynchronizedblock → pins the virtual thread (cannot unmount while in synchronized)
Pinning is the current limitation: code in synchronized blocks cannot be unmounted. A blocking operation inside synchronized blocks the carrier thread. This is why Project Loom recommends migrating synchronized to ReentrantLock:
// BAD: pins the virtual thread
synchronized (lock) {
database.query(); // Blocks; pins carrier thread
}
// GOOD: virtual thread can be unmounted
lock.lock();
try {
database.query(); // Blocks; virtual thread parks, carrier continues
} finally {
lock.unlock();
}
10.3 Structured Concurrency
Java 21+ also introduces structured concurrency — a disciplined approach where subtasks' lifetimes are bounded by the parent scope:
import java.util.concurrent.StructuredTaskScope;
String fetchWithTimeout(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Both subtasks are scoped to this try block
StructuredTaskScope.Subtask<String> profile =
scope.fork(() -> fetchProfile(userId)); // Virtual thread
StructuredTaskScope.Subtask<String> orders =
scope.fork(() -> fetchOrders(userId)); // Virtual thread
scope.join() // Wait for both
.throwIfFailed(); // Propagate any exception
// Both completed successfully
return combineResults(profile.get(), orders.get());
}
// When try block exits: scope cancelled, all subtasks terminated
// Lifetime of subtasks cannot outlive parent scope
}
This eliminates the classic bug where spawned tasks outlive their parent and access freed resources.
Chapter 11: Channels and Communication
Channels are the primary communication primitive in M:N runtimes. They allow green threads to exchange data safely without explicit locks.
11.1 Channel Taxonomy
Unbuffered channels (rendezvous): Sender blocks until receiver is ready, and vice versa. Guarantees synchronisation at every message. Used when coordination matters more than throughput.
Buffered channels: A queue of fixed capacity. Sender only blocks when the buffer is full. Receiver only blocks when the buffer is empty. Decouples sender and receiver speeds.
Unbounded channels: Never block the sender (until OOM). Risk: fast producers overwhelm slow consumers (backpressure problem).
One-shot channels: Send exactly one value. Common for async results/futures.
Select / select!: Wait on multiple channels simultaneously, handling whichever is ready.
11.2 Implementing an Unbuffered Channel
// Synchronous (rendezvous) channel
// Sender and receiver must both be ready simultaneously
typedef struct {
GreenThread* sender; // Parked sender (NULL if none waiting)
GreenThread* receiver; // Parked receiver (NULL if none waiting)
void* value; // The value being transferred
bool closed;
pthread_mutex_t lock; // Brief critical section only
} Channel;
// Send blocks until a receiver is ready
void chan_send(Channel* ch, void* value) {
pthread_mutex_lock(&ch->lock);
if (ch->receiver) {
// A receiver is already waiting — hand off directly
GreenThread* recv = ch->receiver;
ch->receiver = NULL;
recv->waitResult = value; // Put value in receiver's result slot
pthread_mutex_unlock(&ch->lock);
enqueue(recv); // Wake the receiver
return;
}
// No receiver — park this sender
GreenThread* g = currentWorker->current;
g->waitValue = value;
ch->sender = g;
g->state = GT_WAITING;
pthread_mutex_unlock(&ch->lock);
// Yield to scheduler — will be woken when receiver arrives
swapcontext(&g->ctx, ¤tWorker->schedulerCtx);
}
// Receive blocks until a sender is ready
void* chan_recv(Channel* ch) {
pthread_mutex_lock(&ch->lock);
if (ch->sender) {
// A sender is already waiting — grab the value
GreenThread* send = ch->sender;
void* value = send->waitValue;
ch->sender = NULL;
pthread_mutex_unlock(&ch->lock);
enqueue(send); // Wake the sender
return value;
}
// No sender — park this receiver
GreenThread* g = currentWorker->current;
ch->receiver = g;
g->state = GT_WAITING;
pthread_mutex_unlock(&ch->lock);
swapcontext(&g->ctx, ¤tWorker->schedulerCtx);
return g->waitResult; // Set by the sender
}
11.3 Implementing a Buffered Channel
typedef struct {
void** buf;
uint32_t cap;
_Atomic uint32_t head;
_Atomic uint32_t tail;
_Atomic uint32_t count;
// Waiting lists
GreenThread* sendWaiters; // Parked senders (when full)
GreenThread* recvWaiters; // Parked receivers (when empty)
pthread_mutex_t lock;
bool closed;
} BufferedChan;
bool bchan_send(BufferedChan* ch, void* value) {
pthread_mutex_lock(&ch->lock);
if (ch->closed) {
pthread_mutex_unlock(&ch->lock);
return false; // Send on closed channel
}
// Is there a waiting receiver? Short-circuit the buffer
if (ch->recvWaiters) {
GreenThread* recv = ch->recvWaiters;
ch->recvWaiters = recv->next;
recv->waitResult = value;
pthread_mutex_unlock(&ch->lock);
enqueue(recv);
return true;
}
// Is there space in the buffer?
if (atomic_load(&ch->count) < ch->cap) {
uint32_t tail = atomic_load(&ch->tail);
ch->buf[tail % ch->cap] = value;
atomic_store(&ch->tail, tail + 1);
atomic_fetch_add(&ch->count, 1);
pthread_mutex_unlock(&ch->lock);
return true;
}
// Buffer full — park sender
GreenThread* g = currentWorker->current;
g->waitValue = value;
g->next = ch->sendWaiters;
ch->sendWaiters = g;
g->state = GT_WAITING;
pthread_mutex_unlock(&ch->lock);
swapcontext(&g->ctx, ¤tWorker->schedulerCtx);
return true;
}
void* bchan_recv(BufferedChan* ch) {
pthread_mutex_lock(&ch->lock);
// Buffer has data?
if (atomic_load(&ch->count) > 0) {
uint32_t head = atomic_load(&ch->head);
void* value = ch->buf[head % ch->cap];
atomic_store(&ch->head, head + 1);
atomic_fetch_sub(&ch->count, 1);
// Wake a waiting sender (there's now space)
if (ch->sendWaiters) {
GreenThread* sender = ch->sendWaiters;
ch->sendWaiters = sender->next;
// Put sender's value in newly freed buffer slot
uint32_t tail = atomic_load(&ch->tail);
ch->buf[tail % ch->cap] = sender->waitValue;
atomic_store(&ch->tail, tail + 1);
atomic_fetch_add(&ch->count, 1);
pthread_mutex_unlock(&ch->lock);
enqueue(sender);
} else {
pthread_mutex_unlock(&ch->lock);
}
return value;
}
if (ch->closed) {
pthread_mutex_unlock(&ch->lock);
return NULL;
}
// No data — park receiver
GreenThread* g = currentWorker->current;
g->next = ch->recvWaiters;
ch->recvWaiters = g;
g->state = GT_WAITING;
pthread_mutex_unlock(&ch->lock);
swapcontext(&g->ctx, ¤tWorker->schedulerCtx);
return g->waitResult;
}
11.4 Go Channels in Practice
Go channels are the idiomatic coordination mechanism:
// Fan-out: distribute work to multiple workers
func fanOut(jobs <-chan Job, numWorkers int) []<-chan Result {
results := make([]<-chan Result, numWorkers)
for i := 0; i < numWorkers; i++ {
resultCh := make(chan Result, 100)
results[i] = resultCh
go func(out chan<- Result) {
for job := range jobs { // Range over channel until closed
out <- process(job)
}
close(out)
}(resultCh)
}
return results
}
// Fan-in: merge multiple channels into one
func fanIn(channels ...<-chan Result) <-chan Result {
out := make(chan Result, 100)
var wg sync.WaitGroup
merge := func(ch <-chan Result) {
defer wg.Done()
for r := range ch {
out <- r
}
}
wg.Add(len(channels))
for _, ch := range channels {
go merge(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
// Pipeline: chain stages
func pipeline(input []int) <-chan int {
// Stage 1: generate
gen := func(nums []int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums { out <- n }
close(out)
}()
return out
}
// Stage 2: square
sq := func(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in { out <- n * n }
close(out)
}()
return out
}
return sq(sq(gen(input))) // Double-square pipeline
}
11.5 Select: Waiting on Multiple Channels
// Timeout with select
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
resp, err := http.Get(url)
if err != nil { errCh <- err; return }
body, err := io.ReadAll(resp.Body)
if err != nil { errCh <- err; return }
resultCh <- string(body)
}()
select {
case result := <-resultCh:
return result, nil
case err := <-errCh:
return "", err
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
// Done channel pattern for cancellation
func longRunning(ctx context.Context) error {
for {
select {
case <-ctx.Done(): // Cancellation signal
return ctx.Err()
default:
if err := doOneUnit(); err != nil {
return err
}
}
}
}
Chapter 12: Synchronization Primitives in M:N Runtimes
Synchronisation primitives in M:N runtimes must be goroutine-aware — blocking operations must park green threads, not OS threads.
12.1 Green-Thread Mutex
A standard pthread_mutex_t blocks the OS thread, which blocks all green threads on that worker. A green-thread-aware mutex parks just the waiting goroutine:
// Go's sync.Mutex simplified implementation concept
// (actual implementation uses lock-free fast path + semaphore slow path)
type GreenMutex struct {
state int32 // 0=unlocked, 1=locked, locked|waiter_count
sema uint32 // Semaphore for goroutine parking
}
func (m *GreenMutex) Lock() {
// Fast path: try CAS from 0 to 1
if atomic.CompareAndSwapInt32(&m.state, 0, 1) {
return // Got the lock immediately
}
// Slow path: spin briefly, then park
m.lockSlow()
}
func (m *GreenMutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// Spin a few times (only useful on multi-core)
if old & (mutexLocked|mutexStarving) == mutexLocked &&
runtime_canSpin(iter) {
// ... spinning logic ...
runtime_doSpin()
iter++
old = m.state
continue
}
new := old
if old & mutexStarving == 0 {
new |= mutexLocked // Try to acquire
}
new += mutexWaiterShift // Increment waiter count
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old & (mutexLocked|mutexStarving) == 0 {
break // Acquired the lock
}
// Park this goroutine
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// Woken up — try again
}
old = m.state
}
}
The runtime_SemacquireMutex call parks the goroutine using the runtime's semaphore implementation, which places the goroutine in a wait queue and calls gopark() — switching back to the scheduler without blocking the OS thread.
12.2 Green-Thread RWMutex
// sync.RWMutex: multiple concurrent readers OR one writer
var mu sync.RWMutex
var data map[string]string
// Multiple goroutines can read simultaneously
func read(key string) string {
mu.RLock()
defer mu.RUnlock()
return data[key]
}
// Only one goroutine can write at a time
func write(key, value string) {
mu.Lock()
defer mu.Unlock()
data[key] = value
}
12.3 WaitGroup — Waiting for Multiple Goroutines
// sync.WaitGroup: wait for a collection of goroutines
func processAll(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(idx int, it Item) {
defer wg.Done()
results[idx] = process(it)
}(i, item)
}
wg.Wait() // Blocks until all goroutines call Done()
return results
}
12.4 Once — Lazy Initialization
// sync.Once: run initialization exactly once, even with concurrent callers
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
instance = connectToDatabase() // Called exactly once
})
return instance
}
12.5 Semaphores for Rate Limiting
// Use a buffered channel as a semaphore
type Semaphore chan struct{}
func NewSemaphore(n int) Semaphore {
return make(Semaphore, n)
}
func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }
// Limit concurrent database connections
var dbSem = NewSemaphore(10)
func queryWithLimit(query string) Result {
dbSem.Acquire()
defer dbSem.Release()
return db.Query(query)
}
// Tokio equivalent (Rust):
use tokio::sync::Semaphore;
let sem = Arc::new(Semaphore::new(10));
async fn limited_query(sem: Arc<Semaphore>, query: &str) -> Result<Rows> {
let _permit = sem.acquire().await?; // Park if no permits available
db.query(query).await
// _permit dropped here → permit released
}
12.6 Atomic Operations
For lock-free communication between goroutines, sync/atomic provides atomic operations:
import "sync/atomic"
type Stats struct {
requestCount int64
bytesReceived int64
errors int64
}
func (s *Stats) RecordRequest(bytes int64) {
atomic.AddInt64(&s.requestCount, 1)
atomic.AddInt64(&s.bytesReceived, bytes)
}
func (s *Stats) RecordError() {
atomic.AddInt64(&s.errors, 1)
}
func (s *Stats) Read() (int64, int64, int64) {
return atomic.LoadInt64(&s.requestCount),
atomic.LoadInt64(&s.bytesReceived),
atomic.LoadInt64(&s.errors)
}
Chapter 13: I/O Integration and Event Loops
The true power of M:N systems comes from integrating non-blocking I/O. When a green thread needs to read from a socket, instead of blocking the OS thread, it parks itself and registers the socket with the I/O poller.
13.1 The epoll Integration Pattern
Linux's epoll watches multiple file descriptors and reports which are ready. The M:N runtime bridges epoll and the green thread scheduler:
// netpoll.c — Integration between epoll and green thread scheduler
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#define MAX_EVENTS 256
static int epollFd = -1;
static int wakeupPipe[2]; // To interrupt blocked epoll_wait
// Map from fd → waiting green thread
#define FD_TABLE_SIZE 65536
static GreenThread* fdWaiters[FD_TABLE_SIZE];
static pthread_mutex_t fdLock = PTHREAD_MUTEX_INITIALIZER;
void netpollInit(void) {
epollFd = epoll_create1(EPOLL_CLOEXEC);
pipe2(wakeupPipe, O_NONBLOCK | O_CLOEXEC);
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = wakeupPipe[0]
};
epoll_ctl(epollFd, EPOLL_CTL_ADD, wakeupPipe[0], &ev);
}
// Called by a green thread that wants to wait for fd to be readable
void netpollWait(int fd, uint32_t events) {
Worker* w = currentWorker;
GreenThread* g = w->current;
pthread_mutex_lock(&fdLock);
fdWaiters[fd] = g;
pthread_mutex_unlock(&fdLock);
struct epoll_event ev = {
.events = events | EPOLLONESHOT,
.data.fd = fd
};
epoll_ctl(epollFd, EPOLL_CTL_ADD, fd, &ev);
// (or EPOLL_CTL_MOD if already registered)
// Park this goroutine
g->state = GT_WAITING;
g->waitFd = fd;
swapcontext(&g->ctx, &w->schedulerCtx);
// We're back: fd is ready
}
// Called by the scheduler (or a dedicated netpoll goroutine) to check for events
GreenThread* netpollCheck(int64_t timeoutNs) {
struct epoll_event events[MAX_EVENTS];
int timeout_ms = (timeoutNs < 0) ? -1
: (timeoutNs == 0) ? 0
: (int)(timeoutNs / 1000000);
int n = epoll_wait(epollFd, events, MAX_EVENTS, timeout_ms);
GreenThread* first = NULL;
GreenThread* last = NULL;
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == wakeupPipe[0]) {
// Drain the wakeup pipe
char buf[256];
while (read(wakeupPipe[0], buf, sizeof(buf)) > 0);
continue;
}
pthread_mutex_lock(&fdLock);
GreenThread* g = fdWaiters[fd];
fdWaiters[fd] = NULL;
pthread_mutex_unlock(&fdLock);
if (g) {
g->state = GT_RUNNABLE;
g->next = NULL;
if (!first) first = g;
else last->next = g;
last = g;
}
}
return first; // Linked list of woken goroutines
}
// Wake the epoll_wait thread (when new work arrives and it's sleeping)
void netpollBreak(void) {
write(wakeupPipe[1], "x", 1);
}
13.2 Wrapping Blocking I/O
With netpollWait, we can wrap blocking syscalls to be goroutine-friendly:
// Goroutine-safe read — parks goroutine until data is available
ssize_t green_read(int fd, void* buf, size_t count) {
// Try non-blocking read first
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
for (;;) {
ssize_t n = read(fd, buf, count);
if (n >= 0) return n;
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// No data yet — park goroutine until fd is readable
netpollWait(fd, EPOLLIN);
continue; // Try again after wakeup
}
return -1; // Real error
}
}
// Goroutine-safe write — parks goroutine if socket buffer full
ssize_t green_write(int fd, const void* buf, size_t count) {
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
size_t written = 0;
while (written < count) {
ssize_t n = write(fd, (const char*)buf + written, count - written);
if (n > 0) {
written += n;
continue;
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
netpollWait(fd, EPOLLOUT);
continue;
}
return -1;
}
return written;
}
13.3 io_uring: The Modern I/O Interface
Linux 5.1+ offers io_uring, a high-performance async I/O interface that can be used to eliminate the overhead of per-operation syscalls:
// io_uring based async I/O for M:N runtimes
#include <liburing.h>
struct IOUring {
struct io_uring ring;
// Map from user_data → parked goroutine
GreenThread* completions[4096];
uint64_t nextTag;
};
// Async read using io_uring
ssize_t uring_read(IOUring* uring, int fd, void* buf, size_t count) {
struct io_uring_sqe* sqe = io_uring_get_sqe(&uring->ring);
uint64_t tag = uring->nextTag++;
GreenThread* g = currentWorker->current;
uring->completions[tag % 4096] = g;
io_uring_prep_read(sqe, fd, buf, count, 0);
io_uring_sqe_set_data64(sqe, tag);
io_uring_submit(&uring->ring);
// Park goroutine
g->state = GT_WAITING;
swapcontext(&g->ctx, ¤tWorker->schedulerCtx);
// Woken by completion handler — result in g->waitResult
return (ssize_t)(intptr_t)g->waitResult;
}
// Completion handler (runs in scheduler loop)
void uringPollCompletions(IOUring* uring) {
struct io_uring_cqe* cqe;
unsigned head;
io_uring_for_each_cqe(&uring->ring, head, cqe) {
uint64_t tag = io_uring_cqe_get_data64(cqe);
GreenThread* g = uring->completions[tag % 4096];
if (g) {
g->waitResult = (void*)(intptr_t)cqe->res;
g->state = GT_RUNNABLE;
enqueue(g);
}
}
io_uring_cq_advance(&uring->ring, head);
}
io_uring allows batching multiple I/O operations into a single syscall via the submission queue, dramatically reducing per-operation overhead — critical for high-throughput servers doing millions of I/O operations per second.
Chapter 14: Debugging and Observability
Debugging M:N concurrent programs is harder than debugging sequential programs. The non-deterministic scheduling makes bugs intermittent and hard to reproduce.
14.1 Goroutine Dumps
Go can dump all goroutine stacks at any time:
import (
"runtime"
"fmt"
"os"
"os/signal"
"syscall"
)
func init() {
// Ctrl+\ dumps all goroutine stacks
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGQUIT)
for range c {
buf := make([]byte, 1 << 20) // 1MB buffer
n := runtime.Stack(buf, true) // true = all goroutines
fmt.Fprintf(os.Stderr, "=== Goroutine Dump ===\n%s\n", buf[:n])
}
}()
}
Typical output:
goroutine 1 [running]:
main.main()
/home/user/main.go:42 +0x1f4
goroutine 18 [chan receive, 3 minutes]:
main.worker()
/home/user/worker.go:17 +0x6a
created by main.startWorkers in goroutine 1
/home/user/main.go:29 +0x39
goroutine 42 [IO wait]:
net.(*netFD).Read(...)
/usr/local/go/src/net/fd_posix.go:56
net.(*conn).Read(...)
/usr/local/go/src/net/net.go:185
The state in brackets tells you why a goroutine is paused:
[running]: Currently executing[chan receive]: Waiting for channel data[chan send]: Blocked sending (buffer full or no receiver)[IO wait]: Waiting for I/O (epoll)[semacquire]: Waiting for a mutex/semaphore[sleep]: Intime.Sleep[select]: Blocked in aselectstatement
14.2 Race Detector
Go's race detector (go run -race) instruments all memory accesses and reports unsynchronised concurrent accesses:
// This code has a data race
var counter int
func unsafeIncrement() {
go func() { counter++ }() // Race!
go func() { counter++ }() // Race!
}
Running with `-race`:
==================
WARNING: DATA RACE
Write at 0x00c0001b4050 by goroutine 7:
main.unsafeIncrement.func1()
/home/user/main.go:8 +0x34
Previous write at 0x00c0001b4050 by goroutine 6:
main.unsafeIncrement.func1()
/home/user/main.go:7 +0x34
Goroutine 7 (running) created at:
main.unsafeIncrement()
/home/user/main.go:8 +0x5e
==================
How it works: The race detector adds instrumentation that records each memory access with a vector clock — a logical timestamp that can determine if two accesses are concurrent and one is a write.
14.3 pprof: CPU and Memory Profiling
Go's pprof package can profile goroutine scheduling and CPU usage:
import (
"net/http"
_ "net/http/pprof"
"runtime/pprof"
"os"
)
func main() {
// Enable HTTP profiling endpoint
go http.ListenAndServe(":6060", nil)
// Or collect to file
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ... your program ...
}
// Then analyse:
// go tool pprof http://localhost:6060/debug/pprof/goroutine
// go tool pprof http://localhost:6060/debug/pprof/heap
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
The goroutine profile shows where goroutines are blocked — invaluable for finding deadlocks and contention hotspots.
14.4 Deadlock Detection
A deadlock occurs when goroutines are waiting for each other in a cycle. Go's runtime detects deadlocks when all goroutines are blocked:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/home/user/main.go:5 +0x28
goroutine 6 [chan receive]:
main.producer()
/home/user/main.go:11 +0x30
However, Go cannot detect partial deadlocks — if only some goroutines are deadlocked but others are still running. Tools like goleak can detect goroutine leaks in tests:
// Using goleak in tests
import "go.uber.org/goleak"
func TestMyFunction(t *testing.T) {
defer goleak.VerifyNone(t) // Fails if any goroutines leaked
// Run test
result := myFunction()
assert.Equal(t, expected, result)
// goleak checks: are there goroutines that didn't exist before this test?
}
14.5 Tokio Console
For Rust/Tokio, the tokio-console tool provides a live view of async task scheduling:
// Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
console-subscriber = "0.2"
// main.rs
#[tokio::main]
async fn main() {
console_subscriber::init(); // Enable tokio-console
// ... rest of program
}
Running tokio-console shows:
- All active tasks with their poll counts
- Tasks blocked waiting for I/O
- Tasks busy in CPU-bound code
- Wakeup counts (a task woken very frequently may be spinning)
Chapter 15: Performance Analysis and Tuning
15.1 The Scheduler's Effect on Throughput
The scheduler adds overhead to every green thread switch. For a system doing millions of short tasks per second, scheduling overhead can dominate:
// Benchmark: scheduling overhead per goroutine
func BenchmarkGoroutineRoundtrip(b *testing.B) {
ch := make(chan struct{})
go func() {
for range ch { ch <- struct{}{} }
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ch <- struct{}{}
<-ch
}
}
// Result: ~200 ns per roundtrip = ~5M goroutine switches/second/core
For comparison:
- Goroutine switch (Go): ~100–300 ns
- Task switch (Tokio): ~80–200 ns (context switch amortised across many polls)
- OS thread switch (pthread, voluntary): ~2–5 µs
- OS thread switch (preemptive): ~5–20 µs
15.2 Backpressure and Bounded Queues
An unbounded run queue is dangerous: if producers create green threads faster than workers can execute them, memory grows without bound:
// Pattern: bounded semaphore to limit concurrent goroutines
type Pool struct {
sem chan struct{}
wg sync.WaitGroup
}
func NewPool(maxConcurrent int) *Pool {
return &Pool{sem: make(chan struct{}, maxConcurrent)}
}
func (p *Pool) Submit(fn func()) {
p.sem <- struct{}{} // Blocks if pool is full
p.wg.Add(1)
go func() {
defer func() {
<-p.sem
p.wg.Done()
}()
fn()
}()
}
func (p *Pool) Wait() { p.wg.Wait() }
15.3 Cache-Aware Task Sizing
Green thread tasks should be large enough to amortise scheduling overhead but small enough to stay in cache:
// Bad: millions of tiny goroutines — scheduling overhead dominates
func badParallel(data []int) []int {
results := make([]int, len(data))
var wg sync.WaitGroup
for i, v := range data {
wg.Add(1)
i, v := i, v
go func() { // One goroutine per element — too fine-grained!
defer wg.Done()
results[i] = v * v
}()
}
wg.Wait()
return results
}
// Good: chunk the work — goroutine per CPU, not per element
func goodParallel(data []int) []int {
results := make([]int, len(data))
numCPU := runtime.NumCPU()
chunkSz := (len(data) + numCPU - 1) / numCPU
var wg sync.WaitGroup
for i := 0; i < numCPU; i++ {
start := i * chunkSz
end := min(start + chunkSz, len(data))
if start >= len(data) { break }
wg.Add(1)
go func(s, e int) {
defer wg.Done()
for j := s; j < e; j++ {
results[j] = data[j] * data[j]
}
}(start, end)
}
wg.Wait()
return results
}
15.4 GOMAXPROCS Tuning
GOMAXPROCS controls the number of OS threads (Ps) in Go. The default is runtime.NumCPU(). Tuning cases:
CPU-bound workloads: Default is optimal. More Ps than cores adds context switch overhead.
I/O-bound workloads: Default is also good — goroutines park during I/O, Ps pick up other goroutines.
Mixed with C extensions (cgo): cgo calls can block OS threads. If cgo blocks frequently, increase GOMAXPROCS or use a larger thread pool.
Container environments: If running in a container with CPU limits (e.g., 0.5 CPUs), runtime.NumCPU() may report host CPU count. Use uber-go/automaxprocs to set GOMAXPROCS based on actual CPU quota:
import _ "go.uber.org/automaxprocs" // Auto-sets GOMAXPROCS for containers
Chapter 16: Design Patterns for M:N Systems
16.1 The Worker Pool Pattern
type WorkerPool struct {
jobs chan func()
results chan error
wg sync.WaitGroup
}
func NewWorkerPool(workers, queueDepth int) *WorkerPool {
p := &WorkerPool{
jobs: make(chan func(), queueDepth),
results: make(chan error, queueDepth),
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go func() {
defer p.wg.Done()
for job := range p.jobs {
if err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
job()
return nil
}(); err != nil {
p.results <- err
}
}
}()
}
return p
}
func (p *WorkerPool) Submit(job func()) {
p.jobs <- job
}
func (p *WorkerPool) Shutdown() {
close(p.jobs)
p.wg.Wait()
close(p.results)
}
16.2 The Circuit Breaker Pattern
Prevent cascading failures by stopping calls to a failing service:
type CircuitBreaker struct {
state int32 // 0=closed, 1=open, 2=half-open
failures int32
threshold int32
timeout time.Duration
lastFailTime int64
mu sync.Mutex
}
func (cb *CircuitBreaker) Call(fn func() error) error {
state := atomic.LoadInt32(&cb.state)
switch state {
case 1: // Open
elapsed := time.Since(time.Unix(0, atomic.LoadInt64(&cb.lastFailTime)))
if elapsed < cb.timeout {
return errors.New("circuit breaker open")
}
// Try half-open
atomic.StoreInt32(&cb.state, 2)
fallthrough
case 0, 2: // Closed or half-open
err := fn()
if err != nil {
fails := atomic.AddInt32(&cb.failures, 1)
atomic.StoreInt64(&cb.lastFailTime, time.Now().UnixNano())
if fails >= cb.threshold {
atomic.StoreInt32(&cb.state, 1) // Trip to open
}
return err
}
// Success: reset
atomic.StoreInt32(&cb.failures, 0)
atomic.StoreInt32(&cb.state, 0)
return nil
}
return nil
}
16.3 The Actor Pattern
Erlang-style isolated actors with message passing:
type Message struct {
Type string
Payload interface{}
ReplyTo chan<- interface{}
}
type Actor struct {
mailbox chan Message
state interface{}
handler func(state interface{}, msg Message) interface{}
}
func NewActor(initialState interface{},
handler func(interface{}, Message) interface{}) *Actor {
a := &Actor{
mailbox: make(chan Message, 100),
state: initialState,
handler: handler,
}
go a.run()
return a
}
func (a *Actor) run() {
for msg := range a.mailbox {
a.state = a.handler(a.state, msg)
}
}
func (a *Actor) Send(msg Message) {
a.mailbox <- msg
}
func (a *Actor) Ask(msgType string, payload interface{}) interface{} {
reply := make(chan interface{}, 1)
a.Send(Message{Type: msgType, Payload: payload, ReplyTo: reply})
return <-reply
}
// Example: counter actor
func main() {
counter := NewActor(0, func(state interface{}, msg Message) interface{} {
n := state.(int)
switch msg.Type {
case "increment":
return n + 1
case "get":
msg.ReplyTo <- n
return n
case "reset":
return 0
}
return n
})
counter.Send(Message{Type: "increment"})
counter.Send(Message{Type: "increment"})
counter.Send(Message{Type: "increment"})
fmt.Println("Count:", counter.Ask("get", nil)) // 3
}
16.4 The Scatter-Gather Pattern
Dispatch work to multiple goroutines and collect results:
func scatterGather[T, R any](
items []T,
fn func(T) (R, error),
maxConcurrent int,
) ([]R, []error) {
type indexed struct {
idx int
val R
err error
}
sem := make(chan struct{}, maxConcurrent)
out := make(chan indexed, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(idx int, it T) {
defer wg.Done()
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // Release on done
val, err := fn(it)
out <- indexed{idx: idx, val: val, err: err}
}(i, item)
}
go func() {
wg.Wait()
close(out)
}()
results := make([]R, len(items))
errs := make([]error, len(items))
for r := range out {
results[r.idx] = r.val
errs[r.idx] = r.err
}
return results, errs
}
// Usage
urls := []string{"https://a.com", "https://b.com", "https://c.com"}
bodies, errs := scatterGather(urls, func(url string) (string, error) {
resp, err := http.Get(url)
if err != nil { return "", err }
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b), nil
}, 10) // Max 10 concurrent
Appendix A: M:N Runtime Comparison Table
| Runtime | Language | Green thread | Stack | Preemption | I/O model | Work stealing |
|---|---|---|---|---|---|---|
| Go runtime | Go | Goroutine | 2KB–1GB, copying | Signal (SIGURG) | netpoll (epoll/kqueue) | Yes (Chase-Lev) |
| Tokio | Rust | Task (state machine) | Stack only during poll | No (cooperative) | io_uring / epoll | Yes |
| BEAM | Erlang | Process | Fixed per process | Reduction count | Dedicated async threads | Yes |
| Project Loom | Java | Virtual thread | Small, fixed | JVM-driven | NIO integration | Yes (ForkJoinPool) |
| GHC | Haskell | Spark / thread | Small | Allocation sites | Event manager | Yes |
| libuv | C (Node.js) | N/A (event loop) | OS thread stack | N/A (single-threaded) | epoll / IOCP / kqueue | No |
| Boost.Fiber | C++ | Fiber | User-set | No (cooperative) | User must integrate | No |
Appendix B: Quick Reference — Go Concurrency Primitives
// Create goroutine
go func() { /* ... */ }()
go namedFunction(arg1, arg2)
// Channel creation
ch := make(chan T) // Unbuffered
ch := make(chan T, n) // Buffered capacity n
// Channel operations
ch <- value // Send (blocks if no receiver / buffer full)
value := <-ch // Receive (blocks if no data)
value, ok := <-ch // Receive with closed check
close(ch) // Signal no more sends (panics if already closed)
for v := range ch { ... } // Receive until channel closed
// Select
select {
case v := <-ch1: // ...
case ch2 <- v: // ...
case <-time.After(d): // Timeout
default: // Non-blocking
}
// Sync primitives
var mu sync.Mutex
mu.Lock(); defer mu.Unlock()
var rw sync.RWMutex
rw.RLock(); defer rw.RUnlock() // Multiple concurrent readers
rw.Lock(); defer rw.Unlock() // Exclusive write
var wg sync.WaitGroup
wg.Add(1); go func() { defer wg.Done(); /* ... */ }()
wg.Wait()
var once sync.Once
once.Do(func() { /* init exactly once */ })
// Context (cancellation)
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Second))
defer cancel()
select { case <-ctx.Done(): return ctx.Err() }
// Atomics
var n int64
atomic.AddInt64(&n, 1)
atomic.LoadInt64(&n)
atomic.StoreInt64(&n, 0)
atomic.CompareAndSwapInt64(&n, old, new)
Appendix C: Common Pitfalls
Goroutine Leaks
// BAD: goroutine leaks if nobody reads resultCh
func leaky() <-chan Result {
resultCh := make(chan Result) // Unbuffered!
go func() {
resultCh <- computeResult() // Blocks forever if caller discards channel
}()
return resultCh
}
// GOOD: context-aware, can be cancelled
func notLeaky(ctx context.Context) <-chan Result {
resultCh := make(chan Result, 1) // Buffered: goroutine won't block
go func() {
select {
case resultCh <- computeResult():
case <-ctx.Done():
}
}()
return resultCh
}
Closing Channels Twice
// Panics: close of closed channel
close(ch)
close(ch) // panic!
// Safe: use sync.Once
var once sync.Once
safeClose := func() { once.Do(func() { close(ch) }) }
Loop Variable Capture
// BAD: all goroutines capture the same 'i' variable
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // Will likely print 5, 5, 5, 5, 5
}()
}
// GOOD (Go 1.22+): loop variables are per-iteration by default
// GOOD (all versions): pass as argument
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println(i) // Prints 0, 1, 2, 3, 4 in some order
}(i)
}
Mutex Copied by Value
// BAD: copying mutex (sync.Mutex must not be copied after first use)
type Counter struct {
sync.Mutex
value int
}
func copyBad(c Counter) { // Copies the mutex — race condition!
c.Lock()
defer c.Unlock()
c.value++
}
// GOOD: use pointer receiver
func (c *Counter) Increment() { // Pointer receiver — no copy
c.Lock()
defer c.Unlock()
c.value++
}
This completes the M:N Concurrent Model guide. The concepts span from assembly-level context switching through production-grade work stealing, language runtime internals, and high-level design patterns. The M:N model is one of the most impactful ideas in modern systems programming — enabling programs that handle millions of concurrent operations on commodity hardware with predictable performance and minimal overhead.