Concurrency Overview
Model
Tape’s concurrency is built on worker threads with two calling modes:
- UI → thread: non-blocking; callbacks deliver the result on the caller’s thread
- Thread → thread: blocking; ordinary sequential code that runs on another core
Key principles:
- No shared mutable state — threads communicate through parameters and return values
- No closures — callbacks are plain function names (no captured environment)
- No async/await — code inside a thread is synchronous; only the UI boundary is asynchronous
- Actor-like — each thread has persistent private state
- Same error model — thread functions return
T or E - No deadlocks — a thread calls only its own nested threads, never upward or sideways
- Implicit cancel — cancellation propagates through the whole call chain
Thread declaration
thread image {
var encoder: PngEncoder = png.new_encoder(); // persistent state
let max_dim: u32 = 4096; // thread-local constant
fn save(readonly pixels: []u8, w: u32, h: u32) -> []u8 or ImageError {
if (w > max_dim || h > max_dim) {
return ImageError.TooLarge;
}
return encoder.encode(pixels, w, h);
}
}A thread’s var and let state is initialized once per pool thread and persists across calls. See Threads & Pools.
When tasks run
An async call queues a task and returns its TaskHandle at once. Queued tasks run at a drain point, and their callbacks fire there, on the draining thread:
ui.rundrains between events, so a component’s async calls run on their own- a program without an event loop calls
threads.drain()where it wants the results threads.wait(h)andthreads.wait_all(hs)wait for some tasks only (Waiting for tasks)threads.native()says whether tasks run on OS threads here (natively, and undertape runandtape test) or cooperatively on the calling thread (a VM a game embeds,tape run --hot): a server that would run a worker loop as a task on OS threads runs it inline where tasks are cooperative, where a task that never returns would block everything
import io from "io";
import threads from "threads";
thread work {
var calls: i64 = 0;
fn square(x: i64) -> i64 { calls = calls + 1; return x * x; }
}
fn show(n: i64) { io.println("got {n}"); }
pub fn main() -> i32 {
work.square(7) { on_done: show }; // queued: nothing printed yet
work.square(8) { on_done: show };
threads.drain(); // got 49, got 64
return 0;
}Tasks queued when main returns never run. threads.queued() counts the tasks waiting, and threads.pending(h) tells whether a task is still queued or running.
Compiled natively (Windows, Linux, macOS, Uusi), each top-level thread runs its tasks on an OS thread of its own, so tasks of different threads run at once while the main thread goes on; ui.run picks up what they deliver between events. A thread with one worker (the default, @pool(1)) delivers its results in the order its tasks were queued; a @pool thread with several workers, and different threads, deliver in the order the tasks finish. Under tape run and tape test the VM does the same, each worker on an OS thread of its own. Where the VM runs tasks cooperatively (a VM a game embeds, tape run --hot), they run one after another on the draining thread, with the same results; there threads.poll() runs one queued task and returns, so an event loop still gets its turn between tasks.
Calling patterns
UI → Thread (non-blocking)
Components and main-thread code call a top-level thread asynchronously. The call returns a TaskHandle immediately; the result arrives through callbacks on the caller’s thread:
component Editor {
var task: ?TaskHandle = null;
fn on_save_click() {
task = image.save(pixels, w, h) {
on_done: handle_result,
on_error: handle_error,
};
}
fn handle_result(result: []u8) { display(result); } // runs on the UI thread
fn handle_error(err: ImageError) { show_error(err.desc()); }
fn on_cancel_click() {
if (task != null) { cancel(task!); } // no callback fires after this
}
}on_donefires when the function returns successfully,on_errorwhen it returns an erroron_errorcan be bound only when the function returnsT or E- Callbacks never fire after
cancel(handle) - When a component unmounts, its pending tasks are cancelled automatically.
ui.unmountalso destroys the instance, one you made in#codeincluded: nothing of yours may touch it afterwards (read a counter kept in a module variable instead of the component’s) - A callback may be one of the component’s own methods (
handle_resultabove): it is called on the instance that made the call
Thread → Thread (blocking)
Inside a thread function, calling one of its own nested threads is an ordinary call: it blocks until the result is ready and returns it directly:
thread work {
thread compress {
fn deflate(data: []u8) -> []u8 or ZipError { ... }
}
fn process(readonly pixels: []u8) -> []u8 or WorkError {
let packed: []u8 = compress.deflate(pixels) or { // runs on compress's pool
return WorkError.CompressFailed;
};
return packed;
}
}Data passing
Arguments are copied into the worker, and return values are copied back. For large data that the worker only reads, readonly avoids the copy:
fn save(readonly pixels: []u8, w: u32, h: u32) -> []u8 or ImageError {
// pixels is borrowed: no copy, and it cannot be mutated
}A readonly argument is never copied: the task reads your memory in place, so 32 tasks can share one large scene for free. In exchange, that memory is frozen while any task holds it:
- The compiler rejects a change to the variable later in the block that made the call.
- At run time, a store into the frozen memory, from anywhere (through a slice, a pointer,
mem.copy, aVec’sset), stops the program with a trap naming it:trap: write to scene.things, which 24 running tasks read. - Freeing it (
mem.free, aVec’sfree, apushthat grows the buffer) traps the same way: free your data afterthreads.drain(), not before. - Each task lets go when its function returns (or when it is cancelled before it ran), before its
on_doneruns, so the callback may change the data again.
What is frozen is every slice in the argument: the argument itself, or the slice fields of a struct, at any depth, whichever module declares the struct. A local array (var a: [N]T, or a slice of one) can’t be passed readonly to an async call: the task may still read it after the function that owns it has returned. Keep such data on the heap (a Vec, mem.alloc), or pass it without readonly, as a copy.
readonly is allowed only on a thread function’s parameters, and only on types without raw pointer fields (slices are fine). Checking stores costs one load and one branch per store through a pointer or slice, and only in programs that have a readonly thread parameter.
No shared state
A thread function cannot read or write module-level vars, and neither can any function it calls, however deep. It sees only its own thread-local state, its parameters, const values and its own nested threads; it changes the program’s state only through what it hands back, the events it fires and its result, which the callbacks receive on the caller’s thread. Combined with copied arguments and tree-shaped calls, this rules out data races and deadlocks by construction.
A function marked @thread_safe vouches that the module state it reaches is safe from any thread, which ends the check there (the standard library marks its scheduler’s functions this way). A generator emits one as @emit @thread_safe fn ..., with @constant_time in either order and pub after them. Calls through a function value are not followed.
One build reports every function a thread reaches that uses a module var, each as an error followed by notes: the chain of calls from the thread’s function to it, one note per call with where it is made, then where it uses each variable. The chain is the shortest one; fix what it shows, and anything past it is reported on the next build.
app.tape:11:5: error: thread 'page' reaches module variable 'cache_dir' through 'dir': ...
app.tape:12:29: note: 'page.load' calls 'dir'
app.tape:7:33: note: 'dir' uses module variable 'cache_dir'var global_count: i64 = 0;
thread counter {
fn increment() {
global_count = global_count + 1; // compile error: mutable global in a thread
}
}Two kinds of module state are open to threads:
- A module
letnever changes after the program starts, so every thread may read it. Its initializer may be any expression, a function call building a table included; it runs once, beforemain. - A
@thread_local varhas one copy per thread, so a thread’s functions may read and write it freely: a counter, a cache, instrumentation. See Thread-local module variables.
let squares: collections.Vec_i64 = build_squares(); // built once at startup, read by any thread
@thread_local var calls: i64 = 0; // each thread counts its ownProfiles
Threads are available in t1 and t2. A thread declaration in a t0 module is a compile error: kernel concurrency uses interrupt handlers and spinlocks instead.
Last modified: