Documentation pages
Concurrency
SultanC follows Rust's model of threads, atomics and shared state (std::thread, std::sync,
std::sync::atomic, std::sync::mpsc) and takes Go's runtime as the reference for the OS layer
(thread start, futex and semaphore). The design is adapted to SultanC's ownership system, which
has no traits and runs no user destructors. This page describes each primitive, the OS layer
underneath, the compile-time check on what a thread may capture, and the current limits. It is
written for programmers who share data between threads and for those who work on std.sync.
Modules
| Module | Rust / Go | Contents |
|---|---|---|
std.sync.atomic |
std::sync::atomic; sync/atomic |
atomic integers and memory orderings |
std.thread.thread |
std::thread |
spawn, join, values from threads, sleep, yield |
std.os.wait_address |
sys::futex; runtime.futex, sema |
sleep while a 32-bit cell holds a value; wake one or all |
std.sync.arc |
Arc |
shared ownership with an atomic count |
std.sync.mutex |
Mutex; sync.Mutex |
three-state futex mutex, closure-scoped |
std.sync.rwlock |
RwLock; sync.RWMutex |
readers or one writer |
std.sync.condvar |
Condvar; sync.Cond |
wait and notify on a sequence word |
std.sync.once |
Once, OnceLock; sync.Once |
one-time initialization |
std.sync.channel |
mpsc; Go channels |
unbounded and bounded channels |
std.os.poll |
mio::Poll; runtime.netpoll |
one-shot readiness polling of many non-blocking descriptors |
A first program
A counter behind a mutex, shared through an Arc, incremented by four threads. Each thread owns
a clone of the Arc; the lock is held only inside the closure given to with_lock.
import std.sync.arc;
import std.sync.mutex;
import std.thread.thread;
import std.fmt.fmt;
def bump(v: &mutable i64): void { *v := *v + 1; }
def read(v: &mutable i64): i64 { return *v; }
def add_thousand(m: &arc<mutex<i64>>): void {
let i: mutable i64 = 0;
while (i < 1000) {
with_lock(arc_get(m), fn(v: &mutable i64): void -> bump(v));
i := i + 1;
}
}
def main(): i32 {
let counter: arc<mutex<i64>> = arc_new(mutex_new(0 as i64));
let handles: mutable vector join_handle = [];
let t: mutable i32 = 0;
while (t < 4) {
let mine: arc<mutex<i64>> = arc_clone(&counter);
match spawn(fn(): void -> add_thousand(&mine)) with
| result.ok(h) -> { append(handles, h); }
| result.err(_) -> { return 1; };
t := t + 1;
}
let j: mutable uint = 0;
while (j < (length(handles) as uint)) {
join(&mutable handles[j]);
j := j + 1;
}
let total: i64 = with_lock(arc_get(&counter), fn(v: &mutable i64): i64 -> read(v));
let r: result uint, fmt_error = println_fmt("total = {}", [fmt_arg.i64(total)]);
return 0;
}
The program prints the total, 4000.
Atomics
std.sync.atomic provides atomic integers (atomic_u64, atomic_u32,
atomic_usize) with load, store, swap, compare-exchange, fetch-add and a memory
fence. atomic_u64 also has fetch-and and fetch-or, and has pointer forms that take a *mutable
cell. Orderings follow Rust's Ordering:
| English | Arabic |
|---|---|
memory_ordering.relaxed |
ترتيب_ذاكرة.مسترخ |
memory_ordering.release |
ترتيب_ذاكرة.إطلاق |
memory_ordering.acquire |
ترتيب_ذاكرة.اكتساب |
memory_ordering.acq_rel |
ترتيب_ذاكرة.اكتساب_وإطلاق |
memory_ordering.seq_cst |
ترتيب_ذاكرة.تسلسلي |
The operations are compiler intrinsics: بدائي_ذري_حمل, بدائي_ذري_خزن, بدائي_ذري_تبديل,
بدائي_ذري_قارن_وبدل, بدائي_ذري_جلب_جمع, the bitwise بدائي_ذري_جلب_و, بدائي_ذري_جلب_أو and
بدائي_ذري_جلب_أو_حصري (fetch-xor), and بدائي_سياج_ذاكرة (fence). Unlike every other operation,
they read and write the integer that a shared reference refers to. This is the one sound point of
interior mutability, as in Rust, where atomics take &self. The frontend accepts a shared & to a
32-bit, 64-bit or pointer-sized integer as the first operand.
Every backend lowers the intrinsics to the architecture's atomic instructions at
sequentially-consistent strength. The requested ordering is carried through, but the backends treat
every ordering as seq_cst, which is always sound.
| Backend | Lowering |
|---|---|
| x86_64 (native and LLVM) | LOCK XADD, XCHG, LOCK CMPXCHG (and/or/xor as a CMPXCHG loop), MFENCE |
| arm64 (native and LLVM) | macOS: the ARMv8.1 LSE forms LDADDAL, SWPAL, LDCLRAL, LDSETAL, LDEORAL, CASAL; Linux, Android, FreeBSD and Windows: ARMv8.0 LDAXR/STLXR loops (with CLREX when a compare-and-swap fails), so the code runs on ARMv8.0 cores; everywhere LDAR, STLR and DMB ISH |
| wasm32 | plain linear-memory loads and stores: a wasm32 module runs one thread (WASI threads are not used); fence is a no-op |
| LLVM | atomicrmw, cmpxchg and fence at seq_cst |
| interpreter | one OS thread, so an atomic is the plain read-modify-write of the cell its reference names; the write goes through even when the reference is shared (compiler/interpreter/execution/primitives.sn) |
OS threads
std.thread provides:
| English | Arabic | Rust |
|---|---|---|
spawn |
أطلق |
thread::spawn: move a closure to a new thread, return a join handle |
join |
انضم |
JoinHandle::join |
spawn_value, join_value |
أطلق_بقيمة, انضم_بقيمة |
a thread whose closure returns a value, which join_value answers as an option |
sleep |
نم |
thread::sleep (seconds and nanoseconds) |
yield_now |
تنازل |
thread::yield_now |
current_id, join_handle_id |
معرف_الخيط_الحالي, معرف_المقبض |
the OS thread id |
available_parallelism |
عدد_المعالجات_المتاحة |
thread::available_parallelism |
spawn returns a result whose error is the platform's failure to start a thread.
Platform support
std.os.threads has one guarded branch per OS. Each OS starts a thread with the mechanism its own
runtime uses.
| OS | Start | Join |
|---|---|---|
| Linux, Android (every architecture) | clone onto a stack std maps, on raw system calls with no libc |
futex wait on the tid cell the kernel clears at exit |
| FreeBSD | thr_new onto a stack std maps |
_umtx_op wait on thr_exit's state word |
| macOS | pthread_create |
pthread_join |
| Windows | CreateThread |
WaitForSingleObject, GetExitCodeThread, CloseHandle |
| WASI, freestanding, the interpreter | not supported: spawn returns an error |
The Linux thread layer
The Linux layer (library/std/os/platform/unix/linux/threads.sn) starts a thread the way Go's
runtime and musl do. A thread gets a 2 MiB mmap'd stack with a PROT_NONE guard page, and a
clone with CLONE_VM|FS|FILES|SIGHAND|THREAD|SYSVSEM|PARENT_SETTID|CHILD_CLEARTID. The clone is
the compiler intrinsic بدائي_استنسخ_خيط_نواة: its child runs the closure on the new stack and
exits the thread. join waits on the tid cell that the kernel clears when the thread exits.
Two details differ between architectures. The tid cell goes into both of clone's last two argument
slots, because they are (child_tid, tls) on x86_64 and (tls, child_tid) on the asm-generic
architectures (aarch64, riscv64, loongarch64); the tls slot is ignored without CLONE_SETTLS.
join tests the whole word, because on big-endian machines (s390x, sparc64) the kernel's 32-bit
tid sits in the high half.
The stack mapping is shared with the FreeBSD layer: mmap, mprotect and munmap take the same
arguments there, and the flags come from the selected kernel's UAPI.
Ownership of the closure
A thread owns its closure, as Rust's FnOnce is moved into a thread. The closure goes into a heap
cell that the thread's entry closure owns. The entry swaps the closure out, runs it and drops it
when it returns, so whatever the closure captured (an Arc clone, a channel's sender) is released
when the thread finishes, not when or whether it is joined. join returns the entry closure's last
reference, rebuilt from its address with بدائي_دالة_من_عنوان, which frees the entry and its cell.
A detached thread leaks only the entry (two small blocks).
Results and panics
A closure given to spawn returns no value through join; spawn_value and join_value hand
a value back through shared state. A panic in any thread ends the whole process
(panic=abort), so join never observes a thread error and no lock can be poisoned.
Waiting and waking
std.os.wait_address is the one blocking primitive that every std.sync type is built on, after
Rust's sys/sync/futex and Go's runtime/sema.
| Call | Meaning |
|---|---|
wait_on_address(&atomic_u32, expected, timeout) |
sleep while the cell holds expected; answers false only when the relative timeout (nanoseconds, or none) passed |
wake_by_address_single, wake_by_address_all |
wake one or every sleeper on the cell |
wait_on_cell, wake_one_on_cell |
the same on a bare 32-bit cell, such as a scalar module variable used as a lock word |
wait_on_address_supported |
whether the target has a blocking wait |
The check of the value and the sleep are one kernel step, so a wake between a caller's load and its wait is never lost. A wait may return spuriously, so callers loop on their condition.
| OS | Wait | Wake | Notes |
|---|---|---|---|
| Linux, Android | futex(FUTEX_WAIT_PRIVATE) |
futex(FUTEX_WAKE_PRIVATE) |
timed waits use futex_time64 on i686 and armv7 (مستقبل_بمهلة), so the timespec is 64-bit everywhere; ETIMEDOUT is 110 (mips 145, sparc 60) |
| FreeBSD | _umtx_op(UMTX_OP_WAIT_UINT_PRIVATE) |
_umtx_op(UMTX_OP_WAKE_PRIVATE) |
the timeout is a relative struct timespec in uaddr2 (مستقبل_بمهلة_بي_إس_دي) |
| macOS | __ulock_wait2(UL_COMPARE_AND_WAIT | ULF_NO_ERRNO) |
__ulock_wake (ULF_WAKE_ALL) |
what Rust uses before macOS 14.4; os_sync_wait_on_address is not in the 11.3 SDK. Imported from libSystem and checked by tools/audit/macos_imports_audit.py |
| Windows | WaitOnAddress |
WakeByAddressSingle / WakeByAddressAll |
api-ms-win-core-synch-l1-2-0.dll, Rust's synchronization import; ERROR_TIMEOUT is the timeout |
| WASI, freestanding | returns at once (a timed wait reports the timeout) | nothing | one thread: nobody could change the cell |
The kernel calls carry memory contracts (توقيع_عملية_نواة_لينكس: the 4-byte cell, the 16-byte
timespec), as does every std kernel call that takes a pointer.
Shared ownership: Arc
arc<T> is Rust's Arc<T>: one heap value, any number of handles, an atomic
reference count, and the value dropped exactly once with the last handle.
SultanC runs no user destructors, so the count and the drop belong to a closure environment. A
handle holds one closure whose environment owns the boxed value (a move capture). The
environment's reference count is the strong count: closures count atomically (LOCK XADD,
LDADDAL or an LDAXR/STLXR loop, LLVM atomicrmw), copying a handle adds a reference, dropping one removes it, and the
environment's destructor drops the box with the last reference. The closure answers the boxed
value's address.
| English | Arabic | Rust |
|---|---|---|
arc_new |
أنشئ_مشترك |
Arc::new |
arc_clone |
استنسخ_مشترك |
Arc::clone; copying a handle is the same clone, as closures are copyable values |
arc_get |
اقرأ_مشترك |
Deref: a shared reference |
arc_strong_count |
عدد_مراجع_مشترك |
Arc::strong_count; reads the environment's count slot |
arc_ptr_eq |
هل_المشترك_نفسه |
Arc::ptr_eq |
arc_as_ptr |
مؤشر_مشترك |
Arc::as_ptr |
The value is reached only through a shared reference. Mutation across threads goes through a
mutex, an rwlock or an atomic inside the Arc. In the interpreter arc_strong_count is
unavailable: it reads the count slot through the closure's address, and the interpreter's closure
addresses are handles, not memory.
Mutex
mutex<T> is Rust's futex mutex (sys/sync/mutex/futex.rs, after Ulrich Drepper's
"Futexes Are Tricky"). The state is 0 unlocked, 1 locked, or 2 locked with waiters. An uncontended
lock is one compare-exchange and an uncontended unlock one swap, with no system call. A contended
lock spins 100 rounds, marks the state 2 and sleeps; an unlock that finds 2 wakes one sleeper.
The value is reached only inside a closure that runs with the lock held:
with_lock(&m, fn(&mutable T): R): R, or try_lock, which answers
none when another thread holds the lock. This is Rust's MutexGuard scoped to a call, because
SultanC runs nothing at scope exit: the unlock cannot be forgotten and no reference to the value
outlives the lock. mutex_is_locked reports the state.
There is no poisoning, because a panic ends the process. A mutex moves but is never copied (a
never-allocated vector marker makes it an owned value), and it is shared through an Arc
(arc<mutex<T>>), as in Rust.
RwLock
rwlock<T> is one futex word: 0 when free, n for n readers, or 0xFFFFFFFF
for a writer. Access is through with_read and with_write. It
is simpler than Rust's futex RwLock: there is no writer preference, so a steady stream of readers
can delay a writer.
Condvar
condvar is Rust's futex condvar: a 32-bit sequence number. condvar_wait(&c, &m)
reads the number, releases the mutex, sleeps while the number is unchanged and
takes the mutex again. A notify increments the number and wakes one sleeper (condvar_notify_one) or all
(condvar_notify_all).
A wait is called with the mutex held, inside the closure of with_lock on the same mutex. The
closure's reference to the value stays valid, because the lock is held again when the wait
returns. condvar_wait_timeout answers whether the wait timed out;
condvar_wait_while loops on a condition over the guarded value.
Once and OnceLock
once is Rust's futex Once: 0 incomplete, 1 running, 2 running with sleepers, 3
complete. call_once runs its closure exactly once however many threads race, and the
others sleep until it completes. A completed Once costs one acquire load.
once_lock<T> stores its value in a vector of at most one element behind an
Arc. An element is a place, so once_lock_get and
once_lock_get_or_init lend it in place, and the vector never grows after the one append, so its
address stays put. once_lock_set refuses a second value.
Channels
channel<T>() and sync_channel<T>(bound) return both ends
(channel_ends), which a pattern takes apart:
| { المرسل: tx, المستلم: rx } -> … (the two fields keep their Arabic names). channel_receiver and
sender_for make the ends one at a time. A channel has many senders (sender_clone) and one
receiver, and delivers in FIFO order per sender.
| Operation | English | Arabic |
|---|---|---|
| Send, waiting for room in a bounded channel | send |
أرسل |
| Send without waiting | try_send |
حاول_الإرسال |
| Receive, waiting | recv |
استقبل |
| Receive without waiting | try_recv |
حاول_الاستقبال |
| Receive with a timeout | recv_timeout |
استقبل_بمهلة |
| Drop an end explicitly | drop_sender, drop_receiver |
أسقط_مرسل, أسقط_مستلم |
send hands the value back as the error once the receiver is gone. try_send reports full or
disconnected, with the value. recv fails once every sender is gone and nothing
is queued. try_recv and recv_timeout report empty, timeout or disconnected, using the error
types of Rust's mpsc (try_recv_error, recv_timeout_error, recv_error,
try_send_error).
Implementation
The shared state sits behind an Arc. It is a queue of two vectors: values are appended to an
inbox and popped from an outbox that is refilled from the inbox, so every value is moved and never
copied. The queue is guarded by the three-state lock and accompanied by two futex sequence words,
"a value is available" and "a slot is free".
Disconnection without destructors
The channel counts its ends with two shared tokens (Arcs). Each token is held by the shared
state and by every sender (or the receiver), so the number of live senders is the sender token's
strong count minus the state's own. An end passed to drop_sender or drop_receiver is dropped there
and the other side is woken at once. An end dropped implicitly, by a thread that finishes or a
scope that ends, is noticed by a waiting thread within a few milliseconds, because the waiter
re-checks the counts between bounded sleeps (1 ms doubling to 32 ms). Ends are not copyable, so a
spawned closure moves the end it captures.
A bound of 0 holds one value: Rust's rendezvous channel is not implemented.
Data-race freedom: the spawn capture check
A closure handed to another thread may capture only what may cross threads, which is Rust's
F: Send + 'static. The compiler rejects a closure that captures:
- a raw pointer, unless the spawn sits inside
unsafe; - a borrowed reference or slice;
- a writable module variable that its body uses outside an unsafe block.
Each case has its own diagnostic, in Arabic and English: SULTANC-THREAD-CAPTURE-RAW-POINTER,
SULTANC-THREAD-CAPTURE-REFERENCE and SULTANC-THREAD-CAPTURE-NON-ATOMIC-SHARED. The calls that
send a closure are derived from the closure-address intrinsics, not listed by name. The rules, the
derivation and their limits are in Type system.
The thread intrinsics themselves stay behind unsafe: بدائي_استنسخ_خيط_نواة runs code on a
caller-supplied stack and بدائي_دالة_من_عنوان rebuilds a closure from an address. Leaking a box
(بدائي_سرب_قيمة) is also an unsafe intrinsic.
Process-wide state
Two process-wide tables are made safe for threads.
- The allocator (
library/std/os/allocation.sn) serves every thread. A flag set by the first spawn turns on a lock around allocate, reallocate and free; a program that never spawns pays one branch.هل_بدأت_خيوطexposes the flag. The lock is the Mutex's three-state futex protocol on a 32-bit module word: one compare-exchange when free, at most 100 spins when held, then a sleep in the kernel (os.platform.*.wait_address, the untimed calls, which allocate nothing), so a waiting thread uses no CPU. Every thread still shares the one lock; per-thread caches would remove it from most allocations. - The environment table (
std.env.table; Rust'sENV_LOCK, Go'senvLock) takes one process-wide lock for every read and write once a thread exists, using the three-state protocol on a scalar module variable (wait_on_cell). Before the first thread, no lock is taken.
Readiness polling
std.os.poll (after Rust's mio and Go's netpoller) lets one thread serve many non-blocking
sockets: epoll on Linux and Android, kqueue on FreeBSD (system calls) and macOS (libSystem).
Windows and the other targets report poll_supported false; Windows needs IOCP, a completion
model.
| Call | mio / epoll |
|---|---|
poller_new |
Poll::new; epoll_create1(EPOLL_CLOEXEC), kqueue() |
poller_register |
Registry::register; EPOLL_CTL_ADD, EV_ADD |
poller_rearm |
Registry::reregister; EPOLL_CTL_MOD, EV_ADD |
poller_deregister |
Registry::deregister; EPOLL_CTL_DEL, EV_DELETE |
poller_wait |
Poll::poll; epoll_pwait, kevent |
poll_event_at |
Events::iter: token, readable, writable, hang-up or error |
Registrations are one-shot (EPOLLONESHOT, EV_ONESHOT): an event disarms its descriptor until it
is re-armed, so several threads can wait on one poller and each descriptor reaches one of them at a
time. Interest is reading or writing, never both, for the same reason (two kqueue filters could fire
on two threads). Each waiting thread owns its event buffer (poll_events_new), so a wait
does not allocate. is_would_block recognizes the would-block error of the target.
std.http.server's run_http_server_evented is built on it. Every worker thread (default: the
available parallelism) owns its poller and its connection table. With a balancing SO_REUSEPORT
(Linux, FreeBSD) each worker also binds its own listener (shard-per-core) and accepts from it until
it would block. Elsewhere (macOS) there is one listener, and a worker draining it would take a whole
burst of connections; so, as libuv's and tokio's acceptors, the calling thread only accepts and hands
each connection to the worker with the fewest open connections (ties in turn), into that worker's
own table and poller, where it stays. Workers never watch the listener, so a connection's events,
re-arms and table updates touch only its worker's poller and lock. A worker reads until the socket would block, runs the
handler for each complete HTTP/1 request, writes without blocking (unsent output waits for
writability), and re-arms the connection. Idle connections hold no thread. The state is shared as
arc<S>. An h2c connection leaves the poller for the blocking HTTP/2 server on its worker; HTTPS
still uses the sequential run_https_server. Without a poller the function runs
run_http_server.
Async tasks
std.async.runtime runs many tasks on one thread, as Go's goroutines on one P, Java's virtual
threads or Lua's coroutines. Tasks are stackful: each runs on a stack of its own (256 KiB
reserved, the lowest 64 KiB an inaccessible guard, pages committed by the system on first touch),
so code inside a task is ordinary code and no function is marked async. A call that has to wait
suspends only its task:
| Call | What it does |
|---|---|
spawn_task(f) |
starts f: () -> T as a task; answers a task<T> handle |
await_task(&mutable t) |
suspends until t has finished; its result once, then none |
block_on(f) |
spawns f and awaits it: the entry of an async program |
task_finished(&t) |
whether t has finished |
yield_task() |
lets the other ready tasks run first |
sleep_task(ns) |
suspends for at least ns nanoseconds |
wait_fd_ready(fd, interest) |
suspends until a descriptor is readable or writable |
tasks_supported() |
whether tasks switch on this target |
std.async.net gives the TCP calls of std.net.tcp on non-blocking sockets:
tcp_listen_async, tcp_accept_async, tcp_connect_async, tcp_read_async,
tcp_write_async, tcp_write_all_async, tcp_close_async and tcp_listener_close_async.
A call that would block suspends the task until the poller reports the socket ready, then retries.
import std.async.runtime;
def slow(n: int): int { sleep_task(40000000); return n; }
def main(): int {
let a: mutable task<int> = spawn_task(fn(): int -> slow(1));
let b: mutable task<int> = spawn_task(fn(): int -> slow(2));
// Both sleep at the same time: about 40 ms in all.
match await_task(&mutable a) with
| option.some(x) -> { match await_task(&mutable b) with
| option.some(y) -> { return x + y; }
| option.none -> { return -1; }; }
| option.none -> { return -1; };
}
The scheduler runs on the thread whose code waits from outside every task (the main code awaiting a task, or blocking on a socket through the same calls): while it waits, ready tasks run in turn, first in, first out, and when none is ready the thread sleeps in the poller (io_uring or epoll on Linux, kqueue on FreeBSD and macOS) until a socket is ready or the nearest timer is due. One scheduler serves the process; a task belongs to the thread that drives it. When every task waits and neither a socket nor a timer can wake one (a task awaiting itself, or a cycle of awaits), the program panics with a message instead of hanging.
A switch is a call to بدل_سياق_المهام_التشغيل, whose body the backends replace with fixed
machine code: it pushes the callee-saved registers (rbx, rbp, r12-r15 on x86_64; x19-x30 and
d8-d15 on arm64), stores the stack pointer, loads the other task's and pops its registers. The
first frame of a new task returns into a trampoline that calls the task's closure. The native x86_64
and arm64 encoders and the LLVM provider (as a naked function) emit it on System V x86_64 and AAPCS64
targets. The interpreter switches tasks too, by switching its frame stacks (see
The interpreter). Elsewhere (wasm32, Windows, other architectures) tasks_supported is
false and spawn_task runs the task to completion at once, so the same program still runs, one task
after another, and each wait blocks the thread.
A closure given to spawn_task is checked like a thread's (the spawn capture check): the task
outlives the caller's frame.
std.http.server's http_serve_async (شغل_خادم_ويب_غير_متزامن) serves every accepted
connection in a task of its own over the tcp_async transport, so an idle or slow client holds a
task stack instead of a thread, and HTTP/1 and h2c share one thread. It checks for a shutdown
request at least every 250 ms.
The interpreter
sultanc run executes on one OS thread. Atomics run as plain read-modify-writes, and mutex,
rwlock, condvar (notify), once, once-lock and Arc reads work and agree with native code;
tests/*/stdlib/behavior/sync_single_thread.sn is a differential pair. A thread cannot start
there, and spawn returns an error instead of stopping the program: the kernel clone answers
ENOSYS on Linux, and on macOS and Windows an interpreted closure has no C entry, which std checks
before it reserves anything. A caller with a fallback takes it: std's HTTP servers serve on the
calling thread, and the sharded event loop closes the SO_REUSEPORT listener and poller of a shard
whose thread did not start (the kernel would otherwise hand that listener connections nobody
accepts). A program that blocked SIGINT and SIGTERM for a watcher thread that did not start can
unblock them again with unblock_stop_signals (std.os.signal), so Ctrl+C stops it at once.
On Linux, async tasks switch under the interpreter as they do natively
(compiler/interpreter/execution/task_switch.sn). The interpreter owns its frames, so a task is one
more stack of frames: a call of the switch routine sets the running stack aside in a table, leaves
its token (a tagged index, never a mappable address) in the save word, and resumes the stack a
token names, or starts a new task from the entry closure std placed in the task's first frame. A
place in a suspended stack (a task's local another task borrows) resolves there. The
closure-address intrinsics answer tagged handles of the interpreter's closures, which is how std
keeps a task's entry; reading machine memory through such a handle (arc_strong_count, and the
channels, which count their ends that way) is refused rather than faulting. The poller is epoll:
io_uring shares rings the kernel maps into the process and updates by atomics, so under the
interpreter io_uring_setup answers ENOSYS and std.os.poll takes epoll. A task stack is machine
memory std maps, which the interpreter reaches only on Linux; on macOS and Windows the interpreter
answers that tasks do not switch, and spawn_task runs each task to completion at once, as on
wasm32.
tests/*/stdlib/behavior/async_task_switch.sn is a differential pair, and the async HTTP server
test runs under sultanc run as well. Timing assertions written for native speed (three 40 ms
sleeps within 100 ms) can miss in the interpreter, which is slower.
Verification
| Where | What runs |
|---|---|
| x86_64 Linux, native backend | every test below, Arabic and English |
| aarch64, riscv64, s390x Linux (LLVM, qemu-user) | the same tests |
| i686 and armv7 Linux (LLVM, qemu-user) | spawn, join and a mutex counter across threads |
| Windows x86_64 (Wine) | wait_address_single, sync_single_thread (WaitOnAddress, including the timed wait), and a spawned mutex counter |
| macOS arm64 and x86_64 | built; macos_imports_audit.py reports no missing import (__ulock_wait2, __ulock_wake) |
| FreeBSD x86_64, aarch64, riscv64 | built; not run |
The tests are in tests/{ar,en}/stdlib/behavior/:
| Test | What it checks |
|---|---|
sync_mutex_counter |
8 threads, 10 000 increments each, exact total |
sync_arc_threads_rss_flat |
10^4 rounds with the allocator's live bytes flat |
sync_condvar_ping_pong |
2 000 turns and a timed wait |
sync_once_contention |
8 threads, one run, once_lock |
sync_channel_producers |
4 producers, exact sum, per-producer order |
sync_channel_bounded |
backpressure and a full try_send |
sync_channel_disconnect |
empty, timeout and disconnected both ways, a sender dropped by a finishing thread |
sync_atomic_register_pressure |
atomics reached through closures and three-deep call chains with many live locals on 4 threads, exact totals |
sync_env_threads |
the environment table under threads |
sync_single_thread |
the interpreter differential pair |
wait_address_single |
wait and wake on one thread |
thread_spawn_join, thread_join_value, thread_current, thread_nested, thread_many_short, thread_slots |
the thread API |
async_tasks |
concurrent sleeps, await, a task awaiting a task, yield, async TCP between two tasks |
http_server_async |
two clients served by tasks of the async HTTP server, then shutdown |
The reject tests are tests/*/diagnostics/safety/thread_capture_{raw_pointer,reference,global}.sn,
and the accept test is tests/*/compiler/semantic/thread_capture_allowed.sn.
Limitations
- The spawn capture check does not see through a closure passed in a variable, a captured closure, or a call that touches a writable module variable. Rust tracks these with auto traits on closure types.
- Scoped threads (
thread::scope) need region and lifetime support that the ownership checker does not express cheaply. Thread-locals, barriers andWaitGroupdo not exist. - Atomics ignore weaker orderings and always use
seq_cst. - The rwlock has no writer preference.
- Channels notice an implicitly dropped end by polling, within 32 ms.
run_http_serverandrun_https_serverserve one connection at a time; the event-drivenrun_http_server_eventedserves HTTP/1 concurrently, but an h2c connection holds its worker, TLS is not event-driven yet, and Windows has no poller (IOCP).- Threads are not available on WASI, on freestanding targets or in the interpreter.
- Async tasks run on one thread per process (no work stealing across threads), one task awaits a
given task at a time,
tcp_connect_asyncblocks during the handshake, and tasks do not switch on Windows, wasm32 or 32-bit targets.
See also
- Type system: the capture rules.
- Ownership: moves, borrows and why closures own what they capture.
- Standard library coverage: where
std.syncandstd.threadsit beside Rust and Go. - Platform dependencies: the system calls under the wait/wake and thread layers.