Skip to content

Concurrency

Almide provides structured concurrency through the fan construct. All concurrent work is scoped, cancellable, and fail-fast. There is no unstructured spawn.

fan { } runs multiple expressions concurrently and returns their results as a tuple:

effect fn load_dashboard(user_id: Int) -> Result[Unit, String] = {
let (user, posts, settings) = fan {
fetch_user(user_id)
fetch_posts(user_id)
fetch_settings(user_id)
}
println("${user.name} has ${int.to_string(list.len(posts))} posts")
ok(())
}

Each expression runs in parallel. Results are collected as a tuple in declaration order.

With one expression, the result is not a tuple:

effect fn one() -> Result[Int, String] = {
let result = fan {
add(10, 20)
}
ok(result) // Int, not a tuple
}

Sequential dependencies between parallel stages:

effect fn pipeline() -> Result[Unit, String] = {
// Stage 1: independent
let (a, b) = fan {
fetch_from_api()
load_from_cache()
}
// Stage 2: depends on stage 1
let (processed, stored) = fan {
process(a, b)
store(a, b)
}
ok(())
}

fan can capture let bindings from outer scope:

effect fn with_capture() -> Result[Unit, String] = {
let config = load_config()
let offset = 100
let (a, b) = fan {
fetch(config.url_a)
add_offset(42, offset)
}
ok(())
}

fan has strict rules to prevent data races:

RuleReason
Only inside effect fnPure functions cannot fork concurrent work
Expressions onlyNo let, var, for, or while inside fan blocks
No var captureOnly let bindings from outer scope (prevents data races)
Fail-fastIf any expression returns err(...), the fan fails with that error. Sibling side effects may still complete on native — only the returned value is guaranteed

What is not allowed:

effect fn add(a: Int, b: Int) -> Result[Int, String] = ok(a + b)
effect fn bad() -> Result[Unit, String] = {
var counter = 0
fan {
add(counter, 1) // error[E008]: cannot capture mutable variable 'counter' inside fan block
}
ok(())
}
// error: `let` is not allowed inside fan block
fan {
let x = fetch()
x + 1
}

Map over a collection with fail-fast error propagation; results keep input order. It runs sequentially today on both targets — see How it runs.

effect fn double(x: Int) -> Result[Int, String] = ok(x * 2)
effect fn fetch_all() -> Result[List[Int], String] = {
let results = fan.map([1, 2, 3, 4, 5], (x) => double(x))
// results: [2, 4, 6, 8, 10]
ok(results)
}

Works with outer captures:

effect fn with_offset() -> Result[List[Int], String] = {
let offset = 100
let results = fan.map([1, 2, 3], (x) => add_offset(x, offset))
// results: [101, 102, 103]
ok(results)
}

Empty list returns []:

effect fn empty_case() -> Result[Unit, String] = {
let results = fan.map([], (x: Int) => double(x))
println(int.to_string(list.len(results))) // 0
ok(())
}

If any invocation returns err(...), the entire fan.map fails.

Run multiple tasks and take the result of the first one to settle:

effect fn fastest_mirror(mirrors: List[String]) -> Result[String, String] = {
let content = fan.race(list.map(mirrors, (url) => () => http.get(url)))
ok(content)
}

fan.race takes a list of thunks (zero-argument functions).

The winner is decided by list order, not by wall-clock speed — the same input always produces the same result, on both targets:

effect fn fast() -> Result[String, String] = ok("fast")
effect fn slow() -> Result[String, String] = ok("slow")
effect fn pick() -> Result[String, String] = {
let winner = fan.race([
() => slow(),
() => fast(),
])
ok(winner) // "slow" — it is first in the list
}

This is deliberate. A race whose outcome depends on timing would make a program non-reproducible and would break the native/wasm equivalence guarantee, so race means “I accept any one of these”, not “give me whichever machine happens to finish first”.

Like fan.race but skips failures. Returns the first successful result:

effect fn primary() -> Result[Int, String] = err("down")
effect fn fallback() -> Result[Int, String] = ok(42)
effect fn pick_available() -> Result[Int, String] = {
let result = fan.any([
() => primary(),
() => fallback(),
])
ok(result) // 42 — primary failed, fallback wins
}

Use this for redundancy patterns (try multiple sources, use first that works).

Run all tasks to completion and collect all results, including failures:

effect fn succeed(x: Int) -> Result[Int, String] = ok(x)
effect fn fail_with(msg: String) -> Result[Int, String] = err(msg)
effect fn run_all() -> Result[Unit, String] = {
let results = fan.settle([
() => succeed(1),
() => fail_with("bad"),
() => succeed(3),
])
// results: [ok(1), err("bad"), ok(3)]
println(int.to_string(list.len(results))) // 3
ok(())
}

Unlike fan blocks which are fail-fast, fan.settle never short-circuits. Useful for batch operations where partial failure is acceptable.

fan.timeout existed once and was removed. Using it is a diagnosed error:

error[E027]: fan.timeout was removed: a wall-clock timeout has no portable
cross-target meaning

A deadline measured in milliseconds cannot mean the same thing natively and in a wasm sandbox, so it would have made programs behave differently per target — the one thing the language refuses to do. Enforce deadlines at the boundary that invokes the program instead:

timeout 5 ./app
FunctionBehaviorFailure mode
fan { a; b }Run expressions concurrently, return tupleFail-fast: first err cancels all
fan.map(xs, f)Deterministic map, list orderFail-fast
fan.race(thunks)First in list order settles the raceFirst result (success or failure)
fan.any(thunks)First success in list order winsAll must fail for error
fan.settle(thunks)Run all, collect all resultsNever fails

All five are deterministic — same inputs, same result, both targets. (fan.any once miscompiled on wasm, returning 0 unless the winning thunk was last — #900 — that was fixed and the fix is pinned by a cross-target fixture.)

TargetImplementation
Nativestd::thread::scope for settle and fan { } blocks; race evaluates only the head thunk; map and any run sequentially
WASMSequential — the target is single-threaded

The asymmetry is not a gap in the wasm backend, it is the point. Because a fan thunk cannot capture a var (that is a compile error), the thunks are pure, so running them in parallel and running them in order produce the same values. Parallelism is an implementation detail the language is free to drop; the result is not. That is what lets the native and wasm legs stay byte-identical while only one of them actually uses threads.

  • Structured — all concurrent work has a clear scope and lifetime
  • No shared mutable statevar capture is forbidden in fan
  • No unstructured spawn — you cannot fire-and-forget
  • Fail-fast by default — errors propagate immediately (use fan.settle when you need partial results)
  • Composable — stage fan blocks sequentially when tasks depend on each other