Skip to main content

Concurrency

Concurrency in BT is explicit. A normal call runs sequentially. Prefix a call or block with branch to start it concurrently, and join it when the result is needed.

fn sum_range(array<int> values, int from, int to) -> int {
int total = 0;
for (int i = from; i < to; i++) { total = total + values[i]; }
return total;
}

fn total_score(array<int> scores) -> int {
int half = scores.length() / 2;
task<int> first = branch sum_range(scores, 0, half);
task<int> second = branch sum_range(scores, half, scores.length());

int a = (join first)?;
int b = (join second)?;
return a + b;
}

Tasks​

branch returns a task<T> immediately. T is the success type of the branched work.

task<int> score = branch total_score(scores);
task<void> saved = branch file_write_text("save.txt", "level=3\n");

A task is running, succeeded, or failed. It is a reference value: pass it to functions or store it in objects and collections.

Some engine functions return a task<T> directly; join it like any other task. Standard timer and I/O functions return their final result instead, so branch them to make them concurrent.

Branching a call​

Any ordinary call can be branched:

task<buffer> data = branch file_read_bytes("world.dat");

The arguments are evaluated before the child starts. Without branch, a call is sequential, even when it waits on a timer, channel, file, stream, or socket.

Branching a block​

A branch block runs a short sequence concurrently:

task<int> work = branch {
string text = file_read_text("highscore.txt")?;
return int_from_string(text)?;
};

The block's return value sets the task type. A block that returns no value produces task<void>. Branch a named function instead when the work is long, reused, or deserves a descriptive name.

The block runs as a separate task, so break and continue cannot leave it to reach a loop outside; the compiler reports break cannot leave a branch block. A loop inside the block may use them normally.

Joining​

join is a prefix expression:

int value = (join work)?;

If the task is still running, the current execution waits while other BT work continues. If it has finished, join produces the result at once.

join is fallible, because the task may have failed. Propagate with ?, or attach !! to the branched operation to recover inside the task:

task<void> optional = branch file_remove("cache.dat") !! {
println(err.message);
};

(join optional)?;

Parentheses make join easy to combine with ?, assignment, or a larger expression.

Branching and immediately joining gives the same result as a sequential call, with no concurrency:

int value = (join branch total_score(scores))?;

Doing work before joining​

branch pays off when the parent has independent work to do:

fn load_level() -> int {
task<buffer> map = branch file_read_bytes("map.dat");
task<buffer> textures = branch file_read_bytes("textures.dat");

println("Loading...");

buffer map_data = (join map)?;
buffer texture_data = (join textures)?;
return map_data.size() + texture_data.size();
}

Captures​

A branch block may use variables from the enclosing scope.

Scalars (numbers, bool, char, enums) and strings are copied into the child:

int count = 10;
task<int> result = branch {
count = count + 1; // changes the child's copy
return count;
};

References stay aliases. Parent and child share the same object or collection:

object Counter { int value; }

Counter shared = Counter { value: 0 };
task<void> update = branch {
shared.value = shared.value + 1; // the parent sees this change
};

Arguments of a branched call follow the same rules.

Shared mutable state​

Aliasing does not make shared mutation safe. Two tasks that write the same fields, array elements, map entries or top-level variables without coordination race. Top-level variables are not locked: every task reads and writes the program's single copy, like a field of an object they all share.

Prefer one of these designs:

  • Give each task disjoint objects or indexes.
  • Let one task own the state, and send it commands through a channel.
  • Guard the state with a Mutex (see Standard library).
  • Join a writer before another task reads its results.

Construction is mutation too. Two tasks building the same structure race, even once at startup. Build it before branching the workers, or hold a mutex while building.

Buffers follow the same rules; see Buffers used concurrently.

Channels as ownership boundaries​

Channels suit streams of values and long-lived producer/consumer pairs:

fn producer(channel<int> output) -> void {
int value = 0;
while (value < 100) {
output.send(value)?;
value = value + 1;
}
output.close();
}

fn run_pipeline() -> int {
channel<int> values = new channel<int>(16);
task<void> producing = branch producer(values);

int total = 0;
repeat (100) {
total = total + values.receive()?;
}

(join producing)?;
return total;
}

Use a task for a finite operation with one result. Use a channel for repeated communication or ownership transfer.

Concurrent timers and I/O​

Timer, file, stream, socket, and channel calls look synchronous. Call them normally to wait. Branch them to overlap other work.

fn read_while_working(stream input, buffer bytes) -> int {
task<int> reading = branch input.read(bytes, 0, bytes.size());
task<void> pause = branch timer_after(1.0);

int checksum = 0;
for (int i = 0; i < 100000; i++) { checksum = (checksum * 31 + i) % 65536; }

int received = (join reading)?;
(join pause)?;
return received;
}

