Concurrency & multiprocessing
X07 intentionally separates:
- deterministic concurrency (for tests and fixture worlds)
- OS threads (for OS-world blocking/I/O concurrency; policy-gated)
- OS multiprocessing (for production parallelism)
Deterministic concurrency (async, single-core)
In fixture worlds, X07’s async system:
- is cooperative (tasks yield at known points)
- runs on a deterministic scheduler
- uses virtual time (ticks) instead of wall-clock time
This enables:
- deterministic pipelines,
- repeatable performance measurements,
- reproducible repair loops.
Async functions (defasync) and task handles
Async functions are defined with defasync.
- A
defasyncreturns an awaited type:bytesorresult_bytes. - Calling a
defasyncreturns an opaque task handle (i32in x07AST).- The handle’s “kind” is determined by the awaited return type (
bytesvsresult_bytes).
- The handle’s “kind” is determined by the awaited return type (
Task ops:
["task.spawn", task_handle] -> i32(stats/registration; optional for most code)["await", <bytes task handle>] -> bytes(alias oftask.join.bytes)["task.try_join.bytes", <bytes task handle>] -> result_bytes(err=1not finished;err=2canceled)["task.join.bytes", <bytes task handle>] -> bytes["task.try_join.result_bytes", <result_bytes task handle>] -> result_result_bytes(err=1not finished;err=2canceled)["task.join.result_bytes", <result_bytes task handle>] -> result_bytes["task.is_finished", task_handle] -> i32(0/1)["task.cancel", task_handle] -> i32["task.yield"] -> i32["task.sleep", ticks_i32] -> i32(virtual time ticks)
Note: await / task.join.* — and likewise task.scope_v1 and all
task.scope.* ops — are only allowed in solve and inside defasync bodies,
not inside a plain defn. This is enforced by the compiler. See
Concurrency and certification
for why this shapes the trust story.
Channels (bytes payloads)
["chan.bytes.new", cap_i32] -> i32["chan.bytes.try_send", chan_handle, bytes_view] -> i32(0 full; 1 sent; 2 closed)["chan.bytes.send", chan_handle, bytes] -> i32["chan.bytes.try_recv", chan_handle] -> result_bytes(err=1empty;err=2closed)["chan.bytes.recv", chan_handle] -> bytes["chan.bytes.close", chan_handle] -> i32
Structured concurrency (task.scope_v1)
task.scope_v1 is a structured concurrency scope (“nursery”) that guarantees: tasks started inside it cannot outlive it.
Shape:
["task.scope_v1",
["task.scope.cfg_v1",
["max_children", <u32>],
["max_ticks", <u64>],
["max_blocked_waits", <u64>],
["max_join_polls", <u64>],
["max_slot_result_bytes", <u32>]
],
<body_expr>
]
Inside a scope:
["task.scope.start_soon_v1", <immediate_defasync_call_expr>] -> i32- The call expression must be an immediate
defasynccall (compile-time enforced). - The child task handle is not returned (prevents orphan-task patterns).
- The call expression must be an immediate
["task.scope.cancel_all_v1"] -> i32(cancels all children; deterministic order)["task.scope.wait_all_v1"] -> i32(joins+drops all children so far; keeps scope open)
Scoped slots (async_let)
Slots are scope-owned handles for child task results. They are not raw task handles, and they must not escape task.scope_v1.
["task.scope.async_let_bytes_v1", <immediate_defasync_call_expr>] -> i32(slot id)["task.scope.async_let_result_bytes_v1", <immediate_defasync_call_expr>] -> i32(slot id)["task.scope.await_slot_bytes_v1", slot_id] -> bytes["task.scope.await_slot_result_bytes_v1", slot_id] -> result_bytes["task.scope.try_await_slot.bytes_v1", slot_id] -> result_bytes(err=1not ready;err=2canceled)["task.scope.try_await_slot.result_bytes_v1", slot_id] -> result_result_bytes(err=1not ready;err=2canceled)["task.scope.slot_is_finished_v1", slot_id] -> i32(0/1)
Static vs dynamic fan-out
There are two ways to fan work out across child tasks, and they differ in whether the number of children is known at authoring time:
-
Static arity (named slots). Use
task.scope.async_let_bytes_v1(or…_result_bytes_v1) to start a fixed, small set of children, thentask.scope.await_slot_bytes_v1each slot by id. The slot ids are distinct named handles, so the child count is fixed in the source. Reach for this when you know exactly how many subtasks there are (e.g. two halves, three stages). Seedocs/examples/14_task_scope_slots.x07.json. -
Dynamic arity (channels). Use
task.scope.start_soon_v1in a loop to start a runtime-determined number of children, and a bytes channel (chan.bytes.new/chan.bytes.send/chan.bytes.recv) to collect their results.start_soon_v1does not return a handle (no per-child slot), so the worker count can be a runtime value. Reach for this for map/reduce-style work where the number of workers (or chunks) is computed. Seedocs/examples/12_async_mapreduce.x07.json.
Both run under the same task.scope_v1 nursery and the same deterministic
scheduler, so both are reproducible.
Scoped select (task.scope.select_*_v1)
Select waits for one of several scope-owned events deterministically:
["task.scope.select_v1", <cfg_v1>, <cases_v1>] -> i32(select evt id)["task.scope.select_try_v1", <cfg_v1>, <cases_v1>] -> option_i32(optional select evt id)
["task.scope.select.cfg_v1",
["max_cases", <u32>],
["policy", "priority_v1" | "rr_v1"],
["poll_sleep_ticks", <u32>],
["max_polls", <u32>],
["timeout_ticks", <u32>]
]
["task.scope.select.cases_v1",
["task.scope.select.case_slot_bytes_v1", slot_id],
["task.scope.select.case_chan_recv_bytes_v1", <i32_chan_handle>]
]
The returned select event handle (evt_id: i32) is scope-owned:
["task.select_evt.tag_v1", evt_id] -> i32["task.select_evt.case_index_v1", evt_id] -> i32(0-based index incases_v1)["task.select_evt.src_id_v1", evt_id] -> i32(slot id or chan id)["task.select_evt.take_bytes_v1", evt_id] -> bytes(moves payload bytes; only valid for “bytes-ready” events)["task.select_evt.drop_v1", evt_id] -> i32(drops payload, if any)
Event tags (stable):
1: slot bytes ready (payload = bytes)2: slot canceled (no payload)3: chan recv bytes ready (payload = bytes)4: chan closed (no payload)5: timeout (no payload)
Concurrency and certification: kernel and shell
task.scope_v1, every task.scope.* op, and await / task.join.* are
allowed only in solve and inside defasync bodies — never in a plain
defn. The compiler enforces this (x07 doc --builtin task.scope_v1 states it
directly: "Only in solve/defasync contexts").
This has a direct consequence for the trust story. XTAL certifies a defn
named by the project's operational_entry_symbol, and a certifiable pure entry
runs under a restricted language subset with allow_defasync: false. So a
concurrent program's orchestration cannot itself be the certified entry —
the orchestration lives in solve (or in defasync helpers), which the pure
certificate deliberately excludes.
The canonical resolution is the kernel/shell split:
- Kernel — the deterministic, pure logic, written as a
defnin a module and named byoperational_entry_symbol. This is what XTAL certifies (withrequires/ensurescontracts and proof objects). It contains notask.scope_v1and nodefasync. - Shell — the
task.scope_v1orchestration in thesolvebody (plus anydefasyncworkers). The shell calls the kernel and is validated against it by golden end-to-end examples.
The two produce byte-identical output: the concurrent shell exists only to
schedule the work; the kernel decides the result. You certify the kernel and
test the shell. See the worked
docs/examples/verified_core_pure_v1/ project (a pure defn entry under
world: solve-pure) and the
Kernel/shell in production guide.
A practical corollary, combined with generics: a generic numeric
reduction is not expressible (no generic arithmetic), so a parallel map/reduce
keeps its per-chunk reducer monomorphic (concrete i32 / u32) — exactly
what docs/examples/12_async_mapreduce.x07.json does.
OS threads (policy-gated)
In OS worlds, X07 can use threads for blocking and I/O-heavy work. In run-os-sandboxed, this is gated by policy.
The threads policy section controls thread-backed blocking operations. Setting:
threads.max_blocking = 0
disables blocking operations and produces a stable trap:
os.threads.blocking disabled by policy
OS multiprocessing (multi-core)
In OS worlds, X07 can spawn subprocesses:
- the OS schedules them across cores
- policies limit what can be spawned, how many, and resource bounds
In run-os-sandboxed, process spawning must be explicitly enabled by policy. If your program needs to spawn helper processes for parallel work, start from:
x07 policy init --template worker-parallel
If you don’t need process spawning, prefer the stricter worker template.
This provides a safe default path to “real parallel work” without bringing nondeterminism into the deterministic test substrate.
Guideline:
- Keep core logic testable in fixture worlds.
- Use subprocess adapters at the edge for CPU-parallel work.