Fibers and brew
A fiber is a green thread: its own stack, scheduled by the Beans runtime on
a worker thread rather than by the operating system. brew f(args) starts f
on a child fiber of the current scope. While a fiber waits on a socket, channel,
or timer, it parks so the worker can run other fibers.
Any function may park. There is no async keyword or await at the call site.
import std.io
fn handle(order: Order) -> Result<int> { brew warm_cache(order) // a child fiber, no handle kept let price: Brew<int> = brew quote(order) // a child, handle kept let stock: int = count_stock(order)? // parks if it must; reads like sync code match price.join() { // park until the child finishes ok(value) => { return ok(value + stock) } err(problem) => { return err(problem.msg, problem.kind) } }} // scope exit joins warm_cachebrew takes a call. It evaluates the arguments now and runs the call on a
child fiber of the current scope, pinned to the current worker thread.
- As a statement,
brew side(9)starts an anonymous child. Nobody holds a handle, so the scope exit joins it. - As an initializer,
let h: Brew<int> = brew work(21)keeps the handle.
brew is contextual, like unique and packed: it is special only directly
before a call, in statement or initializer position. A local, field, or
parameter named brew keeps working, which is why the TaskGroup method below
reads group.brew(...). A brew anywhere else — inside a larger expression —
is refused, because the handle it makes is bound to the scope.
Methods brew too, and a move-only argument moves into the child:
let counter: Counter = new Counter(10)let bumped: Brew<int> = brew counter.bump(5)
let data: List<int> = [1, 2, 3, 4]let total: Brew<int> = brew sum(move data)Brew<T>
Section titled “Brew<T>”Brew<T> is the handle for one child. It is move-only and scope-bound.
Brew<T>.join() -> Result<T>Brew<T>.cancel()join()parks the caller until the child finishes, then answersok(value), or anerrwhen the child panicked (kindpanic) or was cancelled (kindcancelled). Joining twice answers anerrof kindclosed— the first join consumed the outcome.cancel()requests cancellation and returns immediately. It never cancels work that already finished, and cancelling twice does nothing.
A Brew<T> is not Send: the child belongs to the scope and the worker that
brewed it. Use thread.spawn to hand work to another
thread.
The handle also cannot escape its scope. It may not be stored in a field, captured by a closure, returned, or put in a collection — a leaked fiber is not representable:
class Job { pending: Brew<int> // error: a Brew handle is scope-bound}The scope owns its fibers
Section titled “The scope owns its fibers”Every fiber a scope brews belongs to that scope, and the scope will not exit without it.
- Normal exit joins. Falling off the end of a scope, or returning from it,
parks until every un-joined child has finished — newest first, interleaved
with
deferin the order the scope armed them. - Error exit cancels, then joins. Leaving through
?propagation or a panic first cancels every un-joined child, then joins them. Cancellation is a request; the join still waits for each child to unwind. - An unseen failure escalates. If the scope exit joins a child that
panicked and no
join()ever saw that failure, the parent panics at the scope exit with the child’s message and position.
Because the join happens at scope exit, an un-joined child runs after the last statement of the scope:
import std.io
fn side(x: int) { io.println("side {x}")}
fn main() { brew side(9) io.println("end of main")}That program prints end of main, then side 9.
Cancellation
Section titled “Cancellation”Cancellation is cooperative and park-scoped. A cancel request is observed at the fiber’s next park, or immediately if it is parked already. Straight-line code between parks is never interrupted, so a child that never parks simply finishes:
let spinner: Brew<int> = brew spin()spinner.cancel()match spinner.join() { ok(value) => { io.println("finished anyway {value}") } err(problem) => { io.println("cancelled: {problem.kind}") }}The cleanup behavior below is available from v0.1.51. Release v0.1.50 abandons cancelled frames, including on these targets.
A cancelled park does not return a value to the code that parked. The fiber
unwinds on targets with controlled cleanup: ELF and Mach-O on x86_64 and
arm64. On those targets, both executors run armed defers newest-first exactly
once, drop owned values, and request cancellation of every child owned by the
exiting scope before joining any. Cleanup may park; later cancellation requests
cannot interrupt an unwind already running. Other targets retain frame
abandonment in both executors, so cancellation does not guarantee resource or
child cleanup there. The join reports kind cancelled.
Code that must not be cancelled mid-protocol does not park
mid-protocol, the same discipline synchronous code already has.
A panic stops one fiber
Section titled “A panic stops one fiber”A panic unwinds the fiber’s stack, running its defers and dropping its values.
The failure, including its message and source position, is returned by join
as a catchable error. Unhandled failures and poisoned locks can affect other
fibers, as described below.
import std.io
fn boom(a: int) -> int { if a > 0 { panic("boom at {a}") } return a}
fn main() { let risky: Brew<int> = brew boom(5) match risky.join() { ok(value) => { io.println("value {value}") } err(problem) => { io.println("caught {problem.kind}: {problem.msg}") } } io.println("still running")}An unhandled panic on the main fiber ends the program. A panic while holding a
Mutex poisons the lock. Every later with_lock call on that mutex panics
with kind poisoned.
contained
Section titled “contained”contained catches a panic from a call on the current fiber without spawning
or joining a child fiber. It runs the call under a catch frame and returns
Result<T>, where T is the function’s declared result type.
import std.io
fn handle(request: int) -> string { if request < 0 { panic("bad request {request}") } return "ok {request}"}
fn main() { match contained handle(-1) { // runs right here ok(body) => { io.println(body) } err(problem) => { io.println("{problem.kind}: {problem.msg}") } } io.println("still running")}A panic raised anywhere under that call unwinds the frames between it and the
boundary — defers newest-first, owned values dropped — and arrives as an err
of kind panic carrying the message and position.
containedis contextual, likebrew,uniqueandpacked: it opens a catch frame only before a call to a function or method. A local namedcontainedstays an ordinary name.- Unlike
brewit is an ordinary expression, legal wherever one is — inside a loop, anif, a match arm, aletinitializer, a match scrutinee. - The arguments are evaluated outside the frame. A panic while evaluating one is not this call’s to catch.
- The innermost
containedbetween a panic and the top of the stack is the one that answers, and the caller’s own frame is not unwound. - A cancel is not caught. It passes through
contained, runs the enclosing frames’ cleanup, and reaches the fiber entry as kindcancelled. A panic raised while the fiber is already unwinding is still the fatal double panic. - Method calls contain through a reference receiver, a class or an
interface, only;
inoutarguments cannot ride through it.
TaskGroup<T>
Section titled “TaskGroup<T>”When the number of children is a runtime value, use a TaskGroup<T>.
new TaskGroup<T>()TaskGroup<T>.next() -> Option<Result<T>>TaskGroup<T>.try_next() -> Option<Result<T>>TaskGroup<T>.wait_all() -> Result<List<T>>TaskGroup<T>.cancel_all()group.brew(f(x))starts a child exactly as a lonebrewdoes. Unlike a lonebrew, it is legal at any block depth.next()parks for the earliest unclaimed completion and answerssome(ok(v)),some(err(e)), ornoneafter every result has been collected. Results arrive in completion order.try_next()returns immediately, withnonewhen no result is ready.wait_all()joins the rest and answersokof a list in spawn order, or the first failure in spawn order.cancel_all()cancels newest-first, joins, and discards every outcome.
A drained group is reusable. The group carries the same scope-bound walls a
Brew handle does, and its scope exit escalates the first unseen panic.
import std.io
fn double(n: int) -> int { return n * 2}
fn main() { let group: TaskGroup<int> = new TaskGroup<int>() var n: int = 1 for n <= 3 { group.brew(double(n)) n += 1 } match group.wait_all() { ok(values) => { io.println("sum of {values.len()} answers") } err(problem) => { io.println("fleet failed {problem.kind}") } }}Gate is a sticky broadcast flag. One open() wakes every parked waiter at
once, and the gate stays open for every later wait().
new Gate()Gate.wait()Gate.open()Gate.is_open() -> boolwait()parks until the gate opens, and returns immediately once it is open.open()opens the gate and wakes the whole waiting line in FIFO order.is_open()reads the flag without parking.
Unlike a Brew handle, a Gate is Send, so an OS thread can open a gate that
fibers are parked on.
Fibers and threads together
Section titled “Fibers and threads together”Fibers and OS threads answer different questions. A fiber is for waiting — thousands of them park on sockets and timers at almost no cost, but they all share one worker thread and cannot use a second core by themselves. A thread is for CPU-heavy or genuinely blocking work, and for reaching another core.
They compose: main() runs as the root fiber, every thread can host fibers of
its own, and channels, Gate, timers, and sleep park a fiber instead of
blocking its worker.
Where fibers are not available
Section titled “Where fibers are not available”Restricted targets have no scheduler. wasm and freestanding builds reject
brew, Brew, and parking operations at compile time, including timer and
channel waits. Synchronous Beans programs are supported on these targets.
See also
Section titled “See also”- Concurrency, OS threads, channels, mutexes, atomics.
- Ownership handles,
Brew,TaskGroup,Gate, and the rest of the builtin handles. - Errors, the
ResultandErrorthese joins answer with.