writeonce/docs/plan/exploration/fibers/00-fibers.md
shoney.arickathil c0b0dbb846 docs: audit all markdown against the code, fix findings, flatten status folders
- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet
  available"; "no package manager" contradicted [deps]; the deps example
  would not have compiled (the key IS the module name)
- runtime/README: leads with wovm, wo-rt.c demoted to a historical section;
  dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18
- employee + log-watcher READMEs claimed "does not compile"; both are gates
- error catalog: +10 emitted codes incl WO-E250, the only diagnostic the
  shipped query surface raises; recorded why the sweep rotted
- language-surface: group-by parses, then the typechecker refuses it
- 00-code-review + 00-link-audit re-run; history kept, not rewritten
- 48 dead Rust-era exploration links de-linked rather than re-pointed (their
  prose names the retired plan by number); successor map -> discarded.md
- 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty
- releasing.md: dropped a --draft step the workflow never had
- new docs/00-doc-audit.md: findings + disposition, incl one row where the
  audit was wrong and the doc it accused was right
- status folders removed: 34 stories flat, status only in frontmatter; 252
  links recomputed from resolved paths; board/board-views/structure retaught
- story 24 -> in-progress, since frontmatter is now the only truth
- new iteration 38: fs mutation verbs + net.connect, the two capability
  families no iteration owned
- new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called
  CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no
  source of randomness at all
- linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:20:22 +02:00

8.3 KiB
Raw Permalink Blame History

Fibers — the concurrency model, its terminology, and why writeonce chose what it chose

Exploration/reference note (no status banner by board convention). The normative decisions live in the arc spec (2026-08-20-shard-fiber-arc-design.md) and iterations 8 / 11; this page explains the WHY at doctrine depth. Written 2026-08-20, when this file was also the target of a dangling reference from iteration 11 — it exists now.

Demonstrated live by docs/examples/fibers (just fibers, 8 checks): byte-exact budget interleave, a parked sleeper blocking nobody, on the io_uring AND epoll backends.

What a green thread is, and why anyone bothers

A green thread (fiber, virtual thread, coroutine — the tradition picks the name) is a thread scheduled by the LANGUAGE RUNTIME, not the kernel. The kernel sees a handful of OS threads; the runtime multiplexes thousands-to-millions of logical threads on top.

The economics: an OS thread costs a fixed stack reservation (typically 1–8 MiB of address space) and every context switch goes through the kernel scheduler (CFS is O(log N), and task counts in the hundreds already hurt). A green thread can start around 2 KiB and switch in tens of nanoseconds — a register save and a pointer swap, no syscall. That is the difference between a few thousand concurrent connections and a few million.

The terminology, untangled

  • Green thread — originally Java pre-1.3's model, which was M:1 (everything on ONE OS thread — no parallelism at all). Now used loosely for any user-space thread.
  • Fiber — usually implies COOPERATIVE: it runs until it explicitly yields.
  • Coroutine — the broadest term; covers stackful and stackless both.
  • Goroutine — Go's M:N implementation, preemptively scheduled.
  • Virtual thread — Java 21's, also M:N.

The load-bearing distinction between "fiber" and "goroutine" is NOT the stack — it is the EVICTION POLICY. A classic fiber owns the CPU until it yields; one selfish loop starves everything behind it. A goroutine can be interrupted WITHOUT ITS CONSENT (since Go 1.14, via a signal that stops it at a safe instruction).

M:1 vs M:N matters concretely here: one process embedding its own storage would leave every core but one idle under M:1. writeonce is M:N — stage 2 of the arc pins one kernel thread per core (shards), and fibers multiplex above them.

writeonce's model: cooperative mechanics, accounting-based eviction

The BEAM shape. Mechanically cooperative — no signals, no interrupts, no async preemption machinery — but every loop back-edge decrements a REDUCTION BUDGET (WO_REDUCTIONS, default 4000), and at zero the fiber is re-queued whether it likes it or not.

What that buys, all three at once:

  • goroutine-grade starvation-freedom (the corpus pins budget-1 as EXACT round-robin across fibers — runtime/test/test_fiber.c);
  • fiber-grade DETERMINISM: same program, same schedule, byte-identical output — signal-based preemption points are nondeterministic by nature, Go's included;
  • zero interruption machinery: an interpreter's dispatch loop is always at a safe point, so the "where can I safely stop this thread" problem Go solves with signal handlers and pc maps costs nothing.

