Async and concurrency
Velt's async/await behaves like JavaScript's: promises start when you create them, and
output order matches Node. Underneath, async functions compile to state machines running on a
multi-threaded tokio runtime, so a directly awaited call costs no allocation, and spawn puts
work on other cores. This guide covers the patterns; the exact rules are in
the Reference.
Promises start at once
async function fetchPrice(item: string): Promise<f64> {
await sleep(item.length as i64 * 5); // pretend to do I/O
return item.length as f64 * 1.5;
}
const tea = fetchPrice("tea"); // starts now
const coffee = fetchPrice("coffee"); // runs concurrently with tea
console.log(await tea, await coffee); // 4.5 9, after ~30 ms, not ~45
const all = await Promise.all(["tea", "milk"].map((i) => fetchPrice(i)));
console.log(all); // [ 4.5, 6 ]await f()runsfin place: no task, no allocation. Use it whenever you need the result right away.const p = f()startsfnow; it runs until its firstawait, then concurrently with you. It costs one small allocation.- Inside a function,
f();on its own line is a compile error, "floating promise": eitherawaitit orspawnit. In JavaScript a forgottenawaitsilently drops errors. - A promise you never await still runs to completion, and the program waits for it before exiting, like Node.
Started promises run on their creator's task, one at a time, like JavaScript's single thread. That is why they need no locks.
Top-level await
The examples on this page are programs: their statements run from the top, and await works
there as in a Node module. Among the top-level statements of the file a program starts from,
a call on its own line starts the promise as Node does, and the program waits for it before
exiting:
async function render(page: string): Promise<void> {
await sleep(5);
console.log("rendered", page);
}
render("home"); // starts now, as in Node
console.log("started");
await render("about"); // top-level await
// started
// rendered home
// rendered aboutA rejection nobody handles ends the program with exit code 1. Top-level await is for the
file a program starts from; an imported module's statements run before the scheduler starts
(Scripts).
Timeouts with Promise.race
Promise.race settles with the first promise to settle. Like in JavaScript, every promise in
the race counts as handled: the losers keep running to completion, and a loser that rejects
after the race is over is dropped, not reported as an uncaught error. So a timeout can simply
throw:
class OutOfStock extends Error {}
class TooSlow extends Error {}
async function fetchPrice(item: string): Promise<f64> throws OutOfStock {
await sleep(item.length as i64 * 5);
if (item == "caviar") {
throw new OutOfStock(`no ${item} today`);
}
return item.length as f64 * 1.5;
}
async function priceWithin(item: string, ms: i64): Promise<f64> throws OutOfStock | TooSlow {
const price = async (): Promise<f64> throws OutOfStock | TooSlow => await fetchPrice(item);
const timeout = async (): Promise<f64> throws OutOfStock | TooSlow => {
await sleep(ms);
throw new TooSlow(`no price within ${ms} ms`);
};
return await Promise.race([price(), timeout()]);
}
console.log(await priceWithin("water", 100)); // 7.5, and the timer is dropped
try {
await priceWithin("a very long name", 10);
} catch (e) {
console.log(e.message); // no price within 10 ms
}Every promise in one Promise.race (or all, allSettled, any) must have the same type,
including the error type: Promise<f64, OutOfStock | TooSlow> here. That is why both closures
declare the same throws clause; a throws clause may allow more than the body throws.
Errors in concurrent work
Errors are typed in async code too: a promise's type carries what it can reject with, and
await rethrows it.
class OutOfStock extends Error {}
async function fetchPrice(item: string): Promise<f64> throws OutOfStock {
await sleep(5);
if (item == "caviar") {
throw new OutOfStock(`no ${item} today`);
}
return 4.5;
}
try {
await Promise.all([fetchPrice("tea"), fetchPrice("caviar")]);
} catch (e) { // e: OutOfStock
console.log("all failed:", e.message);
}
const results = await Promise.allSettled([fetchPrice("tea"), fetchPrice("caviar")]);
for (const r of results) {
switch (r.status) {
case "fulfilled":
console.log("ok", r.value); // ok 4.5
break;
case "rejected":
console.log("failed:", r.reason.message); // failed: no caviar today
break;
}
}Promise.all rejects as soon as one promise rejects, like JavaScript. The other promises keep
running to completion; their results and any later rejections are dropped.
Promise.allSettled reports each result as a discriminated union. Promise.any gives the first
success, or an AggregateError.
Task scopes and timeouts
taskScope from velt/task is structured concurrency: it settles only once
its body and every task it started with scope.spawn have finished, and fails with the first
error, which aborts scope.signal so the siblings stop. timeout(p, ms) rejects with a
TimeoutError when p is too slow.
import { AbortError, AbortSignal, TaskScope, taskScope, timeout, TimeoutError } from "velt/task";
async function fetchPart(n: i64, signal: AbortSignal): Promise<i64> throws AbortError {
for (let i = 0; i < n; i++) {
signal.throwIfAborted(); // stops once a sibling failed
await sleep(2);
}
return n * 10;
}
const total = await taskScope(async (scope: TaskScope<AbortError>): Promise<i64> throws AbortError => {
const a = scope.spawn(fetchPart(3, scope.signal));
const b = scope.spawn(fetchPart(5, scope.signal));
return (await a) + (await b);
});
console.log(total); // 80
try {
await timeout(sleep(1000), 20);
} catch (e) {
console.log(e instanceof TimeoutError); // true
}Cancellation is cooperative, with AbortController and AbortSignal as in TypeScript: code
that takes a signal checks it, and a started promise is never cancelled behind the program's
back, so finally blocks and using disposal run as usual
(Cancellation).
Using every core with spawn
spawn(f()) or spawn(async () => …) runs work as its own task on any core and returns a
promise for the result. A spawned task runs even if nobody awaits it.
function sumOfSquares(from: i64, to: i64): i64 {
let total = 0;
for (let i = from; i < to; i++) {
total += i * i % 7;
}
return total;
}
const chunks: Promise<i64>[] = [];
for (let c = 0; c < 4; c++) {
chunks.push(spawn(async () => sumOfSquares(c * 1000000, (c + 1) * 1000000)));
}
const parts = await Promise.all(chunks); // four cores at once
console.log(parts.reduce((a, b) => a + b, 0)); // 7999999Sharing state between tasks
Spawned tasks run in parallel, so the compiler doesn't let them modify captured variables
("this async closure modifies captured n, so it must stay on the task that created it", with
the spawn it reaches), and an object a task captures is copied for it (a structured clone)
when the rest of the program still uses it. Data races are compile errors. An async closure that
never leaves its task (it is only called, stored or returned, never spawned or sent) may change
what it captured, as in JavaScript. Module state is the exception that works as in Node: a
module-level let (also one among a program's top-level statements that a closure assigns) is
one per process, and every task may update it without losing updates (let n = 0; and
spawn(async () => { n++; }) a hundred times gives 100;
module state). Share other state between tasks
explicitly:
shared(x)creates an atomically reference-counted value: tasks that capture it refer to the same value. On 64-bit integers,add,getandsetare atomic, andadd(n)returns the new value, soif (pending.add(-1) === 0)tells the task that took the count to zero.shared(new Mutex<T>(x))guards any value;m.with((v) => …)locks it for the callback, which gets the value itself and may return a result (a copy of anything that is part of the value).sharedtakesxitself: aftershared(new Mutex(o)), useoonly through thesharedvalue (or passo.clone()to keep your own).
const seen = shared(new Mutex<string[]>([]));
const done = shared(0);
const workers: Promise<void>[] = [];
for (let w = 0; w < 3; w++) {
workers.push(spawn(async () => {
seen.with((v) => {
v.push(`worker ${w}`);
});
done.add(1);
}));
}
await Promise.all(workers);
console.log(done.get(), seen.with((v) => v.length)); // 3 3A with callback is synchronous: keep it short and don't await inside it. What it hands
out — its result, or a part of the value it stores into a variable or array it captured — is a
copy. A promise started from the value runs after the lock is released, so it gets a copy
too, and one that would change the value is a compile error: take a copy out, await, and put
the result back with another with.
For a map that many tasks read and write at once, ConcurrentMap
from velt/collections takes u64 or string keys and needs no Mutex: readers never slow
each other, and a writer latches one node, not the whole map.
import { ConcurrentMap } from "velt/collections";
const map: ConcurrentMap<string, u64> = ConcurrentMap.create(1000);
const hits = shared(map);
hits.set("/home", 0);
const tasks: Promise<void>[] = [];
for (let i = 0; i < 4; i++) {
tasks.push(spawn(async () => {
hits.add("/home", 1); // atomic
}));
}
await Promise.all(tasks);
console.log(hits.get("/home")); // 4Streams with for await
Work that arrives over time — jobs on a channel, lines of a file,
WebSocket messages, ticks — is consumed with for await, as in TypeScript. A channel passes
values between tasks and ends the loop once it is closed and drained:
import { channel, Channel, ChannelClosed } from "velt/sync";
async function worker(id: i64, jobs: Channel<string>, results: Channel<string>): Promise<void> throws ChannelClosed {
for await (const job of jobs) {
await results.send(`worker ${id} did ${job}`);
}
}
const jobs = channel<string>(8);
const results = channel<string>();
const workers = [spawn(worker(1, jobs, results)), spawn(worker(2, jobs, results))];
for (const job of ["resize", "upload", "notify"]) {
await jobs.send(job);
}
jobs.close(); // the workers' loops end once the queue is drained
await Promise.all(workers);
results.close();
let n = 0;
for await (const line of results) {
n++;
}
console.log(n); // 3Leaving a loop early (break) does not close the channel: the values still queued stay there
for other receivers. Your own sources are async function* generators
(Async generators); the
async iteration reference lists the std ones.
Timers
sleep(ms) pauses the current async function. velt/time has
setImmediate and Ticker, a drift-free interval you iterate with for await (const n of ticker) or pull with await ticker.tick(). setTimeout and setInterval are globals, as in
TypeScript.
Performance
From bench/async (bench/RESULTS.md; release builds on an
Intel i9-12900HK laptop under Windows 11, best of 5, wall-clock including process start, while
other builds were running, so ±15–20%):
| Benchmark | Velt | Rust tokio (multi-thread) | Node |
|---|---|---|---|
| 10M sequential awaits | 55 ms | 52 ms | 508 ms |
| 500k awaits 21 frames deep | 379 ms | 433 ms | 743 ms |
| 1M spawned tasks | 614 ms | 703 ms | 1618 ms |
| 100k concurrent timers | 70 ms | 64 ms | 229 ms |
An idle Velt process uses about 2 MB more memory than the Rust one and about 50 MB less than Node.