Threads & Pools

Thread declaration

tape
@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:

tape
@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 min of 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 + part in on_done adds 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

tape
@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 }:

tape
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.

PlannedToday a panic in a task ends the program like any other trap.

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:

tape
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
    }
}
CallerCan call
UI / componentsTop-level threads only (async)
Top-level threadIts own nested threads (blocking)
Nested threadIts own nested threads deeper (blocking)
Any threadNever 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:

tape
// 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:

tape
let task: TaskHandle = loader.load("big.bin") { on_done: on_loaded };

// later:
cancel(task);   // cooperative: on_loaded will not fire

A 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.

tape
@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_local vars, 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 module let is shared already), not pub (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:

tape
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
FunctionWaits 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 wait returns 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.run app, the event loop delivers results already; a wait there blocks the window until the tasks are done.

Last modified: