threads · API

threads.parallel

parallel_for — the reason this tin exists — plus the spawn_n / join_all layer underneath it.

from threads import parallel_for, AtomicCounter

@fieldwise_init
struct Totals(Copyable, Movable):
    var sum: Int64

def task(i: Int, mut totals: Totals) -> None:
    _ = AtomicCounter.at(Int(Pointer(to=totals.sum))).fetch_add(Int64(i))

def main() raises:
    var totals = Totals(0)
    parallel_for[task](1000, totals)
    print(totals.sum)  # 499500

parallel_for starts min(n_tasks, num_workers or num_cpus()) kernel threads. Each one loops on AtomicCounter.fetch_add(1) and runs task(i, state) for every index it draws, stopping when the counter passes n_tasks. Every thread is joined before the call returns, so by construction nothing the caller owns can be destroyed while a worker still reads it.

Two spellings of the same call

The typed form above takes the shared state by ref and the task by mut. Because the state is an argument, it is alive for the whole call — the compiler extends its lifetime across the joins, with no later mention needed — and an immutable binding or a temporary is rejected at the call site. The erasure to void * that pthread demands happens inside the library, once.

The opaque form is the one underneath:

def square_chunk(i: Int, ctx: OpaquePtr) -> None:
    var cells = i64_ptr(Int(ctx))
    cells[unsafe_offset=i] = cells[unsafe_offset=i] * cells[unsafe_offset=i]

parallel_for[square_chunk](n_tasks=64, ctx=opaque_ptr(Int(data)))

Reach for it when the context is a hand-laid-out block of cells rather than a struct, or when the pointer comes from somewhere the compiler cannot track anyway (an allocation, a foreign buffer). It carries no origin: the caller guarantees the pointee outlives the call, and ctx being an untracked Pointer means Mojo's last-use destruction can free a local out from under the workers with no diagnostic — see threads.thread.

What neither form checks: that T is safe to mutate from several threads at once. Every task receives mut access to the same value; anything that is not an atomic, a mutex-guarded region, or a slot only one task writes is a data race the compiler will not see.

The send contract, restated
  • work is thin and non-raising. It is called through a C function pointer, so it cannot capture, and pthread has no exception channel.
  • All shared state travels through ctx. One pointer, for every task.
  • The caller guarantees ctx outlives the call. parallel_for joins before returning, so "outlives the call" is all that is needed — but see threads.thread on Mojo's last-use destruction if you build the context out of a local value.
  • No allocator assumption beyond what the tests prove. Workers may allocate and free (List, String, alloc) concurrently; the allocator under threads test in tests/test_threads.mojo is the evidence, and it is run on all four CI legs.
Scheduling

The queue is a single shared counter, so it is dynamically load balanced — a slow task does not stall the others — at a cost of one atomic RMW per task. Make tasks chunky: prefer n_tasks in the tens-to-thousands over one task per element. n_tasks may exceed the worker count freely; that is the normal case.

Reporting failures out of a task

work cannot raise. The pattern that fits the model is an error cell in the context: an AtomicFlag plus a fixed-size array of error codes, one slot per worker or per task.

# ctx layout, in 64-bit cells:
#   [0]        error flag (0 = clean)
#   [1..n+1]   per-task error code
def task(i: Int, ctx: OpaquePtr) -> None:
    var flag = AtomicFlag.at(ctx, 0)
    var cells = i64_ptr(Int(ctx))
    if something_went_wrong:
        cells[1 + i] = MY_ERROR_CODE   # plain write, this task owns the slot
        flag.set()                      # release: publishes the write above
        return

# after parallel_for returns:
if flag.is_set():                       # acquire
    raise Error("task ", first_bad, " failed with ", cells[1 + first_bad])

The flag's release/acquire pairing is what makes the plain per-slot writes visible to the caller; the join at the end of parallel_for also gives you that, so the flag is really there to let you skip the scan when nothing failed.

Structs

ThreadGroup

struct ThreadGroup

A batch of threads spawned together and joined together.

Move-only for the same reason ThreadHandle is: the group owns the threads and there must be exactly one join.

Implements Movable, Sized

__init__

def __init__(out self)

An empty group.

__len__

