The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

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] returns T values from its body; the call f(…) has type Task[T]. The short form async func f(…): T means the same thing; Task[T] is the usual spelling.
  • await e needs e to be a Task[T] and evaluates to the T.
  • main may itself be async, returning Task[void] or Task[int]; the runner awaits it, and a Task[int] result is the exit status as for an ordinary int main (ruling MAIN-1).
  • try/catch/finally and local variables work across await exactly as in synchronous code. (defer is specified to work the same way, but on the 1.25 toolchain a defer body does not run; use try/finally for 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:

This code does not compile
@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 run and in Wasm builds alike — calling an async func runs its whole body at once, to completion, before the call returns; await on 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:

This code does not do what it should
@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:

This code throws at run time
@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:

This code does not compile
@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: CancellationError is not defined in 1.25 (undefined identifier), and nothing is ever cancelled. TimeoutError is 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:

This code 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. TaskGroup and CancellationToken (§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:

This code does not compile
@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 AsyncIterable combinators map, filter, take, merge and zip (§2-1.5) are not implemented (type 'AsyncIterable[int]' has no method 'take'). In a Wasm build, for await over an async generator currently traps (wasm trap: wasm 'unreachable' instruction executed); it works under loup 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:

This code does not compile
@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:

This code does not compile
@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 typed WorkerChannel[T] message passing — are not implemented; spawnWorker is declared in the prelude but there is no worker runtime. Chapter 18 describes the specified worker design; the web.workers module 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); } gives undefined identifier 'i').

⚠ Planned. Shared memory (§2-2). The Shareable rules are checked at compile time, but the runtime is missing: a shared class whose fields are all Shareable compiles, and constructing it with shared Hits() then fails under loup run with internal interpreter limitation: unsupported instruction: NewSharedObject. The compile-time check looks like this:

This code does not compile
@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 a T from its body; calling it gives a Task[T]; await gets the value and is allowed only in async functions, web.scripted top-level code and the REPL (E_AWAIT_NOT_ASYNC). main can be async.
  • In Loup 1.25 async code runs eagerly: a call runs the task to completion before returning, under loup run and in Wasm builds. Tasks do not interleave, and a failing task throws at the call, so keep the call inside the try.
  • await group { x = spawn f(); } runs child tasks with var targets; spawn is only valid inside a group; a failing child's exception leaves the group. Cancellation, CancellationError and TaskGroup are planned.
  • async gen func and for await work under loup run (E_NOT_ASYNC_ITERABLE for non-async sources); combinators are planned and for await traps in Wasm builds today.
  • Await a task before putting its value in a template.
  • atomic {} forbids await inside it (E_ATOMIC_AWAIT) and is the tool for check-then-act code.
  • spawnBlocking exists and runs inline today. Actors, workers, for concurrent and shared-memory runtime support are planned; the web modes have no threads.