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
spawnis 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
- Tasks and async — the model behind the three backings, and how to pick one
- The async world — the coroutine backing, for waits that are readiness
- Concurrency primitives — locks, channels, atomics
- Async I/O — sockets, DNS and HTTP