Chapter 15: Async and Concurrency
What you'll learn. async functions and Task[T], await, structured concurrency with await group, async generators and for await, atomic {} blocks, and spawnBlocking — and, just as important, how Loup 1.25 actually runs them: eagerly and in order, not interleaved on an event loop.
Why it matters. A web page is full of work that finishes later: requests, timers, user input. The Language Reference designs Loup's concurrency for that world (Volume 2, §2-1). The syntax and the compile-time checks are implemented today; the scheduler that would interleave tasks is not. If you know where the two differ, you can write code that is correct now and stays correct when the scheduler arrives.
async functions and Task[T]
An async func is declared with the type its call produces, Task[T]. Inside the body you return a plain T, and a caller gets the T out with await:
@mode(web.compiled)
async func fetchUser(id: int): Task[string] {
println("fetching user " + str(id));
return "ann";
}
async func fetchCartTotal(user: string): Task[int] {
println("fetching cart for " + user);
return 2348;
}
async func main(): Task[void] {
let user = await fetchUser(7);
let total = await fetchCartTotal(user);
println(user + " owes " + str(total) + " cents");
}
Listing 15-1: Two async functions awaited in sequence from an async main
fetching user 7
fetching cart for ann
ann owes 2348 cents
The rules (Language Reference §2-1.1):
async func f(…): Task[T]returnsTvalues from its body; the callf(…)has typeTask[T]. The short formasync func f(…): Tmeans the same thing;Task[T]is the usual spelling.await eneedseto be aTask[T]and evaluates to theT.mainmay itself beasync, returningTask[void]orTask[int]; the runner awaits it, and aTask[int]result is the exit status as for an ordinaryint main(ruling MAIN-1).try/catch/finallyand local variables work acrossawaitexactly as in synchronous code. (deferis specified to work the same way, but on the 1.25 toolchain adeferbody does not run; usetry/finallyfor cleanup.)
The same program compiles to Wasm. loup-bootstrap build --target browser writes build/<name>.wasm, and loup-launcher --run-bare prints the same three lines. (The Wasm lane runs async code the same eager way described below.)
await only works in an async context
await is allowed inside an async func, at the top level of an entry script (web.scripted, which has no main), and in the REPL. A synchronous main cannot use it:
@mode(web.compiled)
async func half(n: int): Task[int] {
return n // 2;
}
func main(): void {
let h = await half(9);
println(h);
}
main.lpweb: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 enclosing function async. For main, that also means giving it a Task[...] return type:
@mode(web.compiled)
async func half(n: int): Task[int] {
return n // 2;
}
async func main(): Task[void] {
let h = await half(9);
println(h);
}
4
The opposite mistake — awaiting something that is not a task — is caught too. await 5 gives error[E0001]: can only await 'Task[T]' values, found 'int'.
How async code runs in Loup 1.25
The design (§1-2.1, §2-1.1) is a cooperative, single-threaded runtime: calling an async function starts a task, await suspends the current task, and while it is suspended the event loop runs other tasks. On a browser that event loop would be the page's own.
⚠ Planned. The task scheduler is not wired in. In Loup 1.25 — under
loup runand in Wasm builds alike — calling anasync funcruns its whole body at once, to completion, before the call returns;awaiton the resulting task just hands back the value. Nothing interleaves. There is also no browser event loop yet, because no browser runtime ships (Chapter 1).
The difference is invisible in Listing 15-1, where every task is awaited as soon as it starts. It shows as soon as you start two tasks and await them later. This web.scripted program (it is web.scripted because sleep lives in core.time, and a web.compiled file cannot import the standard library — Chapter 13) starts a slow task and a fast one:
@mode(web.scripted)
from core.time import (sleep, Duration);
async func step(name: string, ms: int): Task[string] {
println(name + ": start");
await sleep(Duration.milliseconds(ms));
println(name + ": done");
return name;
}
let slow = step("slow", 50);
let fast = step("fast", 1);
let a = await slow;
let b = await fast;
println(a + ", " + b);
Listing 15-2: On the 1.25 runtime the two tasks run one after the other
slow: start
slow: done
fast: start
fast: done
slow, fast
Under the specified scheduler slow would suspend at its sleep, fast would start and finish, and the output would read slow: start, fast: start, fast: done, slow: done. On 1.25 the first call runs slow to the end — sleep blocks for the full 50 ms — before fast even starts. Two practical consequences:
- Starting several tasks does not make them concurrent. Write the code the way the design intends (start tasks, then await them), but do not expect it to be faster today.
- Do not depend on ordering in either direction. Code that only works because tasks run eagerly will break when they start to interleave; code that assumes interleaving does not get it yet.
Where a failing task throws
The design says a task that fails stores its exception, and await rethrows it. Because the 1.25 runtime runs the body during the call, the exception comes out of the call instead. This handler never runs:
@mode(web.compiled)
async func loadProfile(id: int): Task[string] {
throw ValueError("no profile " + str(id));
}
async func main(): Task[void] {
let task = loadProfile(42);
println("task created");
try {
let name = await task;
println(name);
} catch (ValueError as e) {
println("caught: " + e.message);
}
}
Uncaught ValueError: no profile 42
at loadProfile (main.lpweb:4:11)
at main (main.lpweb:8:16)
The trace points at line 8, the call, and task created is never printed: the exception left main before the try was entered. Put the call inside the try. That is correct under both the eager runtime and the specified one:
@mode(web.compiled)
async func loadProfile(id: int): Task[string] {
throw ValueError("no profile " + str(id));
}
async func main(): Task[void] {
try {
let name = await loadProfile(42);
println(name);
} catch (ValueError as e) {
println("caught: " + e.message);
}
}
Listing 15-3: Call and await inside the try, so the handler sees the failure on every runtime
caught: no profile 42
Structured concurrency: await group
await group { … } is the structured way to run several child tasks (§2-1.2). Inside the block, target = spawn call() starts a child; the block does not finish until every child has, and then each target holds its child's result. If a child fails, the design cancels the others, waits for them, and rethrows the failure from the block.
@mode(web.compiled)
async func fetchUser(id: int): Task[string] {
println("fetch user " + str(id));
return "ann";
}
async func fetchCount(): Task[int] {
println("fetch count");
return 3;
}
async func main(): Task[void] {
var user = "";
var count = 0;
await group {
user = spawn fetchUser(7);
count = spawn fetchCount();
}
println(user + " has " + str(count) + " items");
}
Listing 15-4: Two children in an await group, each result assigned to a var
fetch user 7
fetch count
ann has 3 items
The targets are assigned when the group finishes, so they must be var bindings declared before the block. The specification's own examples declare them as let user: User; with no initializer; the 1.25 compiler rejects that:
@mode(web.compiled)
async func fetchUser(id: int): Task[string] {
return "ann";
}
async func main(): Task[void] {
let user: string;
await group {
user = spawn fetchUser(7);
}
println(user);
}
main.lpweb:10:9: error[E0001]: cannot assign to immutable binding 'user'
Declare the target with var and an initial value, as in Listing 15-4.
spawn itself only exists inside a group. Writing let x = spawn one(); in an ordinary async body is error[E0001]: 'spawn' can only appear inside an 'await group' block; to start a task outside a group, call the async function (let t = one();) and await it later.
A failing child
With eager execution, a child that throws stops the group at the spawn that started it, so later children are never started. The exception leaves the block as the design specifies:
@mode(web.compiled)
async func fetchUser(id: int): Task[string] {
println("fetch user");
throw ValueError("user " + str(id) + " not found");
}
async func fetchCount(): Task[int] {
println("fetch count");
return 3;
}
async func main(): Task[void] {
var user = "";
var count = 0;
try {
await group {
user = spawn fetchUser(7);
count = spawn fetchCount();
}
println("loaded");
} catch (ValueError as e) {
println("failed: " + e.message);
}
}
Listing 15-5: The first child's exception propagates out of the group
fetch user
failed: user 7 not found
fetch count never prints. Under the specified scheduler both children would start, and the group would cancel fetchCount by raising CancellationError at its next await.
⚠ Planned. Cancellation is not implemented:
CancellationErroris not defined in 1.25 (undefined identifier), and nothing is ever cancelled.TimeoutErroris defined and can be thrown and caught like any exception.
TaskGroup is not there yet
The specification also has a TaskGroup class with collect, race, withTimeout, withCancellation and collectBounded (§2-1.3). Code that uses it does not compile:
@mode(web.compiled)
async func priceOf(sku: string): Task[int] {
return sku.len() * 100;
}
async func main(): Task[void] {
let prices = await TaskGroup.collect(["tea", "mug"], priceOf);
println(prices);
}
main.lpweb:8:24: error[E0001]: undefined identifier `TaskGroup`
For a list of items, await each one in a loop. The result is the same as collect would give, and it is what the 1.25 runtime does anyway:
@mode(web.compiled)
async func priceOf(sku: string): Task[int] {
return sku.len() * 100;
}
async func main(): Task[void] {
var prices: list[int] = [];
for (sku in ["tea", "mug", "teapot"]) {
prices.push(await priceOf(sku));
}
println(prices);
}
Listing 15-6: Collecting results with a loop instead of the planned TaskGroup.collect
[300, 300, 600]
⚠ Planned.
TaskGroupandCancellationToken(§2-1.3) are not implemented.
Async generators and for await
An async gen func produces values over time as an AsyncIterable[T], and for await consumes one (§2-1.4). The generator is lazy: each value is produced when the loop asks for it, and break stops it.
@mode(web.compiled)
async gen func pages(last: int): AsyncIterable[int] {
var page = 1;
while (page <= last) {
println("loading page " + str(page));
yield page;
page = page + 1;
}
}
async func main(): Task[void] {
for await (p in pages(100)) {
println("showing page " + str(p));
if (p == 2) {
break;
}
}
println("stopped");
}
Listing 15-7: An async generator consumed with for await; break stops it after two pages
loading page 1
showing page 1
loading page 2
showing page 2
stopped
for await needs an AsyncIterable. Using it on a list is an error:
@mode(web.compiled)
async func main(): Task[void] {
for await (sku in ["tea", "mug"]) {
println(sku);
}
}
main.lpweb:4:5: error[E_NOT_ASYNC_ITERABLE]: E_NOT_ASYNC_ITERABLE: `for await` needs an `AsyncIterable[T]`, found 'list[string]' (§2-1.4)
A list is already all there; loop over it with a plain for (sku in [...]).
⚠ Planned. The
AsyncIterablecombinatorsmap,filter,take,mergeandzip(§2-1.5) are not implemented (type 'AsyncIterable[int]' has no method 'take'). In a Wasm build,for awaitover an async generator currently traps (wasm trap: wasm 'unreachable' instruction executed); it works underloup run.
Tasks inside templates
A Task[T] is never awaited for you. Putting one straight into a template fence (Chapter 14) is a compile error:
@mode(web.compiled)
async func pageTitle(): Task[string] {
return "Your cart";
}
async func main(): Task[void] {
let page = ```(html):(none)
<h1>{$ pageTitle() $}</h1>
```;
println(page.render({}));
}
main.lpweb:9:9: error[E0001]: cannot insert Task[T] into template: use 'await' first
Await first, then interpolate the value:
@mode(web.compiled)
async func pageTitle(): Task[string] {
return "Your cart";
}
async func main(): Task[void] {
let title = await pageTitle();
let page = ```(html):(none)
<h1>{$ title $}</h1>
```;
println(page.render({}));
}
<h1>Your cart</h1>
atomic {}: no suspension in here
Cooperative scheduling has its own kind of race. Between checking a condition and acting on it, an await lets other tasks run, and one of them may change what you checked — a time-of-check/time-of-use bug. An atomic {} block closes that window by forbidding every suspension point inside it (§2-1.7). It is available in web.scripted, web.compiled, host.scripted and host.dual.
@mode(web.compiled)
class Seats {
pub var left: int = 1;
pub modifier func take(): bool {
var ok = false;
atomic {
if (this.left > 0) {
this.left = this.left - 1;
ok = true;
}
}
return ok;
}
}
async func main(): Task[void] {
let seats = Seats();
safe {
println(seats.take());
println(seats.take());
}
println(seats.left);
}
Listing 15-8: Check-and-update of the last seat inside atomic {}
true
false
0
(take changes a field, so it is a modifier method and is called inside safe { }; Chapter 9 covers both.) An await inside the block is rejected:
@mode(web.compiled)
async func audit(): Task[void] {
println("audit");
}
async func main(): Task[void] {
var left = 1;
atomic {
if (left > 0) {
await audit();
left = left - 1;
}
}
println(left);
}
main.lpweb:11:13: error[E_ATOMIC_AWAIT]: E_ATOMIC_AWAIT: await expression is not allowed inside atomic {} block
Do the check and the update inside the block, and the slow work outside it:
@mode(web.compiled)
async func audit(): Task[void] {
println("audit");
}
async func main(): Task[void] {
var left = 1;
var took = false;
atomic {
if (left > 0) {
left = left - 1;
took = true;
}
}
if (took) {
await audit();
}
println(left);
}
audit
0
atomic {} is not a lock: it does nothing about OS threads. It only guarantees that no other task runs in the middle. With today's eager runtime nothing could interleave anyway; the check still matters because it keeps the code correct once the scheduler arrives.
spawnBlocking
spawnBlocking(body) is a prelude function that runs a synchronous closure off the async executor and gives back a Task[T] (§2-1.9). It is meant for CPU-heavy work that would otherwise stall every task:
@mode(web.compiled)
func sumTo(n: int): int {
var total = 0;
for (i in 0..n) {
total = total + i;
}
return total;
}
async func main(): Task[void] {
let job = spawnBlocking(func(): int => sumTo(1000));
println(await job);
}
499500
In 1.25 the closure runs immediately on the same thread, like every other task.
What is not implemented
The rest of the concurrency material in the Language Reference is design only in Loup 1.25. Each item here is a one-line pointer rather than a listing, because none of it compiles or runs as specified.
⚠ Planned. Actors (
Actor[M],send,ask, supervision; §2-1.6) are not implemented.
⚠ Planned. Web workers and
ScriptWorker(§2-1.10) — isolated Wasm instances with typedWorkerChannel[T]message passing — are not implemented;spawnWorkeris declared in the prelude but there is no worker runtime. Chapter 18 describes the specified worker design; theweb.workersmodule of earlier drafts is not in the Language Reference.
⚠ Planned.
for concurrent(§2-1.11) does not bind its loop variable on the 1.25 interpreter (for concurrent (i in 0..3) { println(i); }givesundefined identifier 'i').
⚠ Planned. Shared memory (§2-2). The
Shareablerules are checked at compile time, but the runtime is missing: ashared classwhose fields are all Shareable compiles, and constructing it withshared Hits()then fails underloup runwithinternal interpreter limitation: unsupported instruction: NewSharedObject. The compile-time check looks like this:
@mode(web.compiled)
shared class Hits {
pub var pages: list[string] = [];
}
func main(): void {
let h = shared Hits();
println(h.pages);
}
main.lpweb:4:5: error[E_SHARED_FIELD_NOT_SHAREABLE]: E_SHARED_FIELD_NOT_SHAREABLE: field `Hits.pages` is not Shareable (`list[T]` is a growable collection and is never Shareable (§2-2.2) — use `SharedArray[T]` or `SharedQueue[T]`) — every field of a `shared class` must satisfy `core.Shareable` (§2-2.2)
A growable list cannot be shared between threads; the message names the shared alternatives, SharedArray[T] and SharedQueue[T], which are themselves not usable from the 1.25 interpreter.
Thread, ThreadPool, Mutex and RwLock are specified for host.dual and host.native only; the web modes are single-threaded by design (§3-1.2). The 1.25 interpreter does not reject Thread.spawn in a web.compiled file, but a browser build has no threads to give it, so do not use it in web code.
Summary
async func f(): Task[T]returns aTfrom its body; calling it gives aTask[T];awaitgets the value and is allowed only in async functions,web.scriptedtop-level code and the REPL (E_AWAIT_NOT_ASYNC).maincan beasync.- In Loup 1.25 async code runs eagerly: a call runs the task to completion before returning, under
loup runand in Wasm builds. Tasks do not interleave, and a failing task throws at the call, so keep the call inside thetry. await group { x = spawn f(); }runs child tasks withvartargets;spawnis only valid inside a group; a failing child's exception leaves the group. Cancellation,CancellationErrorandTaskGroupare planned.async gen funcandfor awaitwork underloup run(E_NOT_ASYNC_ITERABLEfor non-async sources); combinators are planned andfor awaittraps in Wasm builds today.- Await a task before putting its value in a template.
atomic {}forbidsawaitinside it (E_ATOMIC_AWAIT) and is the tool for check-then-act code.spawnBlockingexists and runs inline today. Actors, workers,for concurrentand shared-memory runtime support are planned; the web modes have no threads.