Waiting on a timer this way does not cancel the read: the branched read keeps waiting after the timer finishes. To give up on a wait, use unless.

Cancelling and timeouts​

unless gives up on a wait when a channel fires first. A channel fires when it holds a value or is closed; firing never takes the value.

fn load_or_give_up(string path) -> Mesh {
task<Mesh> loading = branch load_mesh(path);
return (join loading unless timeout(2.0))?;
}
  • join t unless ch gives the task's result if it finishes first. If ch fires first, the task is cancelled and the join fails with -305. A task that has already finished always wins.
  • c.receive() unless ch gives the next value of c, or fails with -305 if ch fires first. It never takes a value it does not return, so a value that arrives at the same moment stays in c for the next receive.
  • select { ... } unless ch; fails with -305 when ch fires before any arm is ready.

All three can fail, so propagate with ? or handle with !!. Parenthesize the join: (join t unless ch)?, because join t unless ch? applies ? to ch. A !! after the channel handles the whole wait:

fn next_job(channel<Job> jobs, channel<bool> shutdown) -> Job? {
Job? job = null;
{ job = (jobs.receive() unless shutdown)?; } !! {
if (err.code != -305) { log_error(err); }
};
return job;
}

A cancelled task stops the next time it waits (on a timer, a channel or another task), without running any more of its code. Other joins of the cancelled task fail with -305 as well.

timeout(seconds) and deadline(at) from time return a channel<bool> that is closed when the time is up. A closed channel stays fired, so one timeout can bound several waits:

fn load_level(Level level) -> void {
channel<bool> budget = timeout(5.0);
Mesh ship = (join branch load_mesh(level.ship) unless budget)?;
Mesh station = (join branch load_mesh(level.station) unless budget)?;
show(ship, station);
}

Each timeout keeps running until its time is up, even when nothing waits on it any more, so create one per deadline, not one per loop iteration.

An I/O operation with its own deadline stops the operation itself, which unless cannot do for an engine call that is already running:

  • stream.read_within(buffer, offset, count, seconds) for a stream.
  • socket.set_read_deadline(seconds) before socket.receive or socket.accept.

Each fails with a distinct, catchable timeout error, separate from end of input, and releases the wait when the deadline passes. This is how a server defends against a client that stalls mid-request.

Background tasks​

A branch need not be assigned or joined. This starts a background task:

fn handle_connection(socket peer) -> void {
buffer reply = buffer_from_string("hello\n")?;
peer.send(reply, 0, reply.size())?;
peer.close();
}

fn serve(socket listener) -> void {
while (true) {
socket peer = listener.accept()?;
branch handle_connection(peer);
}
}

The task keeps running after the function that started it returns. Without a handle, nothing sees its result or error.

Attach a handler to report or transform a background error:

branch handle_connection(peer) !! {
println("connection failed: " + err.message);
};

Join a task when its completion, result, or error affects the current operation. Leave it unjoined when it is truly independent.

Background work lives only as long as the program. When a standalone program's main returns, the process exits and unfinished background tasks are dropped without completing; their handlers never run. Join any task whose work must finish before main returns. In a game, running tasks stop when the game exits.

Waiting on several channels: select​

select waits until one of several channels can deliver, then runs that arm with the received value bound:

object Click { int x; int y; }
object Key { int code; }

fn dispatch(channel<Click> clicks, channel<Key> keys) -> void {
while (true) {
select {
Click click = clicks.receive() {
println("click at " + int_to_string(click.x) + ", " + int_to_string(click.y));
}
Key key = keys.receive() {
println("key " + int_to_string(key.code));
}
}
}
}

Rules:

  • Every arm is a declaration initialized by channel.receive(). The declared type must accept the channel's element type. The variable is scoped to the arm's block.
  • Arms have source-order priority. When several channels are ready, the earliest arm wins. The choice is deterministic, not random.
  • Without else, a closed and drained channel fails the select with the same error receive() raises. A select loop therefore unwinds when its channels close, which is the normal shutdown idiom. Such a select must appear in a fallible function or a handled context.
  • Only the winning arm's channel is received from. If another task takes the value first, the select keeps waiting.
  • A final else arm, inside the braces, makes the select non-blocking. It polls once and runs else when no arm is ready, including when every channel is closed. An arm whose channel still holds a value runs even if an earlier arm's channel is closed. A select with else never fails.
select {
Click click = clicks.receive() {
println("click at " + int_to_string(click.x) + ", " + int_to_string(click.y));
}
else {
idle_frames = idle_frames + 1;
}
}

select has no timeout arm. Add unless after the braces to bound the wait:

select {
Click click = clicks.receive() { handle_click(click); }
Key key = keys.receive() { handle_key(key); }
} unless timeout(0.5);

The select fails with -305 when the timeout fires before any arm is ready. unless cannot follow an else arm, which never waits.