Concepts

Concurrency

Threads, channels and shared state, with the sharing rules checked before you run. Every value that crosses a thread boundary must be Send; every value shared by reference must also be Sync — both declared with one annotation and validated by the compiler.

@:derive([Send, Sync]) class SharedData { public var counter:Int; public function new() { counter = 0; } }

Threads

Lightweight and goroutine-shaped: spawn a closure, get a handle back, join it for the result. Every variable the closure captures must be Send, and the closure itself is moved into the new thread.

var msg = new Message("hello"); var handle = Thread.spawn(() -> { trace(msg.data); return 42; }); var result = handle.join(); // 42 handle.isFinished(); // non-blocking check

Parker exposes the primitive underneath — register for park/unpark, block, and wake by id. An unpark issued before the park makes the next park return immediately, so there is no lost-wakeup window.

Channels

Multi-producer, multi-consumer. Capacity zero is unbounded and never blocks the sender; a positive capacity is bounded and blocks when full. trySend and tryReceive are the non-blocking pair.

var ch = new Channel<Message>(10); // bounded Thread.spawn(() -> ch.send(new Message(42))); var msg = ch.receive(); // blocks var maybe = ch.tryReceive(); // null if empty

Select

Non-deterministic receive across several channels, Go-shaped. Select.recv blocks until any channel yields; Select.tryRecv polls once and reports index == -1 when nothing was ready.

var r = Select.recv([ch1, ch2]); if (r.index == 1) trace(r.value.v); // 42

A closed, empty channel yields its own slot index with a null value — the same "zero value on a closed receive" convention, which is how you detect closure inside a select arm. Channels in one call share an element type; use Channel<Dynamic> for heterogeneous selects.

Shared state

Arc gives shared ownership across threads — cloning bumps the refcount rather than copying — and pairs with Mutex when the shared value is also mutable.

var counter = new Arc(new Mutex(new Counter())); var handle = counter.clone(); // refcount bump Thread.spawn(() -> { var guard = handle.get().lock(); guard.get().value += 1; guard.unlock(); });

The inner type must be Send + Sync to cross a thread boundary at all — the compiler rejects the spawn otherwise, rather than leaving it to a data race at runtime. Atomic covers the cases where a lock is more than you need.

Data-parallel work

WorkerPool is the entry point for anything that iterates a large index range — matmul rows, conv tiles, attention heads, elementwise sweeps.

var pool = WorkerPool.global(); pool.parallelFor(1000000, (idx, node) -> { // one worker per NUMA node when nodeCount > 1 });
Multi-node One worker spawned per NUMA node, each pinned before the closure runs
Single-node No fanout — runs inline on the calling thread. withForcedNodes(N) fans out anyway
Small work Fewer items than twice the node count also runs inline; the fanout would cost more than it saves

SpinPool, for kernels called hundreds of times

Spawning and joining OS threads per call dominates when a kernel runs hundreds of times per token. A SpinPool spawns its workers once and re-dispatches through a lock-free protocol, so a dispatch costs a few atomic stores.

Work distribution is chunk-stealing rather than static bands: workers claim row ranges from a shared atomic cursor until the range is exhausted. Static bands lose the join to the slowest core — an E-core band runs three to four times longer than a P-core band — while stealing self-balances heterogeneous cores without knowing the topology.

Results stay bit-identical. Each row is computed by exactly one worker with an unchanged per-row reduction order, so a parallel run matches the serial loop exactly.

Topology & affinity

CpuTopology is the low-level primitive WorkerPool is built on, exposed for callers who need fine-grained pinning. One topology per process, queried lazily on first call.

Multi-node servers

On multi-socket Linux and Windows, one worker is pinned per NUMA node so allocations land first-touch on that node's memory controller.

UMA hardware and wasm

Node count is 1, every CPU maps to node 0, and binding succeeds as a soft affinity hint. Work runs inline unless you force fanout.

The concurrent package

Thread<T> spawn a closure, join for its result, isFinished for a non-blocking check
Channel<T> MPMC queue — send, receive, trySend, tryReceive, close. Unbounded at capacity 0
Select recv and tryRecv across an array of channels, returning (index, value)
Mutex<T> Exclusive access to the inner value; lock returns a guard, unlock releases it
Arc<T> Atomic refcounted shared ownership; clone bumps the count, not the value
Future<T> Lazy — nothing runs until await() blocks or then(cb) resolves on a worker
WorkerPool parallelFor and parallelRows over an index range, NUMA-pinned where it matters
SpinPool Persistent workers with chunk-stealing dispatch, for kernels called hundreds of times
Parker registerParkable, park, unpark — the wake primitive, with no lost-wakeup window
CpuTopology multiNode, nodeCount, cpu count and bindToNode for explicit affinity
How the runtime backs this

Drop behavior for runtime-managed types, the closure ABI, and the tier ladder.

Architecture →