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). 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 value's owner drops it (a value type) or the last reference to it goes away (a reference type), 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, 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;
  • 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, own(cb) : Impl(Fn(io : Io) -> unit, Send)) -> unit

own(cb): the task closure is MOVED in. A plain cb borrowed a literal capturing a not-implicitly-copyable value, and a borrow capture is never Send (decision 38 E), so such a task could never be submitted (issues/fixed/a-move-only-value-cannot-be-moved-into-thread-spawn.md).

Parameters

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

Returns: unit

spawn_blocking function
fn(generic(T : Type), own(cb) : Impl(Fn() -> T, Send), io : Io, where(T <: (Send, Acyclic))) -> 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))