Threads & Pools
Thread declaration
@pool(1, 4) // min 1, max 4 worker threads
thread loader {
fn load(path: string) -> string or io.IoError {
return fs.read_file(path);
}
}A thread block defines a worker. Its functions run on the worker’s pool threads, not on the calling thread.
Pool sizing
@pool is an attribute written before the thread declaration:
@pool(4) // fixed pool of 4 threads
thread image { ... }
@pool(2, 8) // min 2, max 8 threads (scales with load)
thread network { ... }
thread work { ... } // default: @pool(1)- Pool threads start lazily: the first task submitted to the thread spawns
minof them - When every pool thread is busy and a task is queued, the pool grows toward
max - Threads idle beyond a timeout (about 30 seconds) shut down, down to
min - Each pool thread runs one task at a time; queued tasks wait for a free thread
- With more than one worker, results arrive in the order the tasks finish, not the order they were queued:
total = total + partinon_doneadds in a different order each run (and a float sum then differs in its last digits). To combine results deterministically keep each one by its index and combine in order once all are in, as fan-out parallel processing does;@pool(1)delivers in queue order
The VM (tape run, tape test) runs pools as natively. Where it runs tasks cooperatively (a VM a game embeds, tape run --hot), a thread’s tasks run one at a time whatever its @pool says, with one copy of its state (a valid schedule of any pool, since calls are never pinned to a pool thread).
Thread-local state
@pool(2, 8)
thread parser {
var buffer: [4096]u8; // each pool thread has its own buffer
fn parse(data: []u8) -> Document {
// buffer is local to this pool thread: no sharing
}
}var in a thread block is per-thread persistent state: it is initialized when a pool thread starts and survives between calls on that thread. Each pool thread has its own copy. When a pool thread shuts down, its state is dropped like a scope ending (reverse declaration order).
Initializers follow the rules of module-level ones: they must be infallible. Handle a fallible one with or { default }:
thread image {
var encoder: PngEncoder = png.new_encoder() or { png.default_encoder() };
}Sequential calls may run on different pool threads. If a sequence of calls depends on accumulated state, use @pool(1), pass the state explicitly, or bundle the steps into one function.
If a pool thread panics (an out-of-bounds index, a failed unwrap), its current task fails with an error to the caller, its state is lost, and a replacement thread starts with fresh initializers.
Nested threads
A thread can contain nested threads, and it may call only its own nested threads — never its parent, never a sibling. The call graph is a tree, so deadlocks are impossible:
thread pipeline {
thread parser { fn parse(input: []u8) -> Document { ... } }
thread validator { fn check(doc: Document) -> Report { ... } }
fn process(input: []u8) -> Report {
let parsed = parser.parse(input); // child: blocks until done
return validator.check(parsed); // child
}
}
thread other {
fn run() {
pipeline.process(data); // compile error: a sibling, not a child
}
}| Caller | Can call |
|---|---|
| UI / components | Top-level threads only (async) |
| Top-level thread | Its own nested threads (blocking) |
| Nested thread | Its own nested threads deeper (blocking) |
| Any thread | Never upward, never sideways |
A nested thread’s @pool(min, max) matters for @fan (see progress): the pieces of a fan run on the nested thread’s pool, as many at once as it has workers.
To reuse a nested thread in several parents, write it once in a @tape fn generator that @emits it, and call the generator inside each parent; each parent gets its own pool:
// counter_gen.tape
pub @tape fn gen_counter() {
@emit thread counter {
var n: i64 = 0;
fn next(step: i64) -> i64 { n = n + step; return n; }
}
}
// app.tape
import counter_gen from "./counter_gen.tape";
thread a {
@tape { counter_gen.gen_counter(); } // a's own counter
fn go() -> i64 { return counter.next(1); }
}@emit thread is valid only in a thread’s @tape block. The emitted thread’s code is resolved where it lands, like any emitted code: a type of the generator’s own module is reached through an import of it, or passed in as a type parameter.
Inside a thread’s functions, the thread’s own names come first: its state, its other functions (called by their bare name) and its nested threads, before module declarations of the same name. A local or parameter can’t take one of those names, and a let of the state can’t be assigned. Module consts and lets are in reach; module vars are not.
TaskHandle
An async call returns a TaskHandle; pass it to cancel to stop the task:
let task: TaskHandle = loader.load("big.bin") { on_done: on_loaded };
// later:
cancel(task); // cooperative: on_loaded will not fireA TaskHandle is a plain value: copying it copies the reference to the task, and TaskHandle { id: 0; } refers to no task (a placeholder before the first call). Cancelling a task that has finished does nothing. TaskHandle and cancel need no import; a module that declares its own TaskHandle or cancel keeps it.
A pub thread can be called from the modules that import its module, by the module’s name: lib.loader.load("big.bin") { on_done: on_loaded };.
Thread-local module variables
A module var marked @thread_local has one copy per thread. The functions of its module use it like any variable, and a thread may call them: each thread reads and writes its own copy, so nothing is shared and the no-shared-state check accepts it.
@thread_local var next_id: i64 = 0;
pub fn fresh_id() -> i64 {
next_id = next_id + 1;
return next_id;
}
thread compiler {
fn compile(src: string) -> Module { ... fresh_id() ... } // its own next_id
}- The main thread’s copy is made when the program starts, from the initializer. Each pool thread’s copy is made the first time a task on it uses one of the module’s
@thread_localvars, from the same initializer, and lives as long as that pool thread: the next task on it sees what the last one left. - Where tasks run cooperatively (a VM a game embeds,
tape run --hot), they run on the draining thread, but they still use a copy of their own, not the main thread’s. - Any type works, managed ones included (a string, a
Vec); a pool thread that ends drops its copies. - Everything in the module can use it: functions, methods, a component’s methods, vars and view tree (those run on the main thread, so they see its copy), and the functions of any thread.
- Rules: only a
var(a moduleletis shared already), notpub(other modules use it through the module’s functions), its type written, and no local or parameter in the module may take its name.
threads.drain() waits for every task of the program. To wait for your own tasks only, keep their handles and wait on them:
import threads from "threads";
let one: TaskHandle = work.square(3) { on_done: show };
threads.wait(one); // show(9) has run
var hs: [3]TaskHandle;
for (i in 0..3) { hs[i] = work.square(i) { on_done: show }; }
threads.wait_all(hs[..]); // all three delivered| Function | Waits until |
|---|---|
threads.wait(h) | task h has been delivered (its events, then its on_done or on_error, have run) or was cancelled |
threads.wait_all(hs) | every task of the slice hs has |
- The results still come through the callbacks, on the thread that waits, as with
drain(). - Other tasks keep running while you wait. Whatever they deliver in the meantime is delivered too, in order: a single-worker thread’s results arrive in the order its tasks were queued, a pool’s in the order they finish. Tasks still running when
waitreturns are left alone. - A handle whose task has finished, or was cancelled, returns at once.
- Where tasks run cooperatively (a VM a game embeds,
tape run --hot), they run one after another in the order they were queued, so waiting for a task also runs the ones queued before it. - Waiting from inside a task, or from a callback (
on_done,on_error, an event handler), traps: what it waits for could be behind it in the batch being delivered. Wait where you started the tasks. - Inside a
ui.runapp, the event loop delivers results already; awaitthere blocks the window until the tasks are done.
Last modified: