Como Usar Operações Atômicas em Zig

Introdução

Operações atômicas são instruções de CPU que executam de forma indivisível, sem possibilidade de interrupção por outra thread. Elas permitem sincronização entre threads sem o overhead de mutexes, sendo ideais para contadores, flags e estruturas lock-free.

Em Zig, std.atomic.Value fornece uma API segura para operações atômicas.

Pré-requisitos

Contador Atômico

O uso mais comum: um contador seguro entre threads sem mutex:

const std = @import("std");

pub fn main() !void {
    var contador = std.atomic.Value(u64).init(0);

    const num_threads = 8;
    var threads: [num_threads]std.Thread = undefined;

    for (&threads) |*t| {
        t.* = try std.Thread.spawn(.{}, struct {
            fn work(c: *std.atomic.Value(u64)) void {
                for (0..10000) |_| {
                    _ = c.fetchAdd(1, .seq_cst);
                }
            }
        }.work, .{&contador});
    }

    for (&threads) |t| {
        t.join();
    }

    const total = contador.load(.seq_cst);
    std.debug.print("Total: {d} (esperado: {d})\n", .{ total, num_threads * 10000 });
}

Saída esperada

Total: 80000 (esperado: 80000)

Operações Atômicas Disponíveis

O Zig oferece diversas operações atômicas:

const std = @import("std");

pub fn main() void {
    var valor = std.atomic.Value(i32).init(10);

    // Load: ler o valor atual
    const atual = valor.load(.seq_cst);
    std.debug.print("Valor atual: {d}\n", .{atual});

    // Store: definir um novo valor
    valor.store(42, .seq_cst);
    std.debug.print("Após store(42): {d}\n", .{valor.load(.seq_cst)});

    // fetchAdd: somar e retornar o valor anterior
    const anterior = valor.fetchAdd(8, .seq_cst);
    std.debug.print("fetchAdd(8): anterior={d}, novo={d}\n", .{
        anterior,
        valor.load(.seq_cst),
    });

    // fetchSub: subtrair e retornar o valor anterior
    const ant_sub = valor.fetchSub(10, .seq_cst);
    std.debug.print("fetchSub(10): anterior={d}, novo={d}\n", .{
        ant_sub,
        valor.load(.seq_cst),
    });

    // fetchAnd: AND bit a bit
    valor.store(0xFF, .seq_cst);
    _ = valor.fetchAnd(0x0F, .seq_cst);
    std.debug.print("fetchAnd(0x0F) de 0xFF: 0x{X}\n", .{
        @as(u32, @intCast(valor.load(.seq_cst))),
    });

    // fetchOr: OR bit a bit
    valor.store(0x0F, .seq_cst);
    _ = valor.fetchOr(0xF0, .seq_cst);
    std.debug.print("fetchOr(0xF0) de 0x0F: 0x{X}\n", .{
        @as(u32, @intCast(valor.load(.seq_cst))),
    });
}

Saída esperada

Valor atual: 10
Após store(42): 42
fetchAdd(8): anterior=42, novo=50
fetchSub(10): anterior=50, novo=40
fetchAnd(0x0F) de 0xFF: 0xF
fetchOr(0xF0) de 0x0F: 0xFF

Compare and Exchange

A operação mais poderosa: atualiza o valor apenas se ele for o esperado:

const std = @import("std");

const SpinLock = struct {
    locked: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),

    pub fn lock(self: *SpinLock) void {
        while (self.locked.cmpxchgWeak(0, 1, .acquire, .monotonic) != null) {
            // Spin: esperar até conseguir o lock
            std.atomic.spinLoopHint();
        }
    }

    pub fn unlock(self: *SpinLock) void {
        self.locked.store(0, .release);
    }
};

pub fn main() !void {
    var spin = SpinLock{};
    var dados: u64 = 0;

    var threads: [4]std.Thread = undefined;
    for (&threads) |*t| {
        t.* = try std.Thread.spawn(.{}, struct {
            fn work(s: *SpinLock, d: *u64) void {
                for (0..10000) |_| {
                    s.lock();
                    d.* += 1;
                    s.unlock();
                }
            }
        }.work, .{ &spin, &dados });
    }

    for (&threads) |t| t.join();

    std.debug.print("Dados: {d} (esperado: 40000)\n", .{dados});
}

Flag Atômica para Sinalização

Use uma flag atômica para sinalizar entre threads:

const std = @import("std");

