The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  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 ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

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/await for 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 an async function. async does run on the interpreter (loup run) and in WebAssembly builds of host.dual programs, 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, then loup-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 before called, because calling an async function runs its whole body, including everything it awaits, before the call returns. The interpreter and WebAssembly have no scheduler yet, so nothing interleaves, and an await group runs 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

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

This code does not compile
@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): async in native binaries, the work-stealing scheduler, and its configuration through runtime.configure(RuntimeConfig(executor = ExecutorConfig(workerThreads = …))).
  • TaskGroup (§2-1.3): TaskGroup.collect, race, withTimeout, withCancellation and collectBounded. The name is undefined identifier 'TaskGroup'. Use await group { … } for a fixed set of tasks.
  • Unstructured tasks and cancellation: Task is a type, not a value with methods, so Task.spawn, Task.all and t.cancel() do not exist. CancellationError is not a built-in exception (undefined identifier 'CancellationError').
  • Combinators: there is no concurrency.combinators module (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 an async func, but has nothing to check against until there is a multi-threaded executor.

A sketch of the planned structured form, for reference only:

Fragment — not a complete program
@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; await is valid only in async functions (E_AWAIT_NOT_ASYNC), and main may be async.
  • await group { x = spawn f(); } runs child tasks and assigns their results to var targets; async gen func with AsyncIterable[T] and for await stream values; exceptions propagate through await.
  • Native builds refuse async (E-BUILD-NATIVE). Under loup run and in WebAssembly, async code runs eagerly and sequentially.
  • spawnBlocking is a prelude builtin returning a Task.
  • TaskGroup, Task.spawn, cancellation, combinators, actors and the multi-threaded executor are ⚠ planned.
  • Chapter 22 previews the planned desktop UI library.