Module thread

thread
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. — stable modules only change additively; this one may still change.

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

Thread type-function
fn(T : Type) -> Type

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

NameTypeNotes
TTypecomptime

Trait Implementations

where Dispose
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

ThreadPool atomic object
ThreadPool

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_threads passed to new is a request — it only takes effect while the runtime pool has not started yet, so the first ThreadPool created (before any task is spawned) fixes the count for the process;
  • join_all and shutdown drain the runtime pool, so if a program holds two ThreadPool values 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

NameTypeDescription
_mutexMutex(bool)
_usedAtomicBool
_closedAtomicBool
_heldAtomicBool

Which thread holds _mutex, so a submission made FROM a task that the runtime is running inline on the submitting thread (no worker thread could be created) re-enters instead of self-deadlocking (issues/fixed/thread-pool-spawn-self-deadlocked-in-the-inline-fallback.md).

_ownerAtomicUsize
impl(ThreadPool, ...)
new : (ThreadPool) fn(num_threads : usize) -> ThreadPool

Create 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

NameTypeNotesDescription
num_threadsusize

The number of worker threads the runtime pool is using (or will use).

Returns: ThreadPool

with_hardware_threads : (ThreadPool) fn() -> ThreadPool

Create 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) -> usize

The number of worker threads the runtime pool is using (or will use).

Parameters

NameTypeNotes
selfThreadPool

Returns: usize

join_all : (ThreadPool) fn(self : ThreadPool) -> unit

Block 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

NameTypeNotes
selfThreadPool

Returns: unit

shutdown : (ThreadPool) fn(self : ThreadPool) -> unit

Refuse 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

NameTypeNotes
selfThreadPool

Returns: unit

is_shutdown : (ThreadPool) fn(self : ThreadPool) -> bool

Whether shutdown has been called (lock-free atomic load).

Parameters

NameTypeNotes
selfThreadPool

Returns: bool

Functions

fn() -> usize

Get the number of hardware threads (CPU cores) available.

Returns: usize

get_thread_id function
fn() -> usize

Get the current OS thread ID.

Returns: usize

get_cpu_id function
fn() -> i32

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 function
fn(pool : ThreadPool, cb : Impl(Fn(io : Io) -> unit, Send)) -> unit

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

NameTypeNotes
poolThreadPool
cbImpl(Fn(io : Io) -> unit, Send)

Returns: unit