Skip to main content

Concurrency

Running work at the same time with tasks: branch and join, channels, select, timers and I/O, Mutex, and the rules for sharing data.

include "io";
include "files";

fn load_level_text() {
task<string> layout = branch file_read_text("levels/1/layout.yaml"); // starts now
task<string> story = branch file_read_text("levels/1/story.txt");
println("loading..."); // runs meanwhile
string layoutText = (join layout)?; // wait for each result
string storyText = (join story)?;
println("loaded " + layoutText + storyText);
}

How it works​

  • Code runs one statement after another unless you use branch.
  • branch starts a task: a lightweight piece of work that runs alongside the code that started it. Tasks are cheap; you can start thousands.
  • When a task waits (for join, a channel, a timer, file or network I/O, or a Mutex), other tasks run in the meantime. Waiting never freezes the game, unless an engine function itself blocks.
  • Any function may wait. There is no async keyword.

branch​

fn score_for(int kills, int seconds) -> int { return kills * 100 - seconds; }

task<int> score = branch score_for(12, 95);
task<int> withBonus = branch {
int base = score_for(3, 40)?;
return base + 50;
};
branch println("level started"); // result ignored
task<void> saved = branch file_write_text("save.txt", "level=2")
!! { println("save failed: " + err.message); };
  • branch call(args) works out the arguments immediately, in the current task, then runs the call as a new task. Functions, trees, methods, interface methods, function values and engine functions can all be branched.
  • branch { ... } runs the block as a new task. Its result type comes from its return statements (task<void> if none returns a value). ? and throw are allowed inside, even in an infallible fn; a failure fails the task.
  • branch call() !! { ... } runs the call and its handler in the new task and gives task<void>.
  • branch returns at once and never fails.

Variables used inside a branch block​

Variable holdsInside the block
bool, byte, int, int32, char, float, float32, enums, type, string, function valuesA copy made when the task starts. Changes inside the block don't affect the outside.
Objects, arrays, maps, channels, tasks, buffers, error, engine objectsThe same object, shared by both tasks.
rows, view, borrowNot allowed (compile error).

Arguments of a branched call follow the same rules. A const value stays const. Top-level variables are never copied: the block reads and writes the program's single copy (see Sharing data between tasks).

Tasks and join​

  • A task<T> is the eventual result of a task: a T, or an error.
  • join t waits for t to finish and gives its result. It can fail, so write (join t)? or guard it. Joining a finished task returns at once.
  • A task can be joined any number of times, from any task; every join gives the same result or the same error.
  • join on a null task traps.
  • You don't have to join a task. It keeps running and keeps the objects it uses alive until it finishes. But a failure in an unjoined task is lost.
  • When main of a standalone program returns, the program exits even if tasks are still running. Join anything that must finish. In a game, tasks end when the game shuts down.

Cancelling and timeouts​

task<Mesh> loading = branch load_mesh("ship.mesh");
Mesh mesh = (join loading unless timeout(2.0))?; // give up after 2 seconds

Job job = (jobs.receive() unless shutdown)?; // stop waiting on shutdown

unless ch makes a wait give up when the channel ch fires: it holds a value or it is closed. Firing never takes the value, and ch may have any element type.

FormIf the wait finishes firstIf ch fires first
join t unless chThe result of join t, or its failure.t is cancelled; the join fails with -305.
c.receive() unless chThe value, or the closed-channel failure (-300).Fails with -305. Nothing is taken from c.
select { ... } unless ch;The arm that is ready runs.The select fails with -305.
  • The result can still fail, so write (join t unless ch)? or guard it with !!. A !! after the channel handles the whole wait: join t unless stop !! { ... };.
  • A task that has already finished wins, even when ch has already fired.
  • A cancelled task stops the next time it waits or is paused. A task that is already waiting (on a timer, a channel, another task) stops when that wait ends, without running further. Every other join of a cancelled task fails with -305 too.
  • ch is read once, when the wait starts.
  • Nothing else cancels a task from script. The engine may cancel tasks it started; joining them then fails.

Timeout channels. timeout(seconds) and deadline(at) (time) return a channel<bool> that is closed after seconds, or once timer_now() reaches at. A closed channel stays fired, so one timeout can guard many waits:

