The same twelve jobs, the same five work units each, dispatched simultaneously down three lanes. One clock across all of them, so the comparison is not a claim — it is three bars finishing at different times in front of you.
- Threads — one process, one heap, one GVL. Every worker that wants the CPU queues for a single token, drawn as a badge that hops from box to box.
- Processes —
fork. Genuinely parallel, and every worker pays for it in pages: a fork cost up front, then private memory accumulating as each one writes. - Ractors — parallel and cheap, but the cargo has to get through a gate first.
The GVL is not the punchline
Set I/O share to 0 and the threads lane crawls: eight threads finish in the same 60 ticks one thread would, because only one of them ever holds the token. Adding threads changes nothing. That is the demo everyone has seen.
Then drag the I/O share up. A thread blocked on the network has released the GVL, so at 80% I/O all three lanes finish together — and the threads lane does it on one heap, with no fork cost and no gate. That is the crossover that actually decides your architecture, and it is why Puma running threads is not a lie. The piece is built so you can find the crossing point yourself rather than take a slogan about it.
Turn workers down to 1 and a third answer appears: the threads lane wins
outright, because fork and Ractor.new cost ticks a thread does not.
Parallelism you never use is pure overhead.
The gate is the punchline
The Ractors lane runs only if the cargo it carries is shareable, and that check
is deep. Pick ["api", "web"].freeze from the cargo list — a frozen Array
— and the lane never starts a single job. The walk at the bottom shows why:
✓ ["api", "web"].freeze frozen — check what it holds
✗ [0] "api" String is not frozen
✗ [1] "web" String is not frozen
Config.new("api", 3).freeze fails the same way one level down, at @name.
Freezing the wrapper is the mistake the piece is built to make visible: the
object you froze is fine, and the thing it is holding is not. [1, 2, 3].freeze
passes, because Integers are shareable whether or not anyone froze them.
The Ractor.make_shareable(…) button deep-freezes the current cargo and
the lane wakes up — which is the honest fix for most of these. Press it on
->(n) { n * factor } and it does not: a Proc drags its binding along, and
isolating one that reads an outer local raises instead. That cargo has to be
rewritten, not frozen.
None of this is visible in the other two lanes. Threads take the object by
reference and never ask; processes copy it at fork and never ask. Only the
Ractor lane audits, which is the whole reason “make it Ractor-ready” is slow
work in a large codebase: it is not a scheduler problem, it is an inventory of
everything mutable you were quietly sharing.
Reading the lanes
- A green box is a worker executing a CPU unit; a blue one is blocked on I/O and, in the threads lane, holding no lock. A dashed amber outline is a thread that wants the CPU and is waiting for the token.
- time and mem bars sit on scales shared across all three lanes, so
they can be compared by eye.
timefreezes when a lane finishes;memkeeps tracking, which is how the processes lane’s bar keeps growing after the fork as copy-on-write pages go private. - The counter under each row is parallel CPU units this tick. The threads lane never reads above 1. That single number is the GVL.
Reuse
src/three-lanes.js is a framework-free ES module with no canvas and no
timers:
simulate({ workers, jobs, slices, ioFraction, cargo, seed })— one frame per tick, each frame carrying all three lanes’ worker states, completed counts, live memory and the GVL holder.gateWalk(value)— the shareability descent as an indented step list ({ depth, slot, kind, label, ok, note }), so a caller can draw the check rather than assert the verdict.isShareable(value)if you only want the boolean.makeShareable(cargo)— deep-freeze, or the Proc isolation failure.CARGO,cargoById,LANES,verdicts,rng(seed).
Same trace-per-tick shape as belady, collapse, rho, sift, splice and
window: the module produces frames, the demo draws them.
Gotchas
- The memory numbers are shaped, not measured — a plausible curve for copy-on-write going private, not a benchmark. The ordering is the claim (one heap ≪ isolated heaps ≪ forked copies), not the megabytes.
- The gate walk keeps checking siblings after the first failure so you can see
every offender; real
Ractor.shareable?short-circuits on the first one. - A tick is a work unit, not a millisecond. Fork costs 3 ticks and
Ractor.new1 — enough to make the one-worker case come out right, which is the only thing those constants are there to do. - The demo bundles its own copy of
three-lanes.js(self-contained by contract); re-copy after editingsrc/.

