Chapter 21: Async on native
⚠ Planned for native binaries — not in Loup 1.25. The Language Reference (§2-1.1 to §2-1.9) specifies
async/awaitfor native programs, running on a multi-threaded, work-stealing executor. The 1.25.1 native code generator has no task scheduler and refuses any program that contains anasyncfunction.asyncdoes run on the interpreter (loup run) and in WebAssembly builds ofhost.dualprograms, and the listings in this chapter were checked that way. The parts marked ⚠ below are not implemented on any path.
This chapter shows the async syntax as it works today, what a native build says when it meets it, and what the specification plans.
What a native build does
A program with an async function builds for every target except native. The native build stops before generating code and names each function it cannot lower:
loup build --target native -o app
error[E-BUILD-NATIVE]: unsupported in managed native (libc foundation): async function `fetchSize` (native builds have no task scheduler: `async`/`await` run on the interpreters only); async function `main` (native builds have no task scheduler: `async`/`await` run on the interpreters only); async function `total` (native builds have no task scheduler: `async`/`await` run on the interpreters only); …
So, for a program that needs async today:
- write it in
host.dual(.loup), which is the mode meant for code delivered more than one way (chapter 2); - run it with
loup run, or build it as WebAssembly (loup-bootstrap build, thenloup-launcher --run-bare build/<name>.wasm) or as a packaged app (loup-bootstrap build --target desktop, chapter 24); - for parallel CPU work in a native binary, use threads (chapter 20) instead.
async functions and await
An async func returns a Task[T]. await waits for a task and gives its value. await is allowed only inside an async function, and main may itself be async:
@mode(host.dual)
async func fetchSize(name: string): Task[int] {
println("fetching " + name);
return name.length() * 100;
}
async func total(): Task[int] {
var page = 0;
var script = 0;
await group {
page = spawn fetchSize("index.html");
script = spawn fetchSize("app.js");
}
return page + script;
}
async func main(): void {
println("start");
let t = total();
println("called");
println(await t);
}
Listing 21-1: async functions, await, and an await group
start
fetching index.html
fetching app.js
called
1600
await group { … } is the structured way to run several tasks: each spawn starts a child task, the block waits for all of them, and each x = spawn f() assigns the child's result to x when the group ends (§2-1.2). If a child fails, the group fails with its exception.
The listing uses + rather than i"…" interpolation because interpolated strings crash a WebAssembly build of 1.25.1 at run time; under loup run either works.
Known gap: async runs eagerly. The output above shows it:
fetching …is printed beforecalled, because calling anasyncfunction runs its whole body, including everything it awaits, before the call returns. The interpreter and WebAssembly have no scheduler yet, so nothing interleaves, and anawait groupruns its children one after another in order. Results and exceptions are as specified; concurrency is not. Do not write code that depends on two tasks making progress at the same time.
Mistake: await outside an async function
@mode(host.dual)
async func fetchSize(name: string): Task[int] {
return name.length() * 100;
}
func main(): void {
println(await fetchSize("index.html"));
}
main.loup:8:13: error[E_AWAIT_NOT_ASYNC]: E_AWAIT_NOT_ASYNC: `await` is only valid inside an `async func` body, an entry script's top level, or the REPL (§2-1.1)
Make the caller async too, here async func main(): void. Async-ness spreads up the call chain to main this way, which is why the specification keeps it out of code that does not need it.
Mistake: an immutable await group target
@mode(host.dual)
async func fetchSize(name: string): Task[int] {
return name.length() * 100;
}
async func main(): void {
let page: int = 0;
await group {
page = spawn fetchSize("index.html");
}
println(page);
}
main.loup:10:9: error[E0001]: cannot assign to immutable binding 'page'
The group assigns the result when it ends, so the target must be a var, as in Listing 21-1.
Errors through await
An exception thrown inside an async function is rethrown by the await that waits for it, so ordinary try/catch works across tasks:
class BadInput : Exception {
new(message: string) { super(message); }
}
async func parsePort(text: string): Task[int] {
if (text == "") {
throw BadInput("port is empty");
}
return text.length();
}
async func main(): void {
try {
let port = await parsePort("");
println(port);
} catch (BadInput as e) {
println("failed: " + e.message);
}
}
Listing 21-2: Catching an async function's exception at the await
failed: port is empty
Async generators and for await
An async gen func produces values one at a time and may await between them; for await consumes them. Its return type is AsyncIterable[T]:
async gen func pages(count: int): AsyncIterable[int] {
var n = 1;
while (n <= count) {
yield n;
n = n + 1;
}
}
async func main(): void {
var total = 0;
for await (p in pages(3)) {
println("page " + str(p));
total = total + p;
}
println(total);
}
Listing 21-3: An async generator consumed with for await
page 1
page 2
page 3
6
Earlier editions spelled the return type AsyncIterator[T], which is undefined type 'AsyncIterator'; the type is AsyncIterable[T] (§2-1.4). A WebAssembly build crashes on for await in 1.25.1, so code that uses it runs only under loup run for now.
Blocking work: spawnBlocking
spawnBlocking(body) is a prelude function that runs a synchronous closure on a pool reserved for blocking work and returns a Task for its result (§2-1.9.3). Under the interpreter it runs the closure at once, like everything else in this chapter:
func checksum(data: string): int {
var h = 7;
for (i in 0..data.length()) {
h = (h * 31 + data.length()) % 1000003;
}
return h;
}
async func main(): void {
let sum = await spawnBlocking(func(): int { return checksum("payload"); });
println(sum);
}
Listing 21-4: Offloading a synchronous computation with spawnBlocking
311715
Older material imports it from sync.thread; that module does not exist, and the prelude function needs no import.
What is planned
These parts of the specification have no implementation on any delivery path in 1.25.1:
- The native executor (§2-1.9):
asyncin native binaries, the work-stealing scheduler, and its configuration throughruntime.configure(RuntimeConfig(executor = ExecutorConfig(workerThreads = …))). TaskGroup(§2-1.3):TaskGroup.collect,race,withTimeout,withCancellationandcollectBounded. The name isundefined identifier 'TaskGroup'. Useawait group { … }for a fixed set of tasks.- Unstructured tasks and cancellation:
Taskis a type, not a value with methods, soTask.spawn,Task.allandt.cancel()do not exist.CancellationErroris not a built-in exception (undefined identifier 'CancellationError'). - Combinators: there is no
concurrency.combinatorsmodule (all,race,timeout,retry). - Actors (
Actor[M], §2-1.6) and task-local storage (TaskLocal[T], §2-1.9.8). @concurrent(§2-1.9.5) is accepted on anasync func, but has nothing to check against until there is a multi-threaded executor.
A sketch of the planned structured form, for reference only:
@mode(host.native)
async func checkAll(urls: list[string]): Task[list[int]] {
return await TaskGroup.withTimeout(5000, async func(): Task[list[int]] {
return await TaskGroup.collect(urls, async func(url: string): Task[int] {
return await probe(url);
});
});
}
Async or threads?
Until the native executor lands, the choice in a native program is simple: use threads (chapter 20). In host.dual code that also has to run in WebAssembly, async is the portable way to express waiting, and its results and errors behave as specified; just do not expect two tasks to run at the same time.
Summary
async func f(): Task[T]declares a task-returning function;awaitis valid only inasyncfunctions (E_AWAIT_NOT_ASYNC), andmainmay beasync.await group { x = spawn f(); }runs child tasks and assigns their results tovartargets;async gen funcwithAsyncIterable[T]andfor awaitstream values; exceptions propagate throughawait.- Native builds refuse
async(E-BUILD-NATIVE). Underloup runand in WebAssembly, async code runs eagerly and sequentially. spawnBlockingis a prelude builtin returning aTask.TaskGroup,Task.spawn, cancellation, combinators, actors and the multi-threaded executor are ⚠ planned.- Chapter 22 previews the planned desktop UI library.