writeonce/docs/runtime/fibers.md
2026-04-05 00:45:02 +02:00

9.2 KiB

Runtime Fibers

Runtime fibers are lightweight, user-space threads managed by an application's runtime system rather than the OS kernel. They enable massive concurrency — millions per machine — because they don't carry the overhead of kernel thread stacks and scheduling. Unlike pre-emptive kernel threads, fibers use cooperative multitasking: they yield control voluntarily at known suspension points.

Context Switching

A context switch is saving the state of one execution unit and restoring another so it can continue running. The cost of this switch is what separates kernel threads from fibers.

Kernel Thread Context Switch

When the OS switches between threads:

  1. Save all CPU registers (general purpose, floating point, SIMD) to kernel memory
  2. Save the thread's stack pointer
  3. Flush the TLB (translation lookaside buffer) if switching processes
  4. Update scheduler data structures
  5. Restore the next thread's registers and stack pointer
  6. Return to userspace

Cost: 1-10 microseconds, involves a kernel trap (syscall boundary crossing), cache pollution from TLB flush.

Fiber Context Switch

When a runtime switches between fibers:

  1. Save a few registers (stack pointer, instruction pointer, callee-saved registers)
  2. Swap the stack pointer to the next fiber's stack
  3. Jump to the next fiber's saved instruction pointer

Cost: ~10-100 nanoseconds, entirely in userspace, no kernel involvement, no TLB flush, cache stays warm.

Kernel thread switch:     ~1,000-10,000 ns   (kernel trap + TLB flush)
Fiber switch:             ~10-100 ns          (register swap in userspace)
                          100x cheaper

Types of Multitasking

Pre-emptive (Kernel Threads)

The OS scheduler interrupts threads at arbitrary points using timer interrupts. The thread does not choose when to yield — the kernel forces it.

Thread A: ████████──┐ (interrupted by OS)
                    │
Thread B:           └──████████──┐ (interrupted by OS)
                                 │
Thread A:                        └──████████
  • Threads can be interrupted mid-instruction
  • Requires locks/mutexes to protect shared state
  • Fairness guaranteed by the scheduler
  • Used by: pthreads, std::thread, OS processes

Cooperative (Fibers / Green Threads)

Fibers explicitly yield at known points (I/O boundaries, channel sends, .await in Rust). The runtime only switches when the fiber says "I'm done for now."

Fiber A: ████████ yield ──┐
                          │
Fiber B:                  └── ████████ yield ──┐
                                               │
Fiber A:                                       └── ████████
  • Fibers are never interrupted mid-computation
  • No locks needed for single-threaded runtimes — only one fiber runs at a time
  • Starvation possible if a fiber never yields (compute-heavy work blocks the loop)
  • Used by: Go goroutines, Erlang processes, Lua coroutines, Rust async/await

Comparison

Property Pre-emptive (Threads) Cooperative (Fibers)
Scheduling OS kernel decides Runtime decides at yield points
Context switch cost ~1-10 us ~10-100 ns
Stack size 1-8 MB per thread (fixed) Bytes to KB per fiber (growable)
Max concurrency ~10,000 threads ~1,000,000+ fibers
Synchronization Locks, mutexes, atomics Not needed in single-threaded runtime
Interruption Any point (timer interrupt) Only at yield points
Fairness Guaranteed by scheduler Must be designed (fiber must yield)

How Fibers Work Internally

A fiber needs three things:

  1. A stack — a block of memory for local variables and call frames
  2. A saved context — the register state at the point it yielded
  3. A function — the code to run when resumed

Minimal Fiber in C

#include <ucontext.h>
#include <stdio.h>

static ucontext_t main_ctx, fiber_ctx;
static char fiber_stack[8192];

void fiber_fn() {
    printf("Fiber: running\n");
    // Yield back to main
    swapcontext(&fiber_ctx, &main_ctx);
    printf("Fiber: resumed\n");
    // Yield again
    swapcontext(&fiber_ctx, &main_ctx);
}

