Module thread
OS threads and a thread pool, each thread with its own Io event loop and GC heap.
Two levels are available:
Thread.spawn— one dedicated OS thread per call, joined explicitly.ThreadPool— a reusable pool of worker threads that tasks are handed to; tasks are distributed round-robin with thread affinity (no work stealing).
Thread(T).spawn carries a value out of the thread: the body returns T
and join() returns it.
{ Thread } :: import "std/thread";
t := Thread(i32).spawn((io : Io) => i32(42));
result := t.join();
Thread(unit) is the value-less thread, and join() then returns ().
ThreadPool.join_all still carries nothing — hand a pool task's value back
over a Channel.
Stability
unstable — Thread(T).spawn/join, ThreadPool and join_all have the
shapes they will keep, Send IS enforced at the spawn boundary
(issues/fixed/send-was-not-enforced-at-spawn-boundaries.md), and D18's
join() -> T has landed. What keeps this from freezing is ThreadPool,
whose task type is still value-less and whose shutdown story is a separate
decision.
Types
OS thread handle — Rust's JoinHandle. Each spawned thread gets its own
Io event loop. join waits for it, once; dropping a handle that was never
joined DETACHES the thread (it keeps running and the OS reclaims it when it
finishes). Reference-counted so that detach-on-drop runs exactly once
however many copies of the handle exist
(issues/fixed/thread-join-was-re-callable-and-handles-leaked.md).
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: (Send, Acyclic)), Thread(T), ...)
spawn : (fn(own(cb) : Impl(Fn(io : Io) -> T, Send)) -> Self)Spawn a new OS thread running the given closure.
The closure receives its own per-thread Io event loop.
own(cb): the callback's capture struct is MOVED in. The relaying
closure below takes its own reference to it, so the one the caller handed
over has to be released here — without own it is orphaned and every
RC'd capture of every spawned callback leaks.
Returns: Self
join : (fn(self : Self) -> T)Block the current thread until this thread completes, and return what its
body produced. Panics on a second call: joining a thread twice is
undefined behaviour in every threading API underneath (pthread_join on
a joined thread; a closed HANDLE).
At T = unit this reads as the old value-less join().
Returns: T
is_joined : (fn(self : Self) -> bool)True once join has returned.
Returns: bool
impl(generic(T : Type), where(T <: (Send, Acyclic)), Thread(T), Dispose(...))
dispose : (fn(self : Self) -> unit)Release the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Returns: unit
A pool of worker threads that tasks are handed to.
Each task is pinned to one worker thread (round-robin, no work stealing), and every worker thread has its own GC heap and async Io event loop.
The OS worker threads live in one process-global pool owned by the
runtime, which starts it on the first spawn and tears it down at process
exit. A ThreadPool value is the explicit handle on that pool: it decides
the thread count, submits tasks, and drains them. Two consequences:
- the
num_threadspassed tonewis a request — it only takes effect while the runtime pool has not started yet, so the firstThreadPoolcreated (before any task is spawned) fixes the count for the process; join_allandshutdowndrain the runtime pool, so if a program holds twoThreadPoolvalues they drain each other's work as well.
Uses atomic reference counting, so a pool may be shared across threads.
Example
{ ThreadPool, spawn } :: import "std/thread";
pool := ThreadPool.new(usize(4));
spawn(pool, (io) => {
// ... do work ...
});
pool.join_all(); // wait for everything submitted so far
pool.shutdown(); // refuse new work, then drain
Fields
| Name | Type | Description |
|---|---|---|
_mutex | Mutex(bool) | |
_used | AtomicBool | |
_closed | AtomicBool | |
_held | AtomicBool | Which thread holds |
_owner | AtomicUsize |
impl(ThreadPool, ...)
new : (ThreadPool) fn(num_threads : usize) -> ThreadPoolCreate a pool that requests num_threads worker threads.
num_threads must be > 0. It is a request, not a guarantee: see the
type-level note about the process-global runtime pool.
Parameters
| Name | Type | Notes | Description |
|---|---|---|---|
num_threads | usize | The number of worker threads the runtime pool is using (or will use). |
Returns: ThreadPool
with_hardware_threads : (ThreadPool) fn() -> ThreadPoolCreate a pool sized to the runtime pool's current thread count, which defaults to the number of hardware threads (CPU cores).
Returns: ThreadPool
num_threads : (ThreadPool) fn(self : ThreadPool) -> usizeThe number of worker threads the runtime pool is using (or will use).
Parameters
| Name | Type | Notes |
|---|---|---|
self | ThreadPool |
Returns: usize
join_all : (ThreadPool) fn(self : ThreadPool) -> unitBlock until every task submitted to this pool so far has finished.
The pool stays open — further spawn calls are still accepted.
The drain is a barrier, not a counter: it submits one sentinel task per
worker thread and waits for all of them. The runtime hands consecutive
submissions to consecutive worker threads (round-robin, guarded by the
runtime's pool mutex) and each worker runs its own queue in FIFO order,
so once every sentinel has run, every task queued before it has run too.
spawn and this submission loop share the pool mutex, so tasks
submitted concurrently through this pool cannot break the round-robin
stride.
Must not be called from inside a pool task: the caller's worker thread would be busy waiting for its own sentinel.
The ~344 bytes this used to leak per call — the sentinels' drained
channel, whose reference a spawn wrapper never released — are freed as of
2026-09-11 (issues/fixed/spawn-closure-captures-never-dropped-leak.md).
Parameters
| Name | Type | Notes |
|---|---|---|
self | ThreadPool |
Returns: unit
shutdown : (ThreadPool) fn(self : ThreadPool) -> unitRefuse new work, then block until every outstanding task has finished.
Idempotent. The runtime's OS worker threads are process-global and are
reclaimed at process exit; shutdown closes this pool and drains the
work it submitted.
Parameters
| Name | Type | Notes |
|---|---|---|
self | ThreadPool |
Returns: unit
is_shutdown : (ThreadPool) fn(self : ThreadPool) -> boolWhether shutdown has been called (lock-free atomic load).
Parameters
| Name | Type | Notes |
|---|---|---|
self | ThreadPool |
Returns: bool
Functions
Get the number of hardware threads (CPU cores) available.
Returns: usize
Get the current OS thread ID.
Returns: usize
The CPU core the calling thread is currently running on, or -1 when the
platform cannot say. Linux (sched_getcpu) and Windows
(GetCurrentProcessorNumber) report it; macOS has no public per-thread
CPU query and always returns -1, as does WASM. Advisory only — the
scheduler may migrate the thread right after the call.
Returns: i32
Spawn a new OS thread running the given closure.
The closure receives its own per-thread Io event loop.
own(cb): the callback's capture struct is MOVED in. The relaying
closure below takes its own reference to it, so the one the caller handed
over has to be released here — without own it is orphaned and every
RC'd capture of every spawned callback leaks.
Parameters
| Name | Type | Notes |
|---|---|---|
pool | ThreadPool | |
cb | Impl(Fn(io : Io) -> unit, Send) |
Returns: unit