🎓 A1Learner
🔍
0 of 46 lessons completed

Zig Atomics Deep

An atomic value is a value that threads can read and change without a mutex. Its operations happen as one indivisible step.

Orderings on one value

fetchAdd returns the old value, 0. A release store then sets 10, and an acquire load reads 10 back.

const std = @import("std");
pub fn main() !void {
    var v = std.atomic.Value(i32).init(0);
    const old = v.fetchAdd(5, .acq_rel);
    v.store(10, .release);
    const cur = v.load(.acquire);
    const out = std.io.getStdOut().writer();
    try out.print("{d} {d}\n", .{ old, cur });
}

A swap that succeeds, then one that fails

The first swap matches the expected value, so it succeeds and returns nothing. The second swap expects 99, so it fails and returns the current value, 20.

const std = @import("std");
pub fn main() !void {
    var v = std.atomic.Value(i32).init(10);
    const first = v.cmpxchgStrong(10, 20, .acq_rel, .acquire);
    const second = v.cmpxchgStrong(99, 30, .acq_rel, .acquire);
    const out = std.io.getStdOut().writer();
    try out.print("{?} {d} {?}\n", .{ first, v.load(.seq_cst), second });
}

Four threads share one counter

Each thread adds 1 twenty-five times with fetchAdd. The total is always 100, because no add is ever lost.

const std = @import("std");
const Context = struct {
    counter: std.atomic.Value(i32) = std.atomic.Value(i32).init(0),
    fn inc(ctx: *@This()) void {
        var k: usize = 0;
        while (k < 25) : (k += 1) {
            _ = ctx.counter.fetchAdd(1, .acq_rel);
        }
    }
};
pub fn main() !void {
    var ctx = Context{};
    var threads: [4]std.Thread = undefined;
    for (&threads) |*t| {
        t.* = try std.Thread.spawn(.{}, Context.inc, .{&ctx});
    }
    for (threads) |t| {
        t.join();
    }
    const out = std.io.getStdOut().writer();
    try out.print("{d}\n", .{ctx.counter.load(.seq_cst)});
}

Example

const std = @import("std");
const Shared = struct {
    data: i32 = 0,
    ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
    fn write(sh: *@This()) void {
        sh.data = 42;
        sh.ready.store(true, .release);
    }
};
pub fn main() !void {
    var sh = Shared{};
    const t = try std.Thread.spawn(.{}, Shared.write, .{&sh});
    t.join();
    const ready = sh.ready.load(.acquire);
    const out = std.io.getStdOut().writer();
    try out.print("{d} {}\n", .{ sh.data, ready });
}

Key points

  • Atomic value: an atomic value is a value whose read and change operations cannot be split by another thread.
  • Memory ordering: a memory ordering is a rule about which other reads and writes become visible when an atomic operation runs.
  • Acquire: an acquire load is a load that sees writes made before a matching release store.
  • Release: a release store is a store that makes earlier writes visible to a thread that acquires the same value.
  • Acq_rel: an acq_rel operation is an operation that both acquires and releases at once.
  • Seq_cst: a seq_cst operation is the strongest ordering, and all threads agree on one order of such operations.
  • fetchAdd adds to an atomic value and returns the value before the add.

The writer stores the data first, then stores the flag with release. The main thread joins the writer, then loads the flag with acquire. The join is the rendezvous in this demo, so the main thread reads the published data with no waiting loop.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

Which ordering both acquires and releases?