Tasks, Channels, and Mutexes
The processor surface is a V3 systems feature. Every processor construct is
std-only: a package must declare the bundled internal standard dependency.
Choosing core or memo alone does not provide threads or hosted services.
The artifact itself uses fol_model = "memo"; there is no std model.
This dependency gates processor APIs, not process execution: std-free core
and memo artifacts may still run on a compatible host.
Spawn
[>]call() starts one operating-system thread and lets the current routine
continue. FOL has no worker pool, scheduler settings, Rust async runtime, or
Tokio dependency. Every spawned task is registered with the process and the
program joins all outstanding tasks before its entry point exits.
use std: pkg = {"std"};
fun[] worker(): int = {
return std::io::echo_int(17);
};
fun[] main(): int = {
[>]worker();
return 0;
};
A bare spawn is always fire-and-forget: [>]call() creates no eventual and
cannot be awaited. It must therefore call an infallible routine. To run a
recoverable routine asynchronously, omit [>], write call() | async, then
consume the eventual with | await and handle the result with check(...) or
||.
The call form must resolve directly to a named routine declaration. Both an
unqualified call such as [>]worker() and a qualified call such as
[>]workers::worker() are supported. Stored routine values, stored anonymous
routines, and routine parameters remain indirect calls and are not spawn
targets in V3. The explicit zero-parameter anonymous spawn form carries an
explicit capture list: channel sender endpoints (c[tx]) and value operations
(data[mov], amount[cpy], record[cln]) thread their captures into the
task, and state[bor] lends the outer binding to the scoped task: the owner
stays readable but is frozen — no mutation, no transfer — until the scope
joins its tasks. Borrowed captures require a clone-safe, thread-safe owner. Receiver-method call
syntax is not a named spawn-target form; use a free routine name or qualified
path instead.
The spawn boundary follows the V3 memory rules:
- clone-safe values clone into the task
- thread-safe move-only values, including
@ownership and unique pointers, move into the task and leave the sender moved-out - borrowed values cross only as explicit
[bor]captures of a scoped task, which freeze their owner until the scope joins ptr[shared, T]values do not cross the boundary because theirRcbacking is not thread-safe- unresolved generic parameters do not cross until FOL has a thread-safety and lifetime contract for generics; concrete thread-safe instantiations can cross
- omitted defaults are checked as task arguments under the same rules as explicit arguments
Cross-thread shared mutation belongs to mux[T] parameters, not Rc.
The exact positive and negative spawn examples are maintained in the canonical shipped processor inventory.
Channels
Channels are an implemented processor surface. The contract is an unbounded
MPSC chn[T] backed by std::sync::mpsc: c[tx] sends without blocking,
c[rx] performs a blocking pull that yields opt[T] — its present branch owns
a fresh payload and nil means every sender has closed — and receiver iteration
runs until all sender handles are dropped. Unwrap the result with c[rx][] or
bind it and inspect it with when ... on ... *. The old sequence-index spelling
c[rx][i] is not part of the contract.
A send value | c[tx] produces a must-handle err[T]: nil means the payload
was delivered, and the present branch owns the unsent payload when every
receiver has already closed. Bind it (var sent: err[int] = value | c[tx]),
inspect it with when ... on ... *, or propagate it; a bare send that discards
the result — and with it the unsent payload — is rejected.
A transmitter is a first-class value. c[tx] has type chn[tx, T], a
clone-capable sender endpoint that may be bound, passed to another routine, or
returned. Sending works through any sender value — value | sender — not only a
direct c[tx] access, so a producer can accept a chn[tx, T] parameter and emit
into it. The receiver endpoint is also a first-class chn[rx, T] value, but
unlike the cloneable sender it is unique and move-only: it may be bound, passed,
or returned, yet never duplicated, so a channel keeps exactly one receiver.
T may be a thread-safe move-only value. Sending consumes that value, and a
blocking receive, receiver iteration, or selected arm transfers the payload to
its destination without cloning it; the blocking receive delivers it inside the
opt[T] shell. Unique pointers are supported this way; values containing
ptr[shared, T] remain barred from OS-thread boundaries.
The first c[rx] acquisition relinquishes that channel binding’s local
transmitter capability. Clone or capture every needed c[tx] handle before
receiving. Sender handles acquired earlier remain valid and the channel closes
when the last of those handles is dropped; trying to acquire c[tx] after the
receiver is active is an ownership error, not a runtime panic. This explicit
endpoint lifecycle lets pull loops and select observe closure without keeping
an invisible transmitter alive.
Channel endpoint acquisition is not allowed inside dfr or edf, including
endpoint captures on anonymous spawned routines in a deferred body. Acquire all
sender handles before entering the deferred block, and keep receiver operations
in ordinary control flow; delayed endpoint acquisition cannot be ordered safely
against the first receiver acquisition.
V3 endpoint access is restricted to a direct local, parameter, or capture
binding owned by the current routine. Projected channel fields, channel
container elements, and implicit references to an outer routine’s channel are
not part of the shipped lifecycle model; keep the channel in a direct binding
before using [tx], [rx], iteration, or select. Top-level/global channel
bindings are also rejected; a channel belongs to the routine that owns its
receiver.
Full chn[T] values also cannot be embedded in records, entries, containers,
or ownership/error/optional wrappers in V3. This keeps the single receiver and
its endpoint lifecycle attached to one direct routine-local binding. Sender
handles that were acquired earlier remain independently cloneable.
Anonymous routines cannot declare chn[T] parameters in V3 because they do not
participate in named-routine sender/receiver effect refinement. Use a named
routine, or capture an already-existing sender explicitly with c[tx].
Spawned anonymous routines can clone a transmitter explicitly with
[>]fun()[c[tx]] = { ... }. Receiver endpoints are not cloned: MPSC retains a
single consuming side.
The exact positive, ownership-boundary, lifecycle, and tier-failure channel examples are maintained in the canonical shipped processor inventory.
Select
The chosen multiplexing form is a multi-arm statement:
select {
when first as value { consume(value); }
when second as value { consume(value); }
};
The old single-channel select(channel as name) { ... } form is not retained.
The runtime polls arms in source order with try_recv(). Closed arms are
skipped and a blocking select completes, continuing after the statement, when
all arms are closed. An optional * arm runs immediately when no receiver is
ready. Simultaneously-ready arms therefore have source-order bias in V3; no
fairness guarantee is promised.
A blocking select without * must contain at least one channel arm.
select {} is invalid and is rejected during typecheck rather than being left
for lowering. A default-only select is non-blocking because its * body runs
immediately.
Mutex parameters
The mutex is the first-class managed type mux[T]. The direct handle surface
locks and unlocks through the handle itself:
fun[] update(value: mux[Counter]): int = {
value.lock();
value.total = value.total + 1;
var result: int = value.total;
value.unlock();
return result;
};
mux[T] lowers to Arc<Mutex<T>>. A routine acquires the guard with .lock();
guarded fields are accessible only while that guard is active. The guard is
released automatically at the end of the lexical scope that acquired it, or
early with .unlock() in that same scope. Locking the same parameter twice is
rejected, as is unlocking without a guard acquired in the current lexical
scope. The historical ((name)) parameter spelling is not retained.
Mutex field access and .lock()/.unlock() are not allowed inside dfr or
edf bodies in V3. Guard transitions are tracked for immediate lexical
execution and are not replayed as delayed effects at scope exit. Forwarding a
mutex handle from a deferred body to another mux[T] routine is rejected for
the same reason.
The guarded T cannot be copied, returned, embedded, or passed to an ordinary
T parameter as a whole value. Passing the mutex handle directly to another
mux[T] parameter is allowed, including through spawn; data access still
requires that receiving routine to acquire its own guard.
Wrapping an existing owner into a mux[T] parameter transfers it into the
mutex and consumes the binding: the call site must state [mov]owner, and the
original binding is unusable afterward. An implicit copy at that boundary
would silently fork the state between the caller’s binding and the guarded
value, so it is rejected even for copy-safe types. Fresh values (record
literals or call results) pass without an operation — there is no surviving
source to fork.
For a synchronous call, the caller must unlock a handle before forwarding it
to another mux[T] parameter; otherwise the callee could block trying to acquire
the caller’s guard. One call also cannot pass the same handle to two mux[T]
parameters because those aliases can self-deadlock. Spawn and async calls are
task boundaries and may receive the cloned handle while the caller still holds
its guard; the new task waits until the caller releases it.
Guard values
Locking a borrowed handle produces a named, lifetime-scoped mutable guard
bound with var[mut, bor]. Field access goes through the guard while the lock
is held:
fun[] bump(state: mux[Counter]): int = {
var[mut, bor] guard: Counter = ([bor]state).lock();
guard.value = guard.value + 1;
var snapshot: int = guard.value;
[end]guard;
return snapshot;
};
The guard releases at its last use (the same non-lexical rule as ordinary
loans), or early and explicitly with [end]guard. A guard value cannot be
moved, copied, or cloned, and it cannot be held across a spawn, an await, or a
blocking receive — the checker rejects those boundaries rather than attempting
static deadlock analysis. End the guard first, then cross the boundary
(examples/proc_mutex_guard_end_m3 shows the early-end pattern;
examples/fail_proc_mutex_guard_await_m3 and
examples/fail_proc_mutex_guard_move_m3 pin the rejections).
The mux[T] calling convention is currently available only on named routines
that are called directly, through either unqualified or qualified names. A
named routine with mux[T] parameters cannot be stored or passed as a
first-class routine value, and anonymous routines cannot declare mux[T]
parameters. Those routine-value forms do not yet retain the mutex ABI metadata
needed to preserve Arc<Mutex<T>>; use a named direct call instead.
Despite this chapter’s historical filename, the shipped surface is OS-thread
tasks, channels, select, and mutexes—not resumable coroutines or generators.
Generator yield is parser-recognized but remains rejected by typecheck and
lowering in V3.
The exact positive, removed-syntax, deferred-effect, and tier-failure examples
for select and mutexes are maintained in the
canonical shipped processor inventory.