Task Groups
Active Development
Concurrency and asynchronous APIs in Zena are under active development. Specifications, runtime behaviors, and standard library interfaces described on this page are incomplete and evolving.
Structured concurrency ensures that concurrent operations have bounded lifetimes
tied to the lexical structure of the program. In Zena, concurrent tasks are
coordinated using TaskGroup from zena:async.
TaskGroup ​
A TaskGroup manages a dynamic collection of concurrently executing child
tasks.
import { TaskGroup } from 'zena:async';
let group = new TaskGroup();
Scope inheritance ​
When a TaskGroup is instantiated:
- It creates an internal
CancelScopeparented to whichever cancellation scope is currently ambient (currentScope()). - If an ancestor scope cancels, the cancellation automatically cascades down to the task group and all tasks executing within it.
- The group can be explicitly cancelled at any time by calling
group.cancel(). - Its status can be checked via the
group.isCancelledgetter.
Spawning tasks ​
Tasks are added to a group using group.spawn():
let group = new TaskGroup();
let futureA = group.spawn(async (): Future<String> => {
return await fetchResourceA();
});
let futureB = group.spawn(async (): Future<i32> => {
return await fetchResourceB();
});
The spawn contract ​
- Factory lambda:
spawntakes a zero-argument function that returns aFuture<T>(() => Future<T>). - Scope installation: When
spawninvokes the lambda, it installs the group's internal cancellation scope as the ambient scope for the duration of the initial synchronous ramp. The newly created async frame binds to this scope. - Eager execution: As with any async function call, execution starts
synchronously in the caller's turn up to the first unsettled
await. - Returned future:
spawnreturns the child'sFuture<T>. Callers can await it directly to consume individual return values, while the group tracks its overall completion.
join and race ​
A task group provides two coordination patterns: waiting for all tasks to finish
with join(), or racing tasks with automatic loser cancellation using
TaskGroup.race().
Waiting for tasks with join() ​
The join() method returns a Future<void> that settles when every task
spawned in the group has finished:
let group = new TaskGroup();
for (let id in itemIds) {
group.spawn(async (): Future<void> => {
await processItem(id);
});
}
// Wait for all spawned tasks to complete:
await group.join();
- If all tasks succeed,
join()completes successfully. - If one or more tasks fail,
join()rejects with the first error encountered. - Multiple calls to
join()while tasks are outstanding share the same future. - If a group goes idle, subsequent tasks can be spawned into it and joined again.
Loser-cancelling races with TaskGroup.race ​
While Future.race() takes pre-existing, running futures and leaves losers
running in the background, TaskGroup.race() takes task factory closures and
cancels all losers as soon as the first candidate settles:
let fastestResult = await TaskGroup.race([
() => fetchFromPrimaryServer(),
() => fetchFromBackupServer(),
]);
- Each candidate lambda is spawned as a child task in a new, dedicated
TaskGroup. - The first task to complete—either with a value or an error—wins the race.
- The group immediately cancels all other candidate tasks, terminating their
in-flight network requests or computations at their next
awaitcheckpoint. - If an external cancellation arrives from an ancestor scope before any candidate finishes, the race future completes as cancelled.
Error propagation ​
TaskGroup enforces fail-fast error handling across all concurrent children:
let group = new TaskGroup();
group.spawn(async (): Future<void> => {
await sleep(milliseconds(100));
throw new Error('Something went wrong');
});
group.spawn(async (): Future<void> => {
// Runs until the sibling fails, then cancels at the next await:
while (true) {
await doWork();
}
});
try {
await group.join();
} catch (e: Error) {
println('Group failed: ' + e.message);
}
Sibling cancellation on failure ​
When any task spawned within a group fails with an unhandled exception:
- Error capture: The group records the first error that occurred.
- Immediate sibling cancellation: The group immediately cancels its internal
CancelScope. - Cooperative termination: All sibling tasks in the group are interrupted at
their next checkpoint (
await) and unwind without executing remaining statements. - Surface at join: When
await group.join()is called, the captured error is re-thrown. - Suppression of secondary errors: If additional siblings fail while unwinding, their errors are ignored so the initial root cause is preserved.
Structured concurrency invariants ​
By using TaskGroup, asynchronous programs adhere to three fundamental
structural invariants:
- Lifetime bounding: A concurrent child task never outlives the scope of the
TaskGroupthat spawned it. Code does not proceed pastawait group.join()until all background work has concluded. - No orphaned tasks: If an operation fails, all associated background work is promptly cancelled rather than left running invisibly, preventing resource exhaustion and memory leaks.
- Hierarchical cancellation trees: Cancel scopes and task groups form a strict tree. Cancelling any branch propagates down to all child groups and tasks while leaving unrelated parent or sibling branches unaffected.