Skip to content

Atomics

A mutex makes one thread wait while another thread uses shared data.

A mutex makes one thread wait while another thread uses shared data.

An atomic operation is different. It performs one small memory operation as a single indivisible step.

A counter is the usual example.

const std = @import("std");

var counter = std.atomic.Value(u32).init(0);

fn increment() void {
    var i: u32 = 0;

    while (i < 100000) : (i += 1) {
        _ = counter.fetchAdd(1, .monotonic);
    }
}

pub fn main() !void {
    const t1 = try std.Thread.spawn(.{}, increment, .{});
    const t2 = try std.Thread.spawn(.{}, increment, .{});

    t1.join();
    t2.join();

    std.debug.print("counter = {d}\n", .{counter.load(.monotonic)});
}

The value is declared as an atomic value:

var counter = std.atomic.Value(u32).init(0);

This means the memory is accessed through atomic operations.

The increment is:

_ = counter.fetchAdd(1, .monotonic);

fetchAdd adds to the value and returns the old value. The operation happens atomically. Two threads may call it at the same time, but no increment is lost.

The final read is:

counter.load(.monotonic)

Atomic values are read with load and written with store.

For a simple counter, .monotonic is enough. It guarantees atomicity for the counter itself. It does not use the counter to order other memory operations.

Atomics are good for small independent values:

var done = std.atomic.Value(bool).init(false);
var count = std.atomic.Value(u64).init(0);
var index = std.atomic.Value(usize).init(0);

They are not a replacement for mutexes.

A mutex protects an invariant across several fields:

const State = struct {
    mutex: std.Thread.Mutex = .{},
    len: usize = 0,
    capacity: usize = 0,
    ptr: [*]u8 = undefined,
};

These fields must agree with one another. Updating only one field atomically does not protect the whole structure.

Use a mutex when the operation has several steps that must be seen as one operation.

Use an atomic when the shared state is one small value and the operation is naturally atomic.

Atomic operations take a memory ordering.

The ordering tells the compiler and CPU how much reordering is allowed around the operation.

For many counters, .monotonic is the right starting point.

For synchronization between threads, stronger orderings may be needed.

A common pattern is a flag:

const std = @import("std");

var ready = std.atomic.Value(bool).init(false);
var data: u32 = 0;

fn producer() void {
    data = 123;
    ready.store(true, .release);
}

fn consumer() void {
    while (!ready.load(.acquire)) {}

    std.debug.print("data = {d}\n", .{data});
}

pub fn main() !void {
    const t1 = try std.Thread.spawn(.{}, producer, .{});
    const t2 = try std.Thread.spawn(.{}, consumer, .{});

    t1.join();
    t2.join();
}

The producer writes data, then stores true with .release.

The consumer waits until it reads true with .acquire.

The acquire and release operations form a synchronization pair. After the consumer sees the flag, it also sees the data written before the release store.

The loop:

while (!ready.load(.acquire)) {}

is a spin loop. It keeps checking the atomic flag until it changes.

Spin loops should be used carefully. They waste CPU while waiting. They are acceptable only for short waits or low-level code.

For longer waits, use a condition variable, semaphore, channel, or another blocking mechanism.

Atomic compare-and-exchange is used when the update depends on the current value.

Example: set a value only if it is still zero.

const std = @import("std");

var value = std.atomic.Value(u32).init(0);

pub fn main() void {
    const result = value.cmpxchgStrong(
        0,
        42,
        .monotonic,
        .monotonic,
    );

    if (result == null) {
        std.debug.print("stored 42\n", .{});
    } else |old| {
        std.debug.print("value was already {d}\n", .{old});
    }
}

cmpxchgStrong compares the current value with the expected value.

If the current value is 0, it stores 42 and returns null.

If the current value is not 0, it returns the actual old value.

This operation is the basis for many lock-free data structures, but lock-free code is hard to write correctly.

Prefer simple designs.

Use message passing when possible. Use a mutex when the state has structure. Use atomics when the state is small and the memory ordering is obvious.

Exercise 18-9. Rewrite the counter example with four threads.

Exercise 18-10. Change the counter type from u32 to u64.

Exercise 18-11. Write a program with an atomic boolean flag. One thread sets the flag. Another waits until it becomes true.

Exercise 18-12. Use cmpxchgStrong to allow only one thread to become the winner.