Having real continuations helped with adding support for core.async on Jolt

yogthos1 pts0 comments

(iterate think thoughts): Fibers in Jolt: green threads that fit core.async

(iterate

think

thoughts)

Theme

August 10, 2026

Fibers in Jolt: green threads that fit core.async

Jolt now provides opt-in support to run go blocks on fibers instead of OS threads. This post will discuss how that works along with the different trade-offs made compared to other implementations, and why core.async turns out to be a great fit for the mechanism. All the numbers below are from an Apple M1 Pro with 10 cores, measured with the harness in bench/fibers on the current tree.<br>The problem with a thread per process<br>As you probably know, core.async's programming model is best served by cheap green processes that communicate over channels. Jolt's original implementation backed every go block with a real OS thread. Using system threads affords the same semantics, and it has a nice property that there's nothing special about a go body. But real threads have significant overheads putting a limit on the number you can reasonably have, and they're not particularly cheap to start.<br>Let's take a look at what that ceiling looks like for threads and fibers when spawning K processes that each immediately park on an empty channel:<br>backendKcreatedper spawnfiber10,00010,0001.09 µsfiber100,000100,0000.74 µsthread1,0001,00053 µsthread10,0004,080168 µsthread100,0004,079159 µsAs the number of threads goes up, they effectively stop working. My machine runs out somewhere around 4,080 live threads, and by that point, the spawn cost has already gone up 3x. Memory story isn't encouraging either with a parked process costing 4,160 bytes of live heap on a fiber while sitting at 68,729 bytes on a thread. That's a 16.5x difference as a base, and measured as peak RSS rather than live bytes the gap widens to 44x since a thread needs a guard page along with a real stack mapping. So the motivation to have a light weight mechanism should be pretty obvious.<br>What a fiber is here<br>A fiber consists of a record holding a state, a body thunk, a continuation slot, an intrusive run queue link, a slice of per-fiber dynamic state, and the carrier it belongs to. In addition, the scheduler needs a little bookkeeping to track the pending step, the fiber's registered monitors, and the interrupt depth it parked at. Parking captures the current continuation with call/1cc and jumps to the scheduler, and that continuation is what gets invoked when resuming. That's all there is to it at a high level, and on Chez a bare continuation switch measures just 8.6 ns.<br>The current fiber lives in a Chez virtual register costing about 2 ns to read. Since the cost is so low, a scheduler can do millions of switches per second. The full scheduler yield, including swapping the fiber's dynamic slice and arming the preemption timer described further down, comes to around 137 ns.<br>Another consideration here is that a continuation on Chez is a stack segment, which is not free. A completed fiber that holds no continuation costs just 108 bytes, but the moment it parks, the cost jumps to about 4,177 bytes. Interestingly, that number barely moves with stack depth:<br>shapebytes per fibercompleted, no continuation108parked, 1 frame4,177parked, 3 nested calls4,187parked inside a dynamic-wind4,281So a fiber isn't actually cheap because its stack is small, but due to 4 KB being much less than the 69 KB an OS thread costs, which makes it possible to have hundreds of thousands of them running concurrently.<br>Why core.async fits<br>On the JVM, go had to be a macro that CPS-transforms its body into a state machine because the JVM had no continuations when core.async was originally written. That's the key reason why and >! only work lexically inside a go block. Putting a parking take inside a function then calling it from a go body does not work since the macro cannot see past the call boundary to rewrite it.<br>Jolt, on the other hand, has real continuations, so there's no need for the transform to park. A fiber's registers a waiter on the channel and captures its continuation, wherever it happens to be. With this approach, parking works through arbitrary call depth, helper functions, callbacks, or even eval. The whole limitation core.async has on the JVM goes away on the Chez runtime.<br>Jolt ships its own native channels, but it implements the same design for the waiter protocol. A channel operation that cannot complete immediately registers a handler and waits to be woken. The only difference from threads is that they wait on a condition variable while fibers wait by parking. The channel core doesn't need to know which it is talking to. All it has to do is commit to a handler under its lock, write a mailbox, and call a wake function. The immediate-completion path, where a buffered value or a waiting putter is already there, captures nothing and never touches the scheduler at all.<br>Adding fibers meant adding a second wake strategy, and the blocking variants fall out from that as well. On a fiber, and >!! park exactly...

fiber core threads async jolt fibers

Related Articles