6 KiB
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).
@gcreferences 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)
- Surface:
spawnreturns what — a fiber handle, an actor address, or nothing (fire-and-forget)? Iteration 8'sspawnand mailbox surface should be the same word; fibers refine its granularity. - Reduction budget size and where it is checked (per instruction vs per call/back-edge) — measure both in the interpreter before choosing.
- 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).
- 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).
- 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.