channel<bool> limit = timeout(5.0);
Mesh ship = (join loading_ship unless limit)?;
Mesh station = (join loading_station unless limit)?; // same 5-second budget

An operation with its own deadline, such as stream.read_within or socket.set_read_deadline (see the standard library), stops the operation itself. Prefer it for I/O.

Channels​

channel<int> jobs = new channel<int>(16); // holds up to 16 values
channel<int> handoff = new channel<int>(); // each send waits for a receiver
OperationBehaviourCan fail
c.send(v)Waits until there is room or a receiver takes v.yes
c.receive()Gives the oldest value, waiting until one is available.yes
c.close()Closes the channel. Safe to call twice.no
  • With no capacity (or 0), each send hands its value directly to a receive. With a capacity, up to that many values can wait in the channel. A negative capacity traps.
  • Values come out in the order they went in. Waiting senders and receivers are also served in order.
  • After close: every send fails; receive keeps returning values still in the channel, then fails. The failure code is -300.
  • A loop like for { int job = jobs.receive()?; ... } therefore ends with a failure when the channel is closed and empty; handle it to finish cleanly.
  • A channel can carry any type except rows, view and borrow.

select​

channel<int> clicks = new channel<int>(8);
channel<string> keys = new channel<string>(8);

select {
int button = clicks.receive() { println("clicked button " + int_to_string(button)); }
string key = keys.receive() { println("pressed " + key); }
}

Syntax rules are in Statements. How it behaves:

  • The arms are checked in the order written. The first arm whose channel has a value runs, with that value. Only that one channel is read.
  • When several channels have values, the earliest arm always wins.
  • Without else: if no channel has a value, the task waits until one does. A closed, empty channel counts as ready and makes the select fail with -300, so a select loop ends when its channels close.
  • With else: the channels are checked once, without waiting. Closed, empty channels are skipped. If no arm can run, else runs. It never fails.
  • There are no send arms or timeout arms. For a timeout, write select { ... } unless timeout(seconds); (see Cancelling and timeouts).

Timers and I/O​

  • Timer, file, stream, process, socket and channel operations look like normal calls: the current task waits until the result is ready, while other tasks keep running.
  • To do something else while waiting, branch the call and join it later.
  • timer_after(seconds) (time) finishes after at least that many seconds; timer_now() reads the same clock. The host decides which clock that is (real time, frame time or game time).
  • Racing a timer against an operation doesn't cancel the operation; join t unless timeout(seconds) does.

Mutex​

Mutex (sync) lets one task at a time use shared data.

Mutex m = mutex_create()?;
m.lock()? && { m.unlock(); };
// ... use the shared data; unlock runs when this block ends
MemberMeaning
mutex_create() -> MutexCreates an unlocked mutex. Can fail.
m.lock()Waits until this task holds the lock. Can fail.
m.unlock()Releases the lock.
m.guard(MutexBody body)Locks, runs body.run(), and unlocks whether or not it fails. Can fail.
  • Waiting tasks get the lock in order.
  • Locking a mutex you already hold waits forever.
  • Any task can unlock it, not only the one that locked it.

Sharing data between tasks​

Tasks can share objects, arrays and maps, and every task sees the same top-level variables. That is safe only if they don't touch the same data at the same time.

  • If two tasks use the same field, element, map entry or top-level variable, and at least one of them writes it, one task must finish with it before the other starts. These make that order certain:
    • code before a branch runs before the new task;
    • a task's work happens before a join that sees it finish;
    • a send happens before the receive that gets the value;
    • an unlock happens before the next lock.
  • Otherwise it is a data race and the result is unpredictable (the program won't corrupt memory, but values may be wrong).
  • Building or growing a shared array or map counts as writing.
  • Don't touch a buffer while an I/O task is using it; wait for the task first.

Safe patterns: give each task its own data; hand data over through a channel; protect shared data with a Mutex; join a task before reading what it wrote.

Scheduling​

  • A long loop doesn't block other tasks: running tasks are paused regularly so others get a turn.
  • An engine function runs to completion once called.
  • Apart from channel order, Mutex order and select arm order, there is no guarantee about which ready task runs next.
  • Tasks may run at the same moment, depending on the host, so follow the data-sharing rules above even when your tests pass without them.
  • Don't rely on task interleaving for correct results; see Determinism.

See also​