Skip to content

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_cache

brew 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> 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 answers ok(value), or an err when the child panicked (kind panic) or was cancelled (kind cancelled). Joining twice answers an err of kind closed — 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
}

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 defer in 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 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 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 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.

  • contained is contextual, like brew, unique and packed: it opens a catch frame only before a call to a function or method. A local named contained stays an ordinary name.
  • Unlike brew it is an ordinary expression, legal wherever one is — inside a loop, an if, a match arm, a let initializer, a match scrutinee.
  • The arguments are evaluated outside the frame. A panic while evaluating one is not this call’s to catch.
  • The innermost contained between 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 kind cancelled. 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; inout arguments cannot ride through it.

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 lone brew does. Unlike a lone brew, it is legal at any block depth.
  • next() parks for the earliest unclaimed completion and answers some(ok(v)), some(err(e)), or none after every result has been collected. Results arrive in completion order.
  • try_next() returns immediately, with none when no result is ready.
  • wait_all() joins the rest and answers ok of 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() -> bool
  • wait() 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 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.

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.

  • Concurrency, OS threads, channels, mutexes, atomics.
  • Ownership handles, Brew, TaskGroup, Gate, and the rest of the builtin handles.
  • Errors, the Result and Error these joins answer with.