Concurrency And Parallelism
This page is the conceptual map of how Zynx runs work concurrently: the async model, structured concurrency for parallel work, message passing between tasks, and the shared-memory primitives. For API detail, follow the links into the per-module references — std.async, std.channel, std.atomic, and std.mutex. For the language-level async/await contract, see Async Semantics.
The Runtime Model
Zynx async is cooperative: an async function is lowered to a state machine that suspends at each await and resumes later. Work is driven by a pool of worker threads (by default one per core, minus one, overridable with ZYNX_TASK_WORKERS). Non-blocking socket and timer I/O is driven by an event reactor. std.async.fs uses native completion I/O when available and a lazily created bounded blocking pool otherwise. The same pool is available explicitly through async.blocking for one deliberately coarse blocking closure; ordinary std.fs calls instead block their calling OS thread.
The scheduler's internal strategy (queue layout, how tasks map to workers, the poll backend) is not a stability contract and may change — the same stance std.async takes. What is guaranteed and worth relying on is the cooperative model: a task only suspends at an await, and the borrow checker enforces the rules around suspension points (for example, a mutable borrow of a frame-local value may be held across an await, because the suspended frame is private to its execution and nothing else can reach it).
Cancellation never licenses detached blocking work. If a host operation or async.blocking closure has started and cannot be interrupted, the owning future and structured scope retain its buffers, loans, handles, and cleanup until the operation reaches true completion. A deadline may therefore be observed before async.timeout can safely return.
Async And Await
Future-producing functions return an owned ^Future<T>. await drives a future to completion and yields its value. Plain Future is not transferable; ^Future<T> where Send is the explicit static identity for a cold frame that may move to one other thread. See std.async for the runtime future API and Async Semantics for the language contract. Zynx has no public unstructured "spawn a background task" API — concurrency is expressed through the structured scopes below.
Structured Concurrency
The way to run concurrent work is the RFC 0009 structured scope: await with async.Scope.local() runs children cooperatively on the current worker, while await with async.Scope.parallel() may use the worker pool. The closing brace cancel-and-joins every child before it returns: a child cannot outlive the scope. This complete await with form is one language construct; ordinary with expr as name { ... } does not exist.
import std.io;
import std.async;
import std.mem.{ AllocError };
fn work(n: i32) -> ^async.Future<i32> where Send {
_ = await async.reschedule();
return n * n;
}
fn main() throws(AllocError) {
var total = 0;
await with async.Scope.parallel() as tasks {
let two = tasks.start(work(2));
let three = tasks.start(work(3));
total = await <-two;
total = total + await <-three;
}
// The scope has joined both children here.
assert total == 13;
io.println(try ^"{total}");
}Because the scope joins, children finish (or are cancelled) before their inputs go out of scope, so structured concurrency composes with the ownership and borrow rules. See std.async for Scope, scoped Join, cancellation, and result composition.
Scope.local.start accepts qualified or unqualified Futures. Scope.parallel.start consumes only a Future<T throws(E)> where Send and also requires the success value and every checked-error payload Send so its Join can return either outcome across the worker boundary. The Future qualifier proves the frame, not those completion payloads.
Message Passing
Channels move ownership of values between tasks instead of sharing state. std.channel.bounded<T> returns a Sender<T>/Receiver<T> pair; a sender hands a value to a receiver, transferring ownership. Channel endpoints are themselves safe to move across worker threads, which makes channels the primary way to communicate between concurrent tasks in this release. See std.channel.
Channel<void> is the ordinary event-only instance: send(void()) sends one logical zero-byte message and receive returns Recv.Value(void()). Capacity counts events rather than bytes, so backpressure and capacity-zero rendezvous behave exactly like other channels. Recv.Closed is a separate state; events are never coalesced into close or handled by a special signaling API.
Shared Memory
For the shared-memory primitives:
std.atomic—Atomic<T>provides lock-free single-value operations over the native scalar set (and smallAtomicRepresentableaggregates), together with theMemoryOrdermodel (the five orderings, synchronizes-with/happens-before, data-race-is-UB). Concurrent atomic operations are race-free by construction.std.mutex—Mutex<T>owns a value and grants exclusive access, either scoped (with/read) or through an RAII guard (lock/get).ThreadLocal<T>— a copyable key whose payload is lazily constructed and isolated independently on each OS thread. Access is callback-scoped through synchronousread/update. This is a semantic requirement: a task may resume fromawaiton a different OS thread, while a TLS borrow must begin and end on the same one.
Both cells are move-only and single-owner: this release does not provide a way to share one atomic or mutex among several concurrent tasks (sharing by shared borrow, or an Arc<Mutex<T>>-style multi-owner handle, is future work). So today these types serve interior mutability and the memory-ordering model rather than multi-task sharing; for communication between tasks, use a channel.
Unsynchronized mutable module globals are rejected in safe code precisely because they would be shared mutable state reachable from any worker.
ThreadLocal<T> is not shared mutable memory. The descriptor may cross a worker boundary, but each worker accesses its own payload. Because async tasks may resume on another worker, TLS is for OS-thread state rather than task identity.
Crossing Thread Boundaries
Work offloaded to a worker thread — a Scope.parallel child — may only carry values that are safe to move across the boundary. The rule is ownership-based: move a self-contained owned value, not a borrow of state someone else still owns.
- May cross: owned values built from send-safe parts (scalars and owned scalar aggregates), channel
Sender/Receiver, network handles, andCapsule<T>(an isolated owner), plus the copyableThreadLocal<T>key regardless of its thread-confined payload type. An erased callback may cross only when its static identity retains the proof, for example^(Job) -> void where Send. A cold Future may cross only as^Future<T> where Sendand only when no checked region can escape the destination boundary. - May not cross: references and raw pointers (they alias state another thread owns), scoped Join handles, unqualified Futures, and any type carrying a user
drop(other than the channel and network handles recognized as synchronization endpoints) — aMutex<T>, which has adrop, is in this last group. The synchronized cells are also not yet on the send-safe list, so anAtomic<T>does not cross a worker boundary in this release either. Unqualified erased callable values also cannot cross: erasing withoutwhere Sendpermanently forgot the concrete capture proof.
The compiler enforces this at each parallel start. The practical way to hand data to a Scope.parallel child is to move an owned, send-safe value in, or to communicate through a channel.
Future portability is verified at its producer over captures, suspend-live locals, cleanup, nested awaits, and runtime frame state. It cannot be inferred or recovered at start. A region-bound Future<T> in L where Send still carries its loan: parallel start is legal only when Scope close joins within L, while a channel rejects storage that may escape L.
The callable receiver (self, &self, or ^self) controls invocation access, not thread capability. where Send is a separate static type qualification and has no runtime flag. It also creates no loan: a closure that explicitly captures a checked reference still needs its named region and a structured scope proving that the child joins before the loan ends.
Choosing An Approach
- Need results from a bounded fan-out? Use
Scope.parallel. - Passing data between tasks? Use a channel — the cross-task mechanism this release supports.
- Need lock-free operations or the memory-ordering model within a task (or in preparation for shared-memory concurrency)? Use an
Atomic. - Need mutual exclusion around a larger value? Use a
Mutex(single-owner in this release). - Need mutable state isolated per OS thread? Use
ThreadLocal<T>.
Related Pages
std.async— futures,Scope, and scopedJoin.std.channel— message-passing channels.std.atomic— lock-free atomics and the memory model.std.mutex— mutual exclusion.ThreadLocal<T>— lazy per-OS-thread state.- Async Semantics — the language-level
async/awaitcontract.