int main() {
    // Set up fiber context
    getcontext(&fiber_ctx);
    fiber_ctx.uc_stack.ss_sp = fiber_stack;
    fiber_ctx.uc_stack.ss_size = sizeof(fiber_stack);
    fiber_ctx.uc_link = &main_ctx;
    makecontext(&fiber_ctx, fiber_fn, 0);

    printf("Main: starting fiber\n");
    swapcontext(&main_ctx, &fiber_ctx);   // switch to fiber

    printf("Main: fiber yielded\n");
    swapcontext(&main_ctx, &fiber_ctx);   // resume fiber

    printf("Main: fiber yielded again\n");
    swapcontext(&main_ctx, &fiber_ctx);   // resume — fiber finishes

    printf("Main: done\n");
    return 0;
}

Output:

Main: starting fiber
Fiber: running
Main: fiber yielded
Fiber: resumed
Main: fiber yielded again
Main: done

swapcontext saves the current register state to one context struct and loads another — that's the entire fiber switch. No kernel involved.

Minimal Fiber in Rust (unsafe)

Rust's async/await compiles to state machines, not stack-swapping fibers. But you can build raw fibers with inline assembly:

use std::arch::asm;

struct Fiber {
    stack: Vec<u8>,
    sp: *mut u8,  // saved stack pointer
}

impl Fiber {
    fn new(func: fn()) -> Self {
        let mut stack = vec![0u8; 8192];
        let sp = unsafe {
            let top = stack.as_mut_ptr().add(stack.len());
            let aligned = (top as usize & !0xF) as *mut u8;
            // Push the function pointer as the return address
            let sp = aligned.sub(8);
            *(sp as *mut fn()) = func;
            sp
        };
        Fiber { stack, sp }
    }

    unsafe fn switch_to(&mut self, from: &mut *mut u8) {
        // Save callee-saved registers and swap stack pointers
        asm!(
            "push rbx",
            "push rbp",
            "push r12",
            "push r13",
            "push r14",
            "push r15",
            "mov [{from}], rsp",     // save current sp
            "mov rsp, [{to}]",       // load fiber sp
            "pop r15",
            "pop r14",
            "pop r13",
            "pop r12",
            "pop rbp",
            "pop rbx",
            from = in(reg) from,
            to = in(reg) &self.sp,
        );
    }
}

This is what runtimes like Go and Erlang do internally — allocate a small stack, save/restore a handful of registers, and jump. The cost is a few nanoseconds.

Fibers vs Rust async/await

Rust chose a different approach than fibers for its async model:

Property Fibers (Go, Erlang) Rust async/await
Implementation Stack swapping at runtime Compiler generates state machines
Stack Each fiber has its own stack No extra stack — state stored in Future struct
Memory per task ~2-8 KB minimum (stack) Bytes — only the live variables at yield points
Yield mechanism swapcontext / assembly .await compiles to Poll::Pending
Overhead Stack allocation + register swap Zero-cost — state machine is a regular struct
Debuggability Separate stacks in debugger State machine is harder to trace
Preemption Runtime can preempt (Go does this) Never preempted — cooperative only

Rust's approach is called stackless coroutines — no extra stack per task. The compiler transforms each async fn into a state machine enum where each variant holds the local variables alive across an .await point.

Relation to writeonce

The writeonce runtime (wo-event, wo-rt) uses neither fibers nor Rust async/await. It uses a single-threaded event loop with callbacks — the simplest model:

loop {
    events = epoll_wait()
    for event in events {
        match event.token {
            WATCHER => handle_file_change(),
            LISTENER => handle_accept(),
            HTTP_CONN => handle_request(),
            ...
        }
    }
}

This is the same model as nginx, Redis, and Node.js (before libuv's thread pool). It works because:

  • Each handler runs to completion quickly (microseconds)
  • No handler blocks — all I/O is non-blocking
  • No concurrent access to shared state — one thing runs at a time
  • Blocking work (index rebuild) offloads to a thread pool and signals back via eventfd

If writeonce ever needed millions of concurrent long-lived tasks (not just connections), fibers would be the next step. But for a content platform serving articles, the event loop is sufficient — and far simpler to reason about.

Summary

Kernel threads:   OS-managed, pre-emptive, expensive switch, ~10K max
Fibers:           Runtime-managed, cooperative, cheap switch, ~1M+ max
Async/await:      Compiler-managed, cooperative, zero-cost, ~1M+ max
Event loop:       No tasks at all — just fd readiness + callbacks

Complexity:       event loop < fibers < async/await < threads
Concurrency:      event loop = fibers = async/await >> threads

The right choice depends on the workload. For writeonce — an event loop. For a database with millions of queries in flight — fibers or async. For CPU-bound parallel work — kernel threads.