velt/task
import { AbortError, TimeoutError, timeout, taskScope } from "velt/task". Cancellation with
TypeScript's AbortController / AbortSignal (global, as in Node; this module exports them
too), timeouts, task scopes (structured concurrency) and task placement. fetch takes a signal.
Cancellation is cooperative. abort() marks the signal and wakes the tasks waiting for it;
code that takes a signal checks it with throwIfAborted(), or races its work against
whenAborted(). Nothing is dropped behind the program's back, so finally blocks and using
disposal run as usual.
new AbortController():signal: AbortSignalreturns its signal (the same object every time, like JS). Pass it anywhere,spawned tasks included.abort(reason = "This operation was aborted")aborts it. Aborting twice does nothing.
AbortSignal:aborted: boolandreason: string(""while not aborted).throwIfAborted()throws if aborted: aTimeoutErrorif anAbortSignal.timeoutaborted it, otherwise anAbortErrorwith the reason as its message.TimeoutErrorextendsAbortError, sothrows AbortErrorcovers both.whenAborted(): Promise<void>resolves once aborted. It never resolves if the signal is never aborted. Race it directly against work,Promise.race([work(), signal.whenAborted()]): when the work wins, the losing wait is dropped. An async function wrapped aroundwhenAborted()would instead keep waiting, and keep the program running, until the signal is aborted.AbortSignal.timeout(ms)makes a signal that aborts by itself aftermsmilliseconds (reason"timed out after <ms> ms"). Its timer goes away with the signal: one made per request and dropped when the request is done costs nothing afterwards, however longms.AbortSignal.any(signals)makes a signal that aborts as soon as any ofsignalsdoes, with its reason. It keepssignalsalive until then.
timeout(p, ms): Promise<T> throws E | TimeoutErrorreturnsp's result, unlesspdoesn't settle withinmsmilliseconds; then it throwsTimeoutError(withms). Whenpwins, the timer is cancelled. After a timeout,pis abandoned like aPromise.raceloser: a runtime wait (signal.whenAborted(), a timer) is dropped, and a running async call keeps running, like JS, with its rejection handled. To stop it, give it a signal.- Signals can't be made from numbers: only
new AbortController(),AbortSignal.timeoutandAbortSignal.anycreate them.
import { AbortController, AbortSignal, AbortError, TimeoutError, timeout } from "velt/task";
async function download(name: string, signal: AbortSignal): Promise<string> throws AbortError {
for (let chunk = 0; chunk < 10; chunk++) {
signal.throwIfAborted(); // stop between chunks
await sleep(5);
}
return `${name}: 10 chunks`;
}
const ac = new AbortController();
const job = download("big.iso", ac.signal);
await sleep(12);
ac.abort("user pressed cancel");
try {
console.log(await job);
} catch (e) {
console.log(e.message); // user pressed cancel
}
try {
console.log(await timeout(download("small.txt", AbortSignal.timeout(1000)), 20));
} catch (e) {
console.log(e.message); // timed out after 20 ms
}Differences from JavaScript:
- Errors are typed, so
throwIfAborted()throws anAbortError(orTimeoutError) carrying the reason; JS throws the reason value itself. reasonis astring.- There is no
addEventListener("abort", …): the runtime keeps no callbacks, because they would breakvelt devhot reload. Useawait signal.whenAborted(), for example in aPromise.race.
Task scopes
taskScope<T, E>(body: (scope: TaskScope<E>) => Promise<T> throws E, options?) runs body
with a TaskScope and settles only once the body and every task it started with scope.spawn
have finished, so no child outlives the scope. E is the error type of the whole scope: the
body's, and every child's.
scope.spawn(p: Promise<T, E>): Promise<T, E>addspto the scope as a child task and returns its handle; the scope waits for it.pis a promise value, so a call such asscope.spawn(work(signal))starts on the scope's own task, like any stored promise, and the child task waits for it: CPU-bound work doesn't move to another core (usespawnfor that). A promise that can't fail, or fails with part ofE, is accepted too (it converts toPromise<T, E>).- The scope returns the body's result, or fails with the first error in time: the body's
or any child's, even a child nobody awaited or whose error the body caught. Errors that
come later, such as the
AbortErrors that the cancellation causes in siblings, don't replace it. scope.signalis aborted when the body or a child fails (unlessoptions.cancelOnErrorisfalse), onscope.cancel(reason), and when the runtime drops the scope's own task. Pass it to the children so they stop cooperatively.scope.spawnafter the scope ended is an error (it panics).
import { AbortSignal, AbortError, TaskScope, taskScope } from "velt/task";
async function fetchPart(n: i64, signal: AbortSignal): Promise<i64> throws AbortError {
for (let i = 0; i < n; i++) {
signal.throwIfAborted();
await sleep(2);
}
return n * 10;
}
const total = await taskScope(
async (scope: TaskScope<AbortError>): Promise<i64> throws AbortError => {
const signal = scope.signal;
const a = scope.spawn(fetchPart(3, signal));
const b = scope.spawn(fetchPart(5, signal));
return (await a) + (await b);
},
);
console.log(total); // 80Task placement
workerCount() and currentWorker(), with spawn(p, { worker }) (a built-in), place tasks on
workers: the threads that run tasks (VELT_THREADS, else the number of cores; 1 on
WebAssembly), plus the spare ones started while tasks compute without pausing (below). A sharded program (one task per worker owning a slice of the data,
Seastar or Orleans style) pins each shard to a worker and its callers next to it, so a call
never wakes another thread.
workerCount(): i64: how many workers there are.currentWorker(): i64: the worker running this task (0toworkerCount() - 1);0outside a task.spawn(p, { worker: i }): startspon workeri, where it stays (SpawnOptionsis{ worker?: i64 };{}pins nothing).pmust be a call or a task body: a promise that already started stays on its task (a started promise stays on its task; pass the call). The options must be an object literal (a variable holding them is an error);workermay be given once. An index out of range is a panic:spawn: worker 7 is out of range (the runtime has 4 workers).- Tasks run on the runtime's own scheduler: every worker runs its own event loop, a task stays on
the worker it started on (an unpinned one starts on the spawning worker), and another worker takes
on part of a worker's tasks only while that one stays busy and the help raises how much gets done.
A pinned task never moves. A task that computes without pausing for more than a few milliseconds
doesn't hold up the rest of its worker: its timers, sockets and woken tasks go to another worker
meanwhile, except tasks pinned to that worker. When no other worker can take them (one worker, or
every other one computing too), a spare worker starts and does, up to four in all;
workerCount()counts each from then on. Past those, they wait for a worker to finish its computation.VELT_RESCUE=0turns this rescue off (for a worker that computes, nothing serves its timers and sockets, no task moves away from it and no spare starts), for deterministic measurement only: together withVELT_THREADS=1a program's instruction count does not depend on how its threads are scheduled, but one task that computes holds up everything on its worker. With more workers, tasks still move to sleeping ones (below, and when a worker stays busy). Tasks spawned together that compute (spawnin a loop, thenawait Promise.all(...)) start on idle workers well under a millisecond apart: once the first has computed for 50 µs, the others, not started yet, go to sleeping workers, one each (on Windows, whose timers are coarser, within about a millisecond). After many spawned tasks that each finished at once, the first such group may take up to a millisecond.VELT_SCHEDULER=stealingorper-coreselects one of tokio's schedulers instead (for comparison;VELT_SCHEDULER); withstealing,{ worker }is checked but the task may run on any worker, andcurrentWorker()can change at anyawait. - A pinned task can't borrow an idle core: CPU-bound code in it delays everything else on its
worker.
scope.spawntakes no placement yet.
import { channel, Channel } from "velt/sync";
import { workerCount, currentWorker } from "velt/task";
type Call = { key: string; reply: (n: i64) => void };
async function shard(inbox: Channel<Call>): Promise<void> {
const counts = new Map<string, i64>();
while (true) {
const c = await inbox.receive();
if (c == null) {
return;
}
const n = (counts.get(c.key) ?? 0) + 1;
counts.set(c.key, n);
c.reply(n);
}
}
async function caller(inbox: Channel<Call>): Promise<i64> {
let n = 0;
for (let i = 0; i < 1000; i++) {
const { promise, resolve } = Promise.withResolvers<i64>();
inbox.trySend({ key: "a", reply: resolve });
n = await promise;
}
return n;
}
const inboxes: Channel<Call>[] = [];
for (let i = 0; i < workerCount(); i++) {
inboxes.push(channel<Call>());
spawn(shard(inboxes[i]), { worker: i });
}
// Next to shard 0: no call wakes another thread.
const n = await spawn(caller(inboxes[0]), { worker: 0 });
console.log(`${n} calls, main on worker ${currentWorker()}`);
for (const c of inboxes) {
c.close();
}