const Flag = struct {
    valor: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),

    pub fn set(self: *Flag) void {
        self.valor.store(true, .release);
    }

    pub fn isSet(self: *Flag) bool {
        return self.valor.load(.acquire);
    }

    pub fn reset(self: *Flag) void {
        self.valor.store(false, .release);
    }
};

fn produtor(flag: *Flag) void {
    std.debug.print("Produtor: preparando dados...\n", .{});
    std.time.sleep(std.time.ns_per_s);

    std.debug.print("Produtor: dados prontos! Sinalizando.\n", .{});
    flag.set();
}

fn consumidor(flag: *Flag) void {
    std.debug.print("Consumidor: esperando dados...\n", .{});

    while (!flag.isSet()) {
        std.atomic.spinLoopHint();
    }

    std.debug.print("Consumidor: sinal recebido! Processando dados.\n", .{});
}

pub fn main() !void {
    var flag = Flag{};

    const t1 = try std.Thread.spawn(.{}, consumidor, .{&flag});
    const t2 = try std.Thread.spawn(.{}, produtor, .{&flag});

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

Estatísticas Atômicas

Colete estatísticas de forma thread-safe sem mutex:

const std = @import("std");

const Estatisticas = struct {
    requisicoes_total: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
    requisicoes_ok: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
    requisicoes_erro: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
    bytes_processados: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),

    pub fn registrarSucesso(self: *Estatisticas, bytes: u64) void {
        _ = self.requisicoes_total.fetchAdd(1, .monotonic);
        _ = self.requisicoes_ok.fetchAdd(1, .monotonic);
        _ = self.bytes_processados.fetchAdd(bytes, .monotonic);
    }

    pub fn registrarErro(self: *Estatisticas) void {
        _ = self.requisicoes_total.fetchAdd(1, .monotonic);
        _ = self.requisicoes_erro.fetchAdd(1, .monotonic);
    }

    pub fn relatorio(self: *Estatisticas) void {
        const total = self.requisicoes_total.load(.seq_cst);
        const ok = self.requisicoes_ok.load(.seq_cst);
        const erros = self.requisicoes_erro.load(.seq_cst);
        const bytes = self.bytes_processados.load(.seq_cst);

        std.debug.print("=== Relatório ===\n", .{});
        std.debug.print("Total: {d}\n", .{total});
        std.debug.print("Sucesso: {d}\n", .{ok});
        std.debug.print("Erros: {d}\n", .{erros});
        std.debug.print("Bytes: {d}\n", .{bytes});
        if (total > 0) {
            std.debug.print("Taxa de erro: {d:.1}%\n", .{
                @as(f64, @floatFromInt(erros)) / @as(f64, @floatFromInt(total)) * 100.0,
            });
        }
    }
};

fn simularWorker(stats: *Estatisticas, id: u32) void {
    var prng = std.Random.DefaultPrng.init(id);
    const rand = prng.random();

    for (0..1000) |_| {
        if (rand.intRangeAtMost(u32, 1, 100) <= 95) {
            stats.registrarSucesso(rand.intRangeAtMost(u64, 100, 5000));
        } else {
            stats.registrarErro();
        }
    }
}

pub fn main() !void {
    var stats = Estatisticas{};

    var threads: [8]std.Thread = undefined;
    for (&threads, 0..) |*t, i| {
        t.* = try std.Thread.spawn(.{}, simularWorker, .{ &stats, @as(u32, @intCast(i)) });
    }

    for (&threads) |t| t.join();

    stats.relatorio();
}

Ordens de Memória

As ordens de memória controlam como operações atômicas são visíveis entre threads:

OrdemDescriçãoUso
.monotonicSem garantias de ordenaçãoContadores, estatísticas
.acquireOperações após o load não são reordenadas antes deleLer flags/locks
.releaseOperações antes do store não são reordenadas depois deleEscrever flags/locks
.seq_cstOrdem total sequencialQuando em dúvida

Dicas e Boas Práticas

  1. Use .seq_cst quando em dúvida: É a ordem mais segura, embora possa ter pequeno custo de performance.

  2. Atômicos para dados simples: Para estruturas complexas, use mutex.

  3. spinLoopHint: Use std.atomic.spinLoopHint() em loops de espera ativa para economizar energia da CPU.

  4. Tamanho importa: Operações atômicas são mais eficientes para tipos de até 8 bytes (64 bits).

Receitas Relacionadas

Tutoriais Relacionados

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.