Standard Library
Concurrency & Async
A plain-object Promise<T>, one privileged operation
(await) with no async keyword and no function coloring, and
thread-backed Worker<T>s that isolate by copying everything across
the boundary. No hidden schedulers — every suspension point is spelled await.
Promise<T>
Promise<T> is a plain library object — there are no special language
rules and no implicit wrapping. A function documented as returning
Promise<T> must construct and return an actual Promise;
the compiler never wraps a bare T for you. It behaves as a one-shot stream:
it resolves at most once, and everything downstream observes that single value.
| Member | Meaning |
|---|---|
Promise() | construct pending — no value yet |
Promise(T value) | construct already resolved with value |
resolve(T value) | settle a pending promise |
isReady() | has it settled? |
get() | read the settled value |
then(cb) | run cb synchronously, in the resolver's own context, once settled |
Promise<int> pending = Promise();
pending.resolve(42);
Promise<int> ready = Promise(42); // pre-resolved, no waiting needed
Promise<int> fetchValue() {
Promise<int> p = Promise();
p.resolve(compute());
return p; // a Promise-returning function IS the async form
}
await is the one privileged operation on a promise — see
await & tasks. There is no async keyword marking a
function and no function coloring: any function may await, and any function
may return a Promise<T>.
Promise<T> carries no scheduler of its own — something else drives
it toward resolution: a Worker join, a timer callback, or a
manual resolve() call. A general-purpose async execution engine
underneath Promise itself is planned.
await & tasks
await expr is a suspension point. If expr is not a
promise-shaped object, await yields it unchanged. If the promise is
already settled, await proceeds without suspending at
all — no scheduling point, deterministic and free. This is the C#
completed-task rule, not JavaScript's always-defer rule.
Promise<int> p = Promise(7);
int v = await p; // settles immediately — no suspension, no interleavingOtherwise the current task suspends: other tasks on the same thread — including new work the event loop dispatches — run while it waits, and the task resumes after the promise settles, in completion order among that thread's runnable tasks. Resumption order is FIFO per thread; it is not the reverse-of-suspension (stack) order an earlier implementation implied, and programs must not depend on either beyond that.
A task is the unit of suspension: the program's top level is a task, and every callback
the event loop dispatches — timer, watch, worker-join — runs as its own task. Tasks are
pinned to their thread; await never moves work across threads
(Channel<T>/Worker<T> remain
the only crossings). Across any await, state on the current thread may be
observed and mutated by other tasks that ran meanwhile — single-threaded interleaving is
real even without data races, and await marks exactly the points where it
can happen.
Because await on a settled promise never enters the scheduler, a loop of
ready-awaits is compute, not yielding — fairness among other runnable tasks only
starts at the first real suspension.
Failure at an await
A Worker that rejected rethrows its failure at the await
(catchable). An await whose promise can no longer be settled — the thread's
event loop has fully drained with the promise still pending — throws a catchable
RuntimeException: await: event loop drained with promise
unresolved.
An uncaught throw inside a loop-dispatched callback terminates the program through the
standard uncaught path; it is never delivered to an unrelated
await. Each callback is its own task with its own failure path:
Promise<int> p = Promise(); // nothing ever resolves p
std::sysTimerStart(5, 0, (n) => { throw RuntimeException("boom"); });
try {
int x = await p;
} catch (IException e) {
console.writeln("caught: " + e.message); // never receives the timer's "boom"
}
The timer callback's throw is program-uncaught (Uncaught RuntimeException:
boom, exit 1) — it does not teleport into the unrelated catch above.
await p separately drains with its own catchable
RuntimeException once the loop is empty.
await inside hermetic comptime
await inside hermetic comptime is a compile error — comptime
evaluation has no event loop to park on.
engines Oracle, IR, and LLVM give
await/tasks full, byte-identical coverage (per-task stacks, FIFO resumption).
emit-C++ has no async surface; the frozen ELF backend is not a target.
Workers & channels
Workers are Leviathan's concurrency execution layer. The model is pure isolation with copy-always boundaries: a worker captures its inputs by copy, runs, and returns a result; every value that crosses a thread boundary crosses by deep copy (flatten/rebuild), so a counted value lives on exactly one execution unit at a time. Immutable zero-copy sharing is a planned v2 optimization.
Worker<int> w = std::spawn(() => {
return expensiveCompute();
});
int result = await w; // the handle IS a Promise<int> — await is the join
Worker<T> std::spawn<T>(() => T body) starts a worker; the
handle itself is a Promise<T>
(Worker<T> : Promise<T>), so await w is the join —
there is no second suspension surface. The body's captures are
snapshotted at the spawn call, so mutating a captured
original afterward is not observed by the worker.
Channel<T>
Channel<T> is a single-producer / single-consumer conduit between
workers.
Channel<int> ch = new Channel(16, std::overflowBlock());
Worker<void> producer = std::spawn(() => {
for (int i in 1..10) { ch.send(i); }
ch.close();
});
Promise<int?> next = ch.receive();
int? item = await next; // None once the channel is closed AND drainednew Channel(int capacity, int policy)- capacity always comes with a policy —
growis rejected. Policy is one ofstd::overflowBlock(),std::overflowDrop(), orstd::overflowError(). send(T value)- copies the value in;
sendon a closed channel throws. receive() -> Promise<T?>- resolves with the next item, or
Noneonce the channel is closed and drained — the receiver narrows with!= None. close()- a captured
Channelhandle is a portal: both units share the one endpoint.
std::cpuCount() reports online logical processors (≥ 1) — useful for
HttpServer(port, workers: cpuCount())-style sizing; pairing it with
sysTcpListen(port, reusePort: true) sets SO_REUSEPORT so N
workers can each run a full accept loop on one port.
Engine coverage
engines The surface runs on all three active
engines by construction, byte-for-byte identically (one shared flatten/rebuild walk). On
the tree-walk oracle and IR interpreter a worker is a cooperative loop task. On the
LLVM backend, a worker is a real OS thread: its own
per-worker TLS heap/arena/event loop, real pthreads, an eventfd
join, and reap-time cleanup — the result rebuilds back on the spawner's thread,
so a Worker/Promise continuation only ever runs on the thread
that owns it. Channel<T> on LLVM is a process-global lock-free SPSC
ring plus two eventfds.
A --target *windows* build rejects any spawn/Channel
at compile time (threads: unsupported on Windows (v1)); the frozen ELF
backend rejects spawn/Channel/sysTcpListen/2
at compile time too.
Output-determinism discipline for worker programs: workers compute and return/send; only the spawning thread prints, at join points. Never print from a worker body — it would race stdout ordering under real threads.
Crossing a thread boundary
Every value that crosses from one worker to another crosses by deep copy. Not everything is flattenable — the copy walk either succeeds completely or fails with a loud, catchable error naming the offending type.
| Crosses | |
|---|---|
| May cross | primitives, char, None, ranges,
struct values, strings, pure Array/Map of
flattenable elements, and statically-shaped class objects (deep, with
shared substructure and cycles preserved) |
| Rejected | a nested closure (only the spawn body itself may
cross as a closure); an fd-/loop-bound carrier
(TcpStream/TcpListener/Timer/Process,
and a disposable InStream whose teardown reaches a live signalfd + loop
watch); a Block |
A plain in-memory InStream still crosses fine — each worker simply opens its
own. Keep loop-bound carriers (open sockets, timers, processes) on their owning thread and
pass a Channel<T> instead.
A spawn body may not reference a Worker<T>/Promise<T>
handle — whether captured through a local, a container, or a bare top-level global —
and sending one through a Channel is likewise a loud, catchable error
naming the type. A cross-thread resolve would run the promise's
continuation on the wrong thread. Channel<T> is the one sanctioned
cross-worker conduit; otherwise, await the Worker<T>
handle spawn returned.
One v1 residual, uniform across every carrier: a carrier reached through a bare
global rather than a captured local is not caught by this check — only a global
Promise is re-scanned at the spawn call.
Cancellation & timeouts
Cancellation is an exception delivered at park points only — a running
task is never preempted; the mark takes effect at its next await.
interface ICancelledException : IException { }
class CancelledException : Exception, ICancelledException { }
CancelledException is an ordinary catchable carrier — no second error
channel. A task may catch it and refuse cancellation, using the same rethrow-point
machinery as a rejected Worker or a drained event loop
(above). An uncaught
CancelledException in a group-owned task is absorbed at the
TaskGroup boundary — cancellation is not a program error — while
an uncaught throw of any other type stays program-uncaught, unchanged.
awaitTimeout
Timeout as an outcome, not a failure:
T? awaitTimeout<T>(Promise<T> work, int ms);
Parks on {work, a timer}; returns None on timeout (the
T?/union rule — expected absence, no exception) or work's value
if it settles first.
awaitTimeout stops waiting; it does not cancel work
— no Promise → producer-task map exists to reach back into it. A
Worker (an OS thread) is never cancelled by timeout either way — threads
cannot be wall-clock killed. Compose with the enclosing group's
cancelAll() for a kill-switch shape:
int? r = awaitTimeout(p, 5000);
if (r == None) { g.cancelAll(); }TaskGroup
Structured concurrency via the existing using/IDisposable rule
— zero new syntax.
class TaskGroup : IDisposable {
new TaskGroup();
void run(() => void body); // start a child task, owned by this group
void cancelAll(); // mark every live child cancelled
void close(); // cancelAll(), then join every child
}using TaskGroup g = TaskGroup();
g.run(() => { doWork(1); });
g.run(() => { doWork(2); });
// close() runs on every exit edge: fall-off, return, throw, break
using TaskGroup g = TaskGroup(); runs close() on every exit edge
of the block (fall-off, return, throw, break): stragglers are cancelled and every child is
joined before the block's scope is left, so no task outlives its lexical
scope.
g.run vs. spawn
g.run starts a same-thread task — cheap and cancellable. That's
deliberately different from std::spawn's
thread-backed Worker<T>, which is parallel and uncancellable. The
two are never meant to blur.
Cancellation delivery is masked while a task runs its own close() unwind (the
shield rule), so a using nested under an already-cancelled task cannot
livelock. A child that catches CancelledException and refuses to park again
leaves close()'s join waiting — loud, not hung (a
[tasks] uncancellable=N report under LANG_TASK_STATS=1) — and
the join still completes once the refuser eventually parks or returns.
engines Oracle, IR, and LLVM share full,
byte-identical coverage of cancellation and TaskGroup (a task-struct cancel
mark, a per-task shield-mask counter, a thread-local id→task registry). emit-C++
has no async surface; the frozen ELF backend is not a target.