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).
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
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
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
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
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
| Name | Type | Notes |
|---|---|---|
pool | &ThreadPool | |
cb | Impl(FnOnce(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))