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 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
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; - the worker threads are shared by every
ThreadPoolvalue, but each pool counts ITS OWN tasks:join_allandshutdownwait 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
| Name | Type | Description |
|---|---|---|
_tasks | WaitGroup | |
_closed | AtomicBool |
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,
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
| 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. 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
| 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
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
| Name | Type | Notes |
|---|---|---|
pool | ThreadPool | |
cb | Impl(Fn(io : Io) -> unit, Send) |
Returns: unit
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
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Parameters
| Name | Type | Notes |
|---|---|---|
cb | Impl(Fn() -> T, Send) | |
io | Io |
Returns: Impl(Future(T, Io))