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. branchstarts 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 aMutex), other tasks run in the meantime. Waiting never freezes the game, unless an engine function itself blocks. - Any function may wait. There is no
asynckeyword.
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 itsreturnstatements (task<void>if none returns a value).?andthroware allowed inside, even in aninfallible fn; a failure fails the task.branch call() !! { ... }runs the call and its handler in the new task and givestask<void>.branchreturns at once and never fails.
Variables used inside a branch block
| Variable holds | Inside the block |
|---|---|
bool, byte, int, int32, char, float, float32, enums, type, string, function values | A copy made when the task starts. Changes inside the block don't affect the outside. |
Objects, arrays, maps, channels, tasks, buffers, error, engine objects | The same object, shared by both tasks. |
rows, view, borrow | Not 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: aT, or anerror. join twaits fortto 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.
joinon anulltask 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
mainof 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.
| Form | If the wait finishes first | If ch fires first |
|---|---|---|
join t unless ch | The result of join t, or its failure. | t is cancelled; the join fails with -305. |
c.receive() unless ch | The 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
chhas 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
joinof a cancelled task fails with-305too. chis 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
| Operation | Behaviour | Can 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), eachsendhands its value directly to areceive. 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: everysendfails;receivekeeps 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,viewandborrow.
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 theselectfail with-300, so aselectloop ends when its channels close. - With
else: the channels are checked once, without waiting. Closed, empty channels are skipped. If no arm can run,elseruns. 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,
branchthe call andjoinit 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
| Member | Meaning |
|---|---|
mutex_create() -> Mutex | Creates 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
branchruns before the new task; - a task's work happens before a
jointhat sees it finish; - a
sendhappens before thereceivethat gets the value; - an
unlockhappens before the nextlock.
- code before a
- 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,
Mutexorder andselectarm 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
- Guide: Concurrency