One implementation lesson worth keeping (it cost a livelock): the budget check runs at loop back-edges AFTER the jump lands, so the saved resume pc is the loop head. A pre-instruction save at budget 1 re-executes the jump straight into the same decrement, forever.

Should the grammar have async? No — permanently rejected

async is only NECESSARY in stackless designs, where the compiler must know statically which functions can suspend so it can transform each into a state-machine struct. That requirement is function COLORING: an async function can only be called from another async function, so the keyword propagates virally up every call chain, and the ecosystem forks into sync and async flavors of everything. In writeonce it would color the query API, then every handler that touches data — the exact code that most wants to read as straight-line logic.

With stackful fibers, a blocking read ten frames deep just parks. The suspension point is a RUNTIME fact, not a TYPE-SYSTEM fact. Same source text in program mode and server mode; no keyword; no coloring. This is iteration 11's "async/await — permanently rejected surface, not deferred", with the reasoning attached.

Stackful vs stackless: the real fork, and the recommendation

Stackful (goroutines, Java virtual threads, classic fibers): each logical thread owns a real, growable stack; it can suspend from ANY depth. Costs: growth-by-copy must find and rewrite every pointer into the old stack (Go's machinery), and FFI is the classic killer — C callees assume a large contiguous stack and know nothing about growth, so every foreign call needs a stack switch or a guaranteed segment.

Stackless (Rust futures, JS promises, C# async, Python coroutines): the compiler rewrites each async function into a fixed-size state object. Cheaper, runtime-optional — and it buys the coloring above, plus suspension only at explicit await points.

Recommendation, and what is already built: STACKFUL — because both of its classic costs vanish in this stack.

  1. No FFI, by doctrine. The main argument against small growable stacks is C interop. writeonce has no C to call (the reject row is load-bearing). The cost is simply absent.
  2. The "stack" is register-indexed, not address-based. A wo_fiber's state is the interpreter's register window + frame array — frames reference registers by INDEX, so a context is relocatable by construction. Growth is allocate-larger + memcpy with ZERO pointer fixups; Go's hardest stackful problem does not exist here. Contexts are ~42 KiB fixed today; start-small (~4 KiB) growable contexts are the recorded improvement that takes fiber counts from thousands toward millions (arc spec, "Fiber context growth").

The two problems every design must solve, and where they land here

  • Blocking syscalls. A green thread calling fsync blocks the OS thread carrying it and strands every fiber behind it — and an embedded database fsyncs on every commit, milliseconds each, an eternity for a scheduler. writeonce's answer is io_uring, twice: the arc's per-shard ring parks blocking net/time builtins (T4, readiness ops first, then reads/writes as ring ops), and iteration 23 rides the SAME ring for WAL WRITE+FSYNC group-commit so the DB-owner shard keeps executing while durability drains. epoll survives only as the portability fallback behind a startup probe (WO_IO=uring|epoll) — seccomp'd containers routinely deny io_uring, and the binary must run everywhere. The reference cards live in ../linux/07-io_uring.md.
  • Preemption. Solved by the reduction budget above. And to the standing question "does preemption depend on FFI?" — NO, inversely: preemption holes come from frames the runtime cannot interrupt, which in Go means foreign C frames (its signal preemption skips them). No FFI means no foreign frames; the only non-preemptible regions are our OWN builtins — C we control, bounded, and the blocking ones become parked ring ops. FFI would have CREATED the dependency; its absence is why the budget is airtight.

Precedent survey (what was adopted, what was rejected)

  • BEAM (Erlang/Elixir): adopted — reduction-budget preemption, actor mailboxes, one-message-at-a-time delivery, isolated failure (a fiber's uncaught trap kills that fiber alone).
  • Go: half-adopted — M:N over pinned threads yes; stack-copy pointer rewriting unnecessary here (register-indexed contexts); signal-based async preemption rejected (nondeterministic, and the budget makes it redundant).
  • Rust/Tokio, JS, C#, Python: rejected — stackless coloring is the cost this language exists to not pay.
  • Java Loom: matches — park-under-a-blocking-API is exactly the stdlib posture (net.read blocks in program mode, parks under fibers, same signature).