Axle v0.17.0

The task world — a stack per job

Giving a job its own stack

fn compressFrame(index : i32) : string {
    return "jpeg:" + index;
}

fn writeFrame(encoded : string, index : i32) : void {
    println(index + ": " + encoded);
}

fn main() : i32 {
    let workers : Future<string>[4];
    for (i of 0..4) {
        workers[i] = spawn compressFrame(i);
    }

    let i : i32 = 0;
    while (i < 4) {
        let encoded : string = await workers[i];
        writeFrame(encoded, i);
        i = i + 1;
    }
    return 0;
}

A task is a stack, and that is what the shape buys: a job whose frames are large, or whose recursion is deep, runs against its own 1 MiB reserve instead of growing the program’s stack, and a job that throws hands its exception back through the await.

Each task is published to the carrier pool as it is spawned, so a peer carrier can take it while main goes on spawning the next; main then collects the results in order, waiting at each await whose task has not finished. Nothing in compressFrame waits, so a task that shares a carrier with another runs to completion before that one starts: tasks of one carrier take turns only at a wait.

What a spawn costs

Each spawn takes a stack out of the current thread’s pool. The default shape is a 1 MiB reservation with 16 KiB committed at the top: a spawn that returns immediately costs 16 KiB of resident memory, and the address space above it is reserved but untouched, committed by the OS on demand as the task’s frames grow down into it.

Two consequences worth knowing:

  • A spawn is cheap next to a thread, and it is not free. The reservation is address space for as long as the task lives, and the committed part stays resident until the task finishes.
  • The reservation is also the ceiling. A task’s recursion that reaches the bottom of its 1 MiB reserve hits a guard page, and the runtime stops the process with a diagnostic naming the task and the reserve size — a real error you can read, not a write into a neighbour’s memory.

The pool keeps a few idle stacks per thread and resets each to its initial commit before handing it out again, so a tight spawn / await loop does not pay a syscall pair per task, and a shallow task never inherits a deep one’s resident set.

Check whether a task is still running

fn work(n : i32) : i32 {
    let acc : i32 = 0;
    for (i of 0..n) { acc = acc + i; }
    return acc;
}

fn main() : i32 {
    let t : Future<i32> = spawn work(100);

    let v : i32 = await t;                 // 4950
    if (v != 4950) { return 2; }

    if (t.isAlive()) { return 3; }         // consumed → not alive
    return 0;
}

.isAlive() is a non-consuming peek: it does not consume the handle, does not block, and advances nothing — it waits for no carrier to run the task. It answers true while the task is still owed a result, and false once the task has finished and once the handle has been consumed by an await.

Right after a spawn its answer depends on whether a peer carrier has already run the task, so it is a hint, never a synchronisation: the question it answers well is “would an await return immediately?“. A task a peer carrier finished reads as finished as soon as its result has been sent back, even if the thread asking has not run anything since. A while (t.isAlive()) { … } loop that makes no wait of its own keeps its carrier busy, and a task on that same carrier never gets the turn that would end the loop.

Streaming from one task to another

A channel carries values between tasks, and either side may reach the queue first: the one that finds it empty — or full, for a bounded one — waits as a task, so the other stage runs and the wait ends. Two stages on one carrier is a pipeline, not a handoff.

use std::concurrent::UnboundedChannel;

fn produce(ch : UnboundedChannel) : void ! InterruptedException {
    for (i of 0..100) { ch.send(i + 1); }
    ch.send(-1);                          // shutdown sentinel
}

fn consume(ch : UnboundedChannel) : i32 ! InterruptedException {
    let total : i32 = 0;
    let v : i32 = ch.receive();
    while (v != -1) { total = total + v; v = ch.receive(); }
    return total;
}

fn main() : i32 ! InterruptedException, IOException {
    let ch : UnboundedChannel = new UnboundedChannel();
    defer ch.close();

    let producer : Future<void> = spawn produce(ch);
    let consumer : Future<i32> = spawn consume(ch);
    await producer;
    return await consumer;                // 5050
}

Neither await is load-bearing. The consumer is spawned second and its first receive() meets an empty queue; that wait suspends the task and hands the carrier to the producer, so the value arrives and the wait ends. The awaits are there to collect the results, not to make the pipeline work — swap them and the same total comes out.

The -1 is a protocol and not a language feature: a channel carries i32, so a stream that ends has to say so with a value the payload range excludes. That is the one thing a channel cannot carry — which is why a result of any other type comes back through Future<T>.

See also

concurrencytasksspawnfutures