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

tape
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.run drains 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) and threads.wait_all(hs) wait for some tasks only (Waiting for tasks)
  • threads.native() says whether tasks run on OS threads here (natively, and under tape run and tape 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
tape
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:

tape
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_done fires when the function returns successfully, on_error when it returns an error
  • on_error can be bound only when the function returns T or E
  • Callbacks never fire after cancel(handle)
  • When a component unmounts, its pending tasks are cancelled automatically. ui.unmount also destroys the instance, one you made in #code included: 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_result above): 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:

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

tape
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, a Vec’s set), stops the program with a trap naming it: trap: write to scene.things, which 24 running tasks read.
  • Freeing it (mem.free, a Vec’s free, a push that grows the buffer) traps the same way: free your data after threads.drain(), not before.
  • Each task lets go when its function returns (or when it is cancelled before it ran), before its on_done runs, 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.

plaintext
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'
tape
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 let never 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, before main.
  • A @thread_local var has 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.
tape
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 own

Profiles

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: