writeonce/docs/plan/exploration/fibers/00-fibers.md

6 KiB
Raw Blame History

Fibers — green threads on the wovm shard scheduler

Research note (2026-08-08) feeding story iteration 11. Expands the blue-green vision §3 with the kernel's-eye evidence and the precedent survey. Prose only; the spec and plan follow the brainstorming → writing-plans path when the iteration starts.

Why the kernel cannot do this for us

Reference: Threads and the OS kernel's view. The facts that matter, condensed:

  • A Linux thread IS a task_struct: own TID, own kernel stack (8–16 KiB), own scheduling slot; clone() flags decide what is shared. There is no cheaper kernel thread to ask for.
  • Costs per thread: ~8 MiB stack VMA, ~9 KiB task_struct, ~20 µs creation, 1–3 µs per context switch. Measured against a userspace runtime spawn (~58 ns): ~342× creation cost, ~70× memory.
  • CFS keys a red-black tree by vruntime — O(log N) per pick. At tens of thousands of runnable tasks the scheduling slice approaches the context-switch cost and the kernel scheduler becomes the bottleneck before application code does.
  • M:N threading died in the kernel (NPTL, 2003) and was reborn in userspace (goroutines, BEAM processes, Tokio tasks, Loom virtual threads) — because only the runtime knows its own blocking points and can keep per-task state tiny.

Conclusion the industry already reached and we adopt: one kernel task per core (plan 09 / iteration 8's pinned shard — already doctrine), userspace tasks above it. The kernel schedules cores; the runtime schedules work.

Precedent survey — who has green threads, and how

Runtime Task state Preemption I/O integration Lesson for wovm
Erlang/BEAM interpreter state per process, private heap reduction budget (~2k reductions, checked at calls) park on scheduler, poll set the closest shape: interpreter = scheduler; deterministic, signal-free preemption
Go native stack, 8 KiB grown by copy async preemption via signals (since 1.14) + safepoints netpoller parks goroutines stack copying needs precise pointer maps — heavy machinery; signals are what the vision explicitly avoids
Java Loom continuation frames on heap, unmounted from carrier cooperative at yield points blocking calls in the JDK park the virtual thread "same blocking API, runtime parks underneath" — exactly our stdlib posture
Tokio/Rust stackless state machines (async fn) cooperative at .await reactor + waker rejected surface: writeonce has no async/await keyword (systems-track spec Part 2); function coloring is the disease
Lua coroutine = own Lua stack (interpreter state) none (pure cooperative) up to the host proof that interpreter-state fibers are nearly free; but no preemption = one hot loop starves the shard
boost.context / libco native stack + hand-rolled register switch none none what we do NOT need: wovm executes bytecode, so no native stack switching, no asm, no guard pages

The wovm design (vision §3, confirmed by this survey)

A fiber is the execution state the VM already isolates: register-window stack + frame stack + pc. Making that per-fiber instead of per-VM turns the interpreter into a scheduler almost for free — the BEAM/Lua insight, minus Lua's starvation problem:

  • Preemption by reduction budget — the dispatch loop decrements a counter per instruction (or per call/back-edge); at zero the fiber parks and the next runnable one is picked. No signals, no safepoint asm, no stack copying; deterministic and debuggable. (Erlang has run this design in telecom production for three decades.)
  • Park on I/O — a blocking stdlib builtin on a server shard hands its fd to the shard's epoll/io_uring loop and parks the fiber; the completion resumes it. Same typed builtins, blocking look, no coloring — the systems-track "one API, two execution disciplines" doctrine gains its third discipline: program mode blocks the thread, server shards park the fiber, the source text is identical.
  • Ownership fits — a fiber owns its objects like a shard owns its heap; cross-fiber sends move ownership exactly like iteration 8's cross-shard sends (same rule, cheaper path: same heap, no copy). @gc references stay shard-local either way, so the per-shard cycle collector (staged to iteration 8) needs fiber stacks as additional roots — the one real collector interaction to spec.
  • Cost target — fiber creation is an arena allocation of a small context (hundreds of bytes, the ~58 ns class), park/resume is a pointer swap in the dispatch loop; thousands of fibers per shard where the kernel tops out at hundreds of threads per core.

What must be specified before implementation (open questions)

  1. Surface: spawn returns what — a fiber handle, an actor address, or nothing (fire-and-forget)? Iteration 8's spawn and mailbox surface should be the same word; fibers refine its granularity.
  2. Reduction budget size and where it is checked (per instruction vs per call/back-edge) — measure both in the interpreter before choosing.
  3. Parked-fiber lifetime: what drops a fiber blocked forever (shard shutdown, blue-green drain)? Unwinding a parked fiber must run its drop maps — same machinery as trap unwind (OOP spec §6).
  4. Fairness: run queue is FIFO per shard in v1; priorities/timers are a later capability (the recipe-box rule — no framework policy in the runtime).
  5. Program mode: stays fiber-free in v1 (one thread, blocking legal) or gains the same scheduler? Default: fiber-free — log-watcher needs none.

Doctrine check

Kernel primitives only (epoll/io_uring already ours; no new syscalls needed) · no signals · no async/await keyword · ownership moves, never shares · per-shard everything (heap, GC, now run queue) · plain diagnostics (a starved-fiber warning names the hot function via the line table). No principle bends.