Lazy evaluation, concurrency, and parallelism (@)
The @ sigil rolls lazy evaluation, concurrency, and parallelism into one primitive. The shape rests on one-shot delimited continuations and linear types.
A lazy binding let @x = expr suspends expr as an unevaluated thunk. Forcing with @x runs it. Within a force expression, thunks with no data dependency on each other run side by side under DAG scheduling; the source order has no say in what fires when.
Design rationale
- No async/await keywords:
@slots into the sigil system already in place (%linear,&borrow,@lazy), so no new keywords show up. - Lazy, then forced: JS Promises evaluate eagerly and swallow errors quietly. An
@thunk waits until you force it. - One-shot continuation: The runtime uses one-shot delimited continuations (compare OCaml 5). Linear types keep the use to one, so there’s no copy and no multi-shot.
- Exception propagation: There’s no separate rejection channel. A
throwinside a thunk surfaces at the force site through the standardtry/catch.
Sigil Table
| Sigil | Meaning | Evaluation | Linearity |
|---|---|---|---|
| (none) | Value | Eager | Non-linear |
% |
Linear value | Eager | Linear |
@ |
Suspended computation | Lazy | Linear |
& |
Borrow | — | Borrow |
Syntax
let @x = expensive_computation() // creates thunk, NOT evaluated
let result = @x // forces thunk, evaluates now
@T is the type of a suspended computation producing T:
let delayed: @string = @("hello" ++ " world")
Parallel Evaluation
@x forces a single thunk. To run a list of thunks side by side, opt in through the stdlib helper force_all. The wrapper ships in the lazy stdlib module.
let @p1 = compute1()
let @p2 = compute2()
let xs = force_all(tasks: [p1, p2]) // [p1's result, p2's result]
Each thunk is dispatched as a task — one struct in linear memory — and joined left-to-right. The dispatch primitives lazy_spawn and lazy_join live in nxlib/stdlib/runtime/lazy.nx.
Status note. The current
lazy_spawnruns sequentially. It forces each thunk inline and stores the result, soforce_allruns the tasks one after the other. Awasi:threadsworker entry point (wasi_thread_start) is exported for runs underwasmtime -S threads, but the sequentiallazy_spawndoes not invoke it. The plan is to switch over to the component-modelfuture<T>for real parallel execution. That switch is tracked as future work.
Linearity
@T is linear. A one-shot continuation must be consumed exactly once. The three consume forms are:
| Operation | Executes? | Waits? | Use case |
|---|---|---|---|
@x (force) |
Yes | Yes | Normal evaluation |
detach(a: x) |
Yes | No | Fire-and-forget |
cancel(a: x) |
No | — | Discard unneeded computation |
An unconsumed @T is a build error. Copying is forbidden, so a thunk runs at most once.
@’s linearity is orthogonal to the result’s linearity:
let @a = compute_string() // @string — result is copyable
let @b = acquire_server() // @%Server — result is linear
let s = @a // s: string (non-linear binding)
let %srv = @b // %srv: %Server (linear binding)
Capturing @x in a closure makes the closure itself linear:
let @x = heavy_compute()
let f = fn () -> i64 do @x end // f captures @x → f is linear
f() // OK: consumes f
f() // ERROR: f already consumed
Deadlock Freedom
Linear types prevent deadlock by construction.
- No forward references.
letbindings run in order. A thunk cannot reach a later@value, so a simple cycle never lands in the source. - No sharing.
@Tcannot be copied. Two thunks cannot share an@value, so a circular dependency is unreachable. - Acyclic DAG. The parallel evaluation DAG comes from the AST. The AST is a tree, and a tree has no cycles.
Data Race Freedom
The existing borrow checker prevents data races during parallel force:
let %arr = [| 0, 0 |]
let @a = do let lock = &%arr; lock[0] <- 1 end
let @b = do let lock = &%arr; lock[1] <- 2 end // ERROR: %arr already borrowed
Sharing mutable state across parallel thunks requires explicit concurrency primitives — channels, atomics, and the like.
Exception Semantics
A throw inside a thunk surfaces at the force site, where the usual try/catch picks it up:
let @result = do
throw NotFound(path: "/missing")
end
try
let v = @result // force → exception propagates here
catch
| NotFound(path: p) -> handle(p: p)
end
When force_all runs and one of its thunks throws, the exception surfaces at the join point. The other thunks’ results are then discarded. Linear types keep this leak-free.
Standard Library (std:lazy)
Only the @ sigil is built into the language. The rest ship as stdlib functions.
| Function | Signature | Description |
|---|---|---|
force_all |
(tasks: [@T]) -> [T] |
Forces every thunk and returns the results in input order (currently sequential; see “Parallel Evaluation” above) |
detach |
(a: @T) -> unit |
Forces the thunk and discards the result |
cancel |
(a: @T) -> unit |
Forces the thunk and discards the result (no runtime drop hook exists — cancel and detach are observationally equivalent) |
race |
(a: @T, b: @T) -> T |
Forces a then b sequentially, returns a’s result (no first-of-two arbiter — both arguments run) |
Current Implementation Status
The @ sigil is fully implemented:
- Thunk creation:
let @x = expr— desugars to zero-argument closures - Force:
@x— evaluates and consumes the thunk (one-shot) - Type system:
@Ttracked as linear; unconsumed@Tis a compile error (including primitives like@i64) - Bare-name access:
x(without@) references the thunk without forcing — enablescancel(a: x)/detach(a: x) - Closure linearization: Capturing
@xin a closure makes the closure linear std:lazy:race,cancel,detach,force_allcombinators — all currently sequential. Seenxlib/stdlib/runtime/lazy.nxfor the task-struct dispatch primitives (lazy_spawn/lazy_join).
See also: Exception Groups, Types, Syntax