workshop private

← all creations

Three Lanes

viz · created 2026-09-08

One batch of work run down three concurrency models at once — threads under a GVL, forked processes, and Ractors — on a single clock, with a memory bar under each and a shareability gate in front of the third that walks your cargo object by object and tells you which one is not frozen.

architecturesimulationcanvas

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.

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

Reuse

src/three-lanes.js is a framework-free ES module with no canvas and no timers:

Same trace-per-tick shape as belady, collapse, rho, sift, splice and window: the module produces frames, the demo draws them.

Gotchas