threads · API

threads.atomic

Atomics that compile on Mojo 1.2.0 and on current nightly.

Everything here operates on a naturally aligned 64-bit cell at an address you own. That is on purpose: atomics are only interesting between threads, and threads in this library share state through a raw context pointer, so the useful shape is a view over somebody else's memory rather than a value you hold. AtomicCounter and AtomicFlag are both such views — copying one copies the view, not the cell. The seven free functions below are that view expressed as std.atomic calls against a Cell the caller's pointer is bitcast to; a Cell is laid out exactly like the Int64 it aliases, which the comptime asserts in _cell pin down so that a future stdlib change to Atomic fails the build rather than silently scribbling past the cell.

Orderings are spelled into the function names (_relaxed, _acquire, _release) rather than passed as a parameter, because the ordering has to be selected at compile time anyway and a named function is both clearer at the call site and cheaper to read than a comptime if ladder. fetch_add is sequentially consistent; that is what a work-queue counter wants.

Why Cell comes from outside this file

std.atomic.Atomic changed its parameter kind under this tin once already:

toolchain declaration
Mojo 1.0.0 struct Atomic[dtype: DType, *, scope: StaticString = ""]
Mojo 1.2.0, nightly struct Atomic[T: Deinitable & Movable, *, scope: ...]

so Atomic[DType.int64] was the only spelling 1.0.0 accepted and Atomic[Int64] is the only spelling the two toolchains supported today accept. Everything else was identical — the same fetch_add[ordering=…], load[ordering=…], store[ordering=…] and fence calls compile unchanged against any of them. The divergence was one type expression and nothing more.

Mojo offers no way to pick a type at module scope from a compiler-version predicate: a comptime if around a comptime Cell = … does not parse on either toolchain, and -D defines (get_defined_bool plus a comptime if) can only branch inside a function body. So the choice is made by the include path instead. compat/threads_compat.mojo declares Cell and nothing else, and a build says so:

mojo build … -I src -I compat

Dropping Mojo 1.0.0 left that directory with one occupant rather than two: 1.2.0 and the 1.3.0.dev nightlies agree on every spelling here. The seam stays because the next divergence is a matter of when, not whether, and re-splitting compat/ into one directory per toolchain is then a two-line change in this tin and a path edit in each consumer.

This used to be solved one level down instead, by reaching past std.atomic to the pop.atomic.rmw / pop.load / pop.store intrinsics it is itself written on top of, on the reasoning that a compiler primitive would be steadier than a stdlib signature. That held until it did not: nightlies from 26.6.0.dev2026090105 onward reject the __mlir_attr syntax those calls need (error: invalid MLIR attribute: expected '<', filed as modular/modular#7094), and because this was the only file in the whole stack touching raw MLIR it was single-handedly capping every downstream tin below that nightly. Two spellings of one alias, in a directory the caller selects, is a far smaller and more legible surface than a private compiler ABI — and when it breaks it breaks in Mojo the language rather than in an attribute grammar nobody documents.

That particular regression was fixed upstream the same day it was reported, so the intrinsics would compile again today. This module is not going back to them. The reasoning that sent it there — that a compiler primitive is steadier than a stdlib signature — was wrong in the way that matters: std.atomic is the interface with a compatibility promise, and pop.* is an implementation detail of it. Two spellings of one alias is the cost of being on the supported side of that line, and it is a cost worth paying whether or not the undocumented side happens to work this week.

Expect compat/ to grow again. Stable and nightly diverge as readily as they converge, and async is the next thing this tin will want that they may well spell differently. The pattern to follow is the one here: the divergent file holds the declaration that differs and nothing whatsoever besides, so that a reader can see the entire delta between the two toolchains at a glance and every line that actually does work stays single-sourced.

Structs

AtomicCounter

struct AtomicCounter

A shared 64-bit counter: a view over a cell somebody else owns.

The canonical use is a work queue. Every worker calls fetch_add(1) and treats the returned value as its task index; the first worker to see an index at or past the task count stops.

while True:
    var i = Int(counter.fetch_add(1))
    if i >= n_tasks:
        break
    do_work(i)

Copying an AtomicCounter copies the view, never the cell — two copies address the same counter, which is exactly what you want when you hand one to each worker.

The cell must be 8-byte aligned and must outlive every view of it. alloc satisfies the alignment; the lifetime is on you.

Implements Copyable, ImplicitlyCopyable, Movable

at

def at(address: Int) -> Self

View the counter cell at a raw address.

Args
address: Address of an 8-byte-aligned 64-bit cell.
def at(ctx: Pointer[UInt8, MutUntrackedOrigin], slot: Int) -> Self

View the slot-th 64-bit cell of a context block.

Args
ctx: Base of a block of 64-bit cells, itself 8-byte aligned.
slot: Which cell, counted in 8-byte units.

alloc

def alloc(initial: Int64 = Int64(0)) -> Self

Allocate a fresh counter cell on the heap.

The view returned owns nothing — call unsafe_free on exactly one copy once every thread that could touch it has been joined.

Args
initial: Starting value.

unsafe_free

def unsafe_free(self)

Free a cell obtained from alloc. Never call this while a thread that can see the cell is still running.

address

def address(self) -> Int

Return the raw address of the cell.

fetch_add

def fetch_add(self, delta: Int64 = Int64(1)) -> Int64

Atomically add and return the previous value.

Args
delta: The amount to add.

load

def load(self) -> Int64

Atomically read the counter (acquire).

store

def store(self, value: Int64)

Atomically write the counter (release).

Args
value: The value to write.

AtomicFlag

struct AtomicFlag

A one-way (or resettable) publish/observe flag over a shared cell.

set is a release store and is_set is an acquire load, so the pair carries data with it:

# publisher
payload[0] = 42        # plain write
flag.set()             # release

# observer
while not flag.is_set():   # acquire
    spin_hint()
assert payload[0] == 42     # guaranteed visible

That release/acquire pairing is the whole point — a plain Bool written from one thread and read from another has no such guarantee, and the compiler is free to hoist the read out of the spin loop entirely.

Like AtomicCounter, this is a view: copying it does not copy the cell.

Implements Copyable, ImplicitlyCopyable, Movable

at

def at(address: Int) -> Self

View the flag cell at a raw address.

Args
address: Address of an 8-byte-aligned 64-bit cell.
def at(ctx: Pointer[UInt8, MutUntrackedOrigin], slot: Int) -> Self

View the slot-th 64-bit cell of a context block.

Args
ctx: Base of a block of 64-bit cells, itself 8-byte aligned.
slot: Which cell, counted in 8-byte units.

alloc

def alloc() -> Self

Allocate a fresh, cleared flag cell on the heap.

unsafe_free

def unsafe_free(self)

Free a cell obtained from alloc. Never call this while a thread that can see the cell is still running.

address

def address(self) -> Int

Return the raw address of the cell.

set

def set(self)

Publish: release-store 1. Everything written before this call is visible to a thread that later observes is_set().

clear

def clear(self)

Reset the flag to 0 with a release store.

is_set

def is_set(self) -> Bool

Observe: acquire-load.

raw

def raw(self) -> Int64

Return the cell's value as an integer (acquire load) — useful when a flag doubles as a small error code.

set_value

def set_value(self, value: Int64)

Release-store an arbitrary non-zero value, for the flag-as-error-code pattern.

Args
value: The value to publish.

Functions

atomic_fetch_add

def atomic_fetch_add(ptr: Pointer[Int64, MutUntrackedOrigin], delta: Int64) -> Int64

Atomically add delta to the cell, sequentially consistent.

Args
ptr: The cell.
delta: The amount to add.

atomic_load_relaxed

def atomic_load_relaxed(ptr: Pointer[Int64, MutUntrackedOrigin]) -> Int64

Atomically read the cell with relaxed ordering.

Args
ptr: The cell.

atomic_load_acquire

def atomic_load_acquire(ptr: Pointer[Int64, MutUntrackedOrigin]) -> Int64

Atomically read the cell with acquire ordering.

Args
ptr: The cell.

atomic_store_relaxed

def atomic_store_relaxed(ptr: Pointer[Int64, MutUntrackedOrigin], value: Int64)

Atomically write the cell with relaxed ordering.

Args
ptr: The cell.
value: The value to write.

atomic_store_release

def atomic_store_release(ptr: Pointer[Int64, MutUntrackedOrigin], value: Int64)

Atomically write the cell with release ordering.

Everything this thread wrote before the call becomes visible to any thread that observes this write through atomic_load_acquire.

Args
ptr: The cell.
value: The value to write.

atomic_fence_acquire

def atomic_fence_acquire()

A standalone acquire fence.

std.atomic.fence covers standalone fences on both toolchains with the same Ordering the loads and stores take, so these two moved across with the rest and no intrinsic is left in this file.

atomic_fence_release

def atomic_fence_release()

A standalone release fence. See atomic_fence_acquire.

Aliases

comptime CellPtr = Pointer[Atomic[Int64], MutUntrackedOrigin]

A pointer to a shared atomic cell — what every primitive below actually operates on, obtained by bitcasting the caller's I64Ptr.