13 KiB
std.atomic - Atomic Operations Reference (Zig 0.16.0)
Lock-free atomic operations for concurrent programming. Wraps Zig's atomic builtins with a type-safe interface.
Primary Zig 0.16 release-note source: https://ziglang.org/download/0.16.0/release-notes.html
Lock-free atomics do not need std.Io. Blocking synchronization should use std.Io.Mutex, std.Io.Condition, std.Io.Semaphore, std.Io.RwLock, std.Io.Event, or std.Io.Group when used in I/O-aware code.
Table of Contents
- Module Structure
- Atomic Value Wrapper
- Atomic Operations
- Atomic Ordering
- Spin Loop Hint
- Cache Line Size
- Common Patterns
Module Structure
std.atomic.Value(T) // Atomic wrapper for T (integers, enums, floats, bools, pointers)
std.atomic.Mutex // Non-blocking/spinning atomic mutex
std.atomic.spinLoopHint() // CPU hint for spin-wait loops
std.atomic.cache_line // Target-based cache-line estimate (comptime constant)
Atomic Value Wrapper
std.atomic.Value(T) wraps a value to enable atomic operations. Supported types: integers, enums, floats, bools, optional pointers.
Creation
const std = @import("std");
// Initialize with value
var counter = std.atomic.Value(u64).init(0);
// Initialize in struct
const State = struct {
count: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
flag: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
};
// Direct access (careful - not atomic!)
var x = std.atomic.Value(u32).init(10);
x.raw = 20; // Non-atomic write - use only when no concurrent access
Basic Operations
var x = std.atomic.Value(u32).init(5);
// Load (atomic read)
const val = x.load(.acquire);
// Store (atomic write)
x.store(10, .release);
// Swap (exchange, returns old value)
const old = x.swap(20, .seq_cst); // old = 10, x = 20
Atomic Operations
Fetch-and-Modify Operations
All return the previous value before modification:
var x = std.atomic.Value(i32).init(10);
// Arithmetic
_ = x.fetchAdd(5, .seq_cst); // x = 15, returns 10
_ = x.fetchSub(3, .seq_cst); // x = 12, returns 15
_ = x.fetchMin(8, .seq_cst); // x = 8, returns 12
_ = x.fetchMax(20, .seq_cst); // x = 20, returns 8
// Bitwise
var bits = std.atomic.Value(u8).init(0b1100);
_ = bits.fetchAnd(0b1010, .seq_cst); // 0b1000
_ = bits.fetchOr(0b0011, .seq_cst); // 0b1011
_ = bits.fetchXor(0b1111, .seq_cst); // 0b0100
_ = bits.fetchNand(0b1100, .seq_cst); // ~(0b0100 & 0b1100) = ~0b0100
// Generic RMW (any AtomicRmwOp)
_ = x.rmw(.Add, 1, .seq_cst);
Compare-and-Swap (CAS)
var x = std.atomic.Value(u32).init(100);
// Strong CAS - guaranteed to succeed if values match
const result = x.cmpxchgStrong(100, 200, .seq_cst, .seq_cst);
// result = null (success, x is now 200)
// result = 100 (failure, current value if mismatch)
// Weak CAS - may spuriously fail, use in loops
var current: u32 = x.load(.acquire);
while (x.cmpxchgWeak(current, current + 1, .acq_rel, .acquire)) |actual| {
current = actual; // Retry with actual value
}
When to use which:
cmpxchgStrong: Single attempt, no retry loopcmpxchgWeak: In retry loops (more efficient on some architectures)
Bit Operations
Individual bit manipulation, returning previous bit state:
var flags = std.atomic.Value(u32).init(0);
// Set bit (returns previous bit value: 0 or 1)
const was_set = flags.bitSet(3, .seq_cst); // Set bit 3
// Reset bit
const was_reset = flags.bitReset(3, .seq_cst); // Clear bit 3
// Toggle bit
const prev = flags.bitToggle(5, .seq_cst); // Flip bit 5
Atomic Ordering
Memory orderings control synchronization guarantees. From std.builtin.AtomicOrder:
| Order | Guarantees | Use Case |
|---|---|---|
.unordered |
No ordering, loads/stores only (no RMW) | Preventing torn reads/writes only (e.g., data inside SeqLock) |
.monotonic |
Coherent on same variable, reorderable with other atomics | Simple counters, progress indicators |
.acquire |
Subsequent reads/writes won't move before this | Loading shared data after flag check |
.release |
Prior reads/writes won't move after this | Publishing data before setting flag |
.acq_rel |
Both acquire and release | Read-modify-write on shared data |
.seq_cst |
Total order among all seq_cst operations | When multiple atomics must be globally ordered |
Ordering Guidelines
// Producer-consumer pattern
var data: Data = undefined;
var ready = std.atomic.Value(bool).init(false);
// Producer thread
fn produce() void {
data = computeData(); // Write data first
ready.store(true, .release); // Then publish (release ensures order)
}
// Consumer thread
fn consume() void {
while (!ready.load(.acquire)) { // Acquire synchronizes with release
std.atomic.spinLoopHint();
}
useData(data); // Safe to read after acquire
}
Rules of thumb:
.monotonicfor counters that don't guard other data (can still reorder with other atomics).releasewhen publishing/storing data that others will read.acquirewhen consuming/loading data others published.acq_relfor RMW operations that both read and write shared state.seq_cstwhen multiple atomics must have a single global order visible to all threads (only orders with other seq_cst ops)
Spin Loop Hint
spinLoopHint() tells the CPU it's in a spin-wait loop, improving power efficiency and SMT performance:
fn spinWait(flag: *std.atomic.Value(bool)) void {
while (!flag.load(.acquire)) {
std.atomic.spinLoopHint(); // Reduce power, yield to sibling threads
}
}
Representative architecture-specific behavior (the implementation contains additional target/feature cases):
- x86/x86_64:
pauseinstruction - AArch64:
isbinstruction - ARM: feature-dependent yield/spin hint
- RISC-V:
pause(Zihintpause extension) - Some unsupported targets: No-op
Cache Line Size
cache_line is a target-based estimate of cache-line size, useful when reducing false sharing. It is not a runtime query of the current CPU:
const cache_line = std.atomic.cache_line; // 64, 128, etc.
// Pad struct to avoid false sharing between threads
const PaddedCounter = struct {
value: std.atomic.Value(u64) align(cache_line) = std.atomic.Value(u64).init(0),
_padding: [cache_line - @sizeOf(std.atomic.Value(u64))]u8 = undefined,
};
// Per-thread counters without false sharing
const ThreadCounters = struct {
counters: [MAX_THREADS]PaddedCounter = [_]PaddedCounter{.{}} ** MAX_THREADS,
};
Representative values selected by the stdlib's target table (not a complete hardware guarantee):
- x86_64, AArch64: 128 bytes (big cores)
- ARM, MIPS: 32 bytes
- Most others: 64 bytes
Common Patterns
Thread-Safe Counter
const Counter = struct {
value: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
pub fn increment(self: *@This()) void {
_ = self.value.fetchAdd(1, .monotonic);
}
pub fn decrement(self: *@This()) void {
_ = self.value.fetchSub(1, .monotonic);
}
pub fn get(self: *const @This()) u64 {
return self.value.load(.monotonic);
}
};
Reference Counting
const RefCounted = struct {
ref_count: std.atomic.Value(usize),
data: *Data,
pub fn retain(self: *@This()) void {
_ = self.ref_count.fetchAdd(1, .monotonic);
}
pub fn release(self: *@This()) void {
// Release ensures writes before release are visible to thread that sees 1
if (self.ref_count.fetchSub(1, .release) == 1) {
// Acquire synchronizes with all previous releases
_ = self.ref_count.load(.acquire);
self.destroy();
}
}
};
Lock-Free Stack (Treiber Stack)
Note: This simplified implementation only supports single-consumer (one thread calling pop() at a time). Multiple concurrent poppers require ABA protection (hazard pointers, epoch-based reclamation, or tagged pointers) to safely read node.next without use-after-free.
fn Stack(comptime T: type) type {
return struct {
const Node = struct {
value: T,
next: ?*Node,
};
head: std.atomic.Value(?*Node) = std.atomic.Value(?*Node).init(null),
pub fn push(self: *@This(), node: *Node) void {
var current_head = self.head.load(.acquire);
while (true) {
node.next = current_head;
if (self.head.cmpxchgWeak(current_head, node, .release, .acquire)) |actual| {
current_head = actual;
} else {
break; // Success
}
}
}
// Single-consumer only! See note above.
pub fn pop(self: *@This()) ?*Node {
var current_head = self.head.load(.acquire);
while (current_head) |node| {
if (self.head.cmpxchgWeak(current_head, node.next, .acq_rel, .acquire)) |actual| {
current_head = actual;
} else {
return node; // Success
}
}
return null; // Empty
}
};
}
Once Initialization (Double-Checked Locking)
var initialized = std.atomic.Value(bool).init(false);
var init_mutex: std.Io.Mutex = .init;
var global_resource: ?*Resource = null;
fn getResource(io: std.Io) *Resource {
// Fast path: already initialized
if (initialized.load(.acquire)) {
return global_resource.?;
}
// Slow path: initialize with lock
// This variant deliberately makes initialization uncancelable. Use
// `try init_mutex.lock(io)` in a cancelable function instead.
init_mutex.lockUncancelable(io);
defer init_mutex.unlock(io);
if (!initialized.load(.acquire)) {
global_resource = initializeResource();
initialized.store(true, .release);
}
return global_resource.?;
}
Spin Lock
const SpinLock = struct {
locked: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
pub fn lock(self: *@This()) void {
while (self.locked.swap(true, .acquire)) {
while (self.locked.load(.monotonic)) {
std.atomic.spinLoopHint();
}
}
}
pub fn unlock(self: *@This()) void {
self.locked.store(false, .release);
}
pub fn tryLock(self: *@This()) bool {
return !self.locked.swap(true, .acquire);
}
};
Progress Flag (SeqLock)
Note: Data must be a type that supports atomic load/store (integers, bools, enums, floats, pointers). For larger structs, use a pointer or different synchronization.
const SeqLock = struct {
seq: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
data: Data = .{},
// Single-writer only!
pub fn write(self: *@This(), new_data: Data) void {
// Odd sequence = write in progress
_ = self.seq.fetchAdd(1, .release);
@atomicStore(Data, &self.data, new_data, .unordered);
_ = self.seq.fetchAdd(1, .release);
}
pub fn read(self: *@This()) Data {
while (true) {
const seq1 = self.seq.load(.acquire);
if (seq1 & 1 != 0) {
std.atomic.spinLoopHint();
continue; // Write in progress
}
const data = @atomicLoad(Data, &self.data, .unordered);
const seq2 = self.seq.load(.acquire);
if (seq1 == seq2) return data;
std.atomic.spinLoopHint();
}
}
};
Barrier Synchronization
const Barrier = struct {
counter: std.atomic.Value(usize),
generation: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
total: usize,
pub fn init(count: usize) @This() {
return .{
.counter = std.atomic.Value(usize).init(count),
.total = count,
};
}
pub fn wait(self: *@This()) void {
const gen = self.generation.load(.acquire);
if (self.counter.fetchSub(1, .acq_rel) == 1) {
// Last thread to arrive
self.counter.store(self.total, .release);
_ = self.generation.fetchAdd(1, .release);
} else {
// Wait for generation to change
while (self.generation.load(.acquire) == gen) {
std.atomic.spinLoopHint();
}
}
}
};
See Also
- std.Io synchronization - Blocking
Mutex,RwLock,Condition,Semaphore,Event, and futex methods - std.Thread - Thread creation, joining, and thread-local facilities