def __len__(self) -> Int

The number of threads in the group.

append

def append(mut self, var handle: ThreadHandle)

Take ownership of one more thread.

Args
handle: The handle to adopt.

join_all

def join_all(mut self)

Join every thread in the group.

Joins are attempted in spawn order. If one fails, the remaining threads are still joined before the error is re-raised — leaving live threads behind while the caller unwinds and frees their context would be far worse than a late error.

Raises Error: The first pthread_join failure, after all joins have been attempted.

pin_all

def pin_all(self)

Pin thread i to CPU i % num_cpus().

A no-op on macOS (see ThreadHandle.pin_to_cpu).

Raises Error: On Linux if the affinity call fails.

Functions

join_all

def join_all(mut group: ThreadGroup)

Free-function spelling of ThreadGroup.join_all.

Args
group: The group to join.

Raises Error: The first join failure.

spawn_n

def spawn_n[start: def(Pointer[UInt8, MutUntrackedOrigin]) thin -> OpaquePtr](n: Int, ctx: Pointer[UInt8, MutUntrackedOrigin], stride: Int = Int(0)) -> ThreadGroup

Start n threads running start, thread i receiving ctx + i * stride.

With the default stride of 0 every thread gets the same pointer, which is what you want for a shared context. A non-zero stride carves the context into equal per-thread slices without a second allocation.

If a spawn fails partway through, the threads already started are joined before the error propagates, so the caller never has to reason about a half-started group.

Parameters
start: The thread body — thin and non-raising.
Args
n: How many threads to start. n <= 0 yields an empty group.
ctx: Base context pointer.
stride: Bytes between consecutive threads' context pointers.

Raises Error: If any pthread_create fails.

parallel_for

def parallel_for[work: def(Int, Pointer[UInt8, MutUntrackedOrigin]) thin -> None](n_tasks: Int, ctx: Pointer[UInt8, MutUntrackedOrigin], num_workers: Int = Int(0))

Run work(i, ctx) for every i in [0, n_tasks), across threads.

Starts min(n_tasks, num_workers or num_cpus()) threads, hands out indices from one shared atomic counter, and joins every thread before returning. The calling thread does not run tasks; it blocks in the joins.

Parameters
work: The task body. Thin (non-capturing) and non-raising — see the module docstring for reporting failures out of a task.
Args
n_tasks: How many indices to hand out. Zero or negative does nothing.
ctx: The shared context pointer given to every task. Must outlive the call; since the call joins before returning, that is automatic for anything the caller keeps alive across it.
num_workers: Thread count. 0 (the default) means num_cpus(). Values above n_tasks are clamped down, because a thread with no index to draw is pure overhead.

Raises Error: If a thread fails to start or to join. The worker context block is freed on every path after the joins, never before.

def parallel_for[T: AnyType, origin: MutOrigin, //, task: def(Int, mut T) thin -> None](n_tasks: Int, ref[origin] state: T, num_workers: Int = Int(0))

Run task(i, state) for every i in [0, n_tasks), across threads.

The typed form of parallel_for. state is an argument, so it is alive for the whole call, joins included, without the caller having to mention it afterwards; and it is taken by mutable ref, so a read argument or a temporary is refused at the call site. The pointer pthread carries is built here and rebuilt in _typed_worker; nothing outside the library handles it.

Parameters
T: The shared state type. Inferred from state.
origin: The origin of state. Inferred.
task: The task body, def(Int, mut T) thin -> None. Thin and non-raising, as in the opaque form.
Args
n_tasks: How many indices to hand out. Zero or negative does nothing.
state: The value every task receives by mut. Guarding concurrent writes to it is the task's job — atomics, a Mutex, or per-task slots.
num_workers: Thread count, as in the opaque form.

Raises Error: If a thread fails to start or to join.

Aliases

comptime WorkFn = def(Int, Pointer[UInt8, MutUntrackedOrigin]) thin -> None

A parallel_for task body, opaque form: the task index and the shared context pointer.

comptime TaskFn: __mlir_type.`!kgen.non_struct_type` = def(Int, mut T) thin -> None

A parallel_for task body, typed form: the task index and mutable access to the shared state. Every task gets the same T, at the same time.