Docs
What it’s for
threads is how a Mojo program uses more than one core today: it wraps pthreads with spawn and join, 64-bit atomics, a mutex and condition variable, parallel_for over a bounded set of tasks, and pools of long-running workers. parquet and iceberg fan reads out with parallel_for, postgres blocks pool checkouts on its condition variable, and restate, k8s, flare_mlake and Orca build on it too. It’s a stopgap until the standard library has threads of its own.
Install
A new project needs Modular’s channel, pixi’s build preview and the compiler before the tin:
pixi init app -c https://conda.modular.com/max -c conda-forge && cd app
sed -i.bak 's/^\[workspace\]/[workspace]\npreview = ["pixi-build"]/' pixi.toml
pixi add "mojo-compiler==1.2.0"
pixi shelf add threads-mojo # no other tins
If you build from a checkout instead, pass both -I ../threads.mojo/src and -I ../threads.mojo/compat. The installed package needs neither.
Quick start
Square 1000 numbers across every core, each task writing its own slot and adding to one shared total atomically:
from threads import AtomicCounter, num_cpus, parallel_for
@fieldwise_init
struct Work(Copyable, Movable):
var squares: List[Int64] # one slot per task: no two tasks write the same one
var total: Int64 # shared by every task: only touched atomically
def task(i: Int, mut w: Work) -> None:
var sq = Int64(i) * Int64(i)
w.squares[i] = sq
_ = AtomicCounter.at(Int(Pointer(to=w.total))).fetch_add(sq)
def main() raises:
var n = 1000
var w = Work(List[Int64](length=n, fill=0), 0)
parallel_for[task](n, w) # num_workers=0: one thread per CPU
print(num_cpus(), w.squares[999], w.total) # ... 998001 332833500
API
Everything is exported from threads. A task or worker body is a thin function: it can’t capture and it can’t raise.
Bounded work:
parallel_for[task: TaskFn[T]](n_tasks: Int, ref[origin] state: T, num_workers: Int = 0) raises: runstask(i, state)for everyiin[0, n_tasks), whereTaskFn[T]isdef(Int, mut T) thin -> None. Threads pull indices off one atomic counter, and every thread is joined before it returns.0workers meansnum_cpus().parallel_for[work: WorkFn](n_tasks: Int, ctx: OpaquePtr, num_workers: Int = 0) raises: the same over a raw pointer, withWorkFn=def(Int, OpaquePtr) thin -> None. You keepctxalive.spawn_n[start: StartFn](n: Int, ctx: OpaquePtr, stride: Int = 0) raises -> ThreadGroup: startsnthreads, threadigettingctx + i * stride.ThreadGrouphasjoin_all(mut self) raisesandpin_all(self) raises.
Long-running workers:
TypedPool.start[task: PoolTaskFn[T]](n: Int, ref[origin] state: T) raises -> TypedPool[T, origin]: startsnworkers runningtask(index, state, stop)until stopped. The pool carriesstate’s origin, so the state outlives it. A form withoutnstartsnum_cpus()workers.WorkerPool.start[work: WorkerFn](n: Int, ctx: OpaquePtr) raises -> WorkerPool: the same over a raw pointer.- On both:
request_stop(self)sets the stop flag,join(mut self) raiseswaits,shutdown(mut self) raisesdoes both, andstop_flag,is_stopping,num_workersandpin_alldo what they say.TypedPool.state(self)gives the state back. Dropping a pool stops and joins it.
Threads: ThreadHandle.spawn[start: StartFn](arg: OpaquePtr) raises -> ThreadHandle, with StartFn = def(OpaquePtr) thin -> OpaquePtr, then join(mut self) raises (safe to call twice), detach(mut self) raises or pin_to_cpu(self, cpu: Int) raises. Also num_cpus() -> Int, current_thread_id() -> UInt64 and yield_now().
Atomics, views over a 64-bit cell at an address you own:
AtomicCounter.at(address: Int) -> AtomicCounter(orat(ctx: OpaquePtr, slot: Int), oralloc(initial: Int64 = 0)withunsafe_free()), thenfetch_add(self, delta: Int64 = 1) -> Int64,load(self) -> Int64andstore(self, value: Int64).AtomicFlag, built the same ways, withset(self)(release),is_set(self) -> Bool(acquire) andclear(self).- Free functions on an
I64Ptr:atomic_fetch_add,atomic_load_relaxed,atomic_load_acquire,atomic_store_relaxed,atomic_store_release, and the two fences.
Locks: Mutex() raises, with lock(self), unlock(self), try_lock(self) -> Bool and with_lock[body: def(OpaquePtr) thin -> None](self, ctx: OpaquePtr). ref(self) -> MutexRef gives a copyable view a worker can hold. CondVar() raises has wait(self, mutex: MutexRef), signal(self) and broadcast(self).
Raw pointers: OpaquePtr, I64Ptr, opaque_ptr(address: Int) and i64_ptr(address: Int).
Rules and errors
- Every task gets
mutaccess to the same state, and nothing checks what it does with it. A plain store to a shared field is a data race: use an atomic, aMutex, or a slot only that task writes. lint catches the plain-store case. - Mojo destroys a value at its last use, and an address made with
Int(Pointer(to=x))oropaque_ptrisn’t a use. With the opaque forms, mention the context after the join or heap-allocate it. The typed forms keep it alive for you. - A task can’t raise. Report failures through the state, for example with an
AtomicFlagand one error cell per task. - Pool workers stop only when they check the flag: loop on
not stop.is_set(). A worker blocked in a syscall doesn’t see the flag until the call returns. - There is no lock guard, since it would unlock at its last use. Call
lock()andunlock(), orwith_lock. - Failures raise
pthread_create failed, rc=<n>,WorkerPool.start needs at least one worker, got <n>, orparallel_for could not start workers: <cause>. pin_to_cpuis real on Linux and does nothing on macOS. Each task costs one atomic increment on a shared cache line, so make tasks chunky.
Tests
pixi run test (nightly) and pixi run -e stable test (Mojo 1.2.0) run races built to give a wrong number when a primitive is broken: 8 threads adding to one counter must reach exactly 800,000, and a typed pool’s state poisons itself on destruction while workers count poisoned reads. pixi run -e stable stress-tsan (macOS) and stress-asan (Linux) rerun a stress sweep under a sanitizer and fail on any warning. CI covers stable and nightly on linux-64 and osx-arm64.
The full reference is the README on GitHub.