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

96 lines
6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fibers — green threads on the wovm shard scheduler
> Research note (2026-08-08) feeding story iteration 11. Expands
> [the blue-green vision §3](../blue-green-vm/00-vision.md) 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](https://learn.padho.ai/wiki/threads-and-the-os-kernels-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.