Module thread

thread
Stability: unstable — may still change; see below.

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).

A move-only value (its state is a Box): there is one handle per thread, so detach-on-drop runs exactly once (issues/fixed/thread-join-was-re-callable-and-handles-leaked.md), and a copy is E0901. Lend it with &t; join takes self : &Self and marks the handle joined inside the box, so a Thread is never Sync. To join from elsewhere, move the handle there.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

where
impl(generic(T : Type), where(T <: Send), Thread(T), ...)
spawn : (fn(sink(cb) : Impl(FnOnce(io : Io) -> T, Send)) -> Self)

Spawn a new OS thread running the given closure. The closure receives its own per-thread Io event loop. sink(cb) : Impl(FnOnce(...), Send) (plans/VALUES_BY_DEFAULT.md decision 37): the callback is MOVED in and called exactly once, so it may move what it captured out of its body ({ tx, msg }(io : Io) => tx.send(msg)). An Fn closure is accepted too. The relay below captures it by value in a capture list and is itself FnOnce, so cb(io) consumes it there and the spawn wrapper frees only the relay's struct.

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

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;
  • the worker threads are shared by every ThreadPool value, but each pool counts ITS OWN tasks: join_all and shutdown wait for the work submitted through that pool (and for the tasks that work submits into it), not for another pool's.

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
_tasksWaitGroup
_closedAtomicBool
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, including the tasks those tasks submitted into it before finishing. The pool stays open — further spawn calls are still accepted.

A completion counter: spawn counts the task in under the submission lock and the task's wrapper counts it out after it returns. (Until 2026-09-26 this was a barrier of one sentinel task per worker thread, which leaned on the runtime's round-robin distribution and drained every pool's work at once; a wrapper around the user's task closure was not expressible before, issues/fixed/spawn-wrapper-forwarded-io-crosses-specializations.md.)

Must not be called from inside a task of this pool: the task would be waiting for itself.

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. Exit does NOT wait for tasks: work that is still queued or running when main returns is abandoned, as a detached Thread is, so a program that needs its tasks' effects calls join_all or shutdown first.

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, sink(cb) : Impl(FnOnce(io : Io) -> unit, Send)) -> unit

sink(cb) : Impl(FnOnce(...), Send): the task closure is MOVED in and run exactly once (plans/VALUES_BY_DEFAULT.md decision 37), so it may move its captures out; an Fn closure is accepted too. (A plain cb borrowed a literal capturing a not-implicitly-copyable value, and a borrow capture is never Send, decision 38 E: issues/fixed/a-move-only-value-cannot-be-moved-into-thread-spawn.md.)

Parameters

NameTypeNotes
pool&ThreadPool
cbImpl(FnOnce(io : Io) -> unit, Send)

Returns: unit

spawn_blocking function
fn(generic(T : Type), sink(cb) : Impl(Fn() -> T, Send), io : Io, where(T <: Send)) -> Impl(Future(T, Io))

Exported since 2026-09-13. It was written and working long before that but held back on a compiler defect: the closure-param form of fn(generic(T), … Impl(Fn() -> T) …) -> Impl(Future(T)) had no per-call identity for T, so it compiled at ONE T per program and silently miscompiled the second (issues/fixed/a-generic-function-returning-impl-future-t-miscompiles-at-a-second-t.md). tests/spawn_blocking.test.yo uses two different Ts for that reason.

Type Parameters

NameTypeNotes
TTypecomptime

Parameters

NameTypeNotes
cbImpl(Fn() -> T, Send)
ioIo

Returns: Impl(Future(T, Io))