Para escolher entre mutex e atomic em Zig, use esta regra: se você protege uma invariável com vários campos ou vários passos, escolha std.Thread.Mutex; se atualiza um valor pequeno e independente com uma única operação, considere std.atomic.Value. Contadores, flags e IDs são bons candidatos a atomic. Mapas, filas, caches e transições compostas normalmente pedem mutex.
Atomic não é um “mutex mais rápido”. Ele transfere a complexidade do bloqueio para um protocolo de memória que precisa continuar correto em todas as interleavings possíveis. Um mutex simples costuma vencer em legibilidade, manutenção e até desempenho quando permite agrupar trabalho e reduzir disputa na mesma linha de cache.
Este guia compara os dois mecanismos, mostra exemplos seguros, explica monotonic, acquire e release sem jargão desnecessário e oferece um roteiro de decisão para código de produção.
Resposta rápida: mutex ou atomic?
| Situação | Escolha inicial | Motivo |
|---|---|---|
| Contador de requisições | Atomic | Uma atualização independente com fetchAdd |
| Flag de shutdown | Atomic | Um booleano publicado entre threads |
| Gerador de IDs | Atomic | Incremento indivisível, sem invariável externa |
| Saldo + limite + histórico | Mutex | Vários valores precisam mudar juntos |
HashMap compartilhado | Mutex | A operação altera estrutura interna composta |
| Fila produtor-consumidor | Mutex + Condition | Estado composto e espera eficiente |
| Estatísticas com muitos campos | Mutex ou dados por thread | Snapshot consistente e menos contenção |
| Estrutura lock-free especializada | Atomic, após prova e benchmark | O protocolo inteiro depende de ordenação de memória |
A coluna diz escolha inicial porque o perfil da carga importa. Comece com a solução mais fácil de provar correta. Só troque depois de medir contenção real com um benchmark que represente número de threads, taxa de escrita, arquitetura e tamanho dos dados da aplicação.
O que um Mutex garante
Um mutex dá exclusividade sobre uma região crítica. Enquanto uma thread mantém o lock, nenhuma outra que respeite o mesmo mutex entra naquela região.
const std = @import("std");
const Account = struct {
balance: i64 = 0,
reserved: i64 = 0,
mutex: std.Thread.Mutex = .{},
pub fn reserve(self: *Account, amount: i64) !void {
self.mutex.lock();
defer self.mutex.unlock();
const available = self.balance - self.reserved;
if (amount < 0 or amount > available) {
return error.InsufficientFunds;
}
self.reserved += amount;
}
pub fn snapshot(self: *Account) struct { balance: i64, reserved: i64 } {
self.mutex.lock();
defer self.mutex.unlock();
return .{
.balance = self.balance,
.reserved = self.reserved,
};
}
};
O lock protege mais do que duas variáveis. Ele protege a regra reserved <= balance e a sequência “calcular disponível, validar, atualizar”. Transformar balance e reserved em atomics separados impediria data races em cada inteiro, mas não garantiria que outra thread observasse uma combinação válida entre eles.
Esse é o principal critério: atomicidade de campo não equivale a atomicidade da operação de negócio.
Mutex também comunica intenção. Durante code review, o leitor identifica o início e o fim da região crítica. O desafio passa a ser verificar se todos os acessos usam o mesmo lock, se a ordem entre locks evita deadlock e se o trabalho caro acontece fora da região protegida.
O que um Atomic garante
std.atomic.Value(T) oferece operações indivisíveis sobre tipos compatíveis. Duas threads podem chamar fetchAdd no mesmo contador sem perder incrementos.
const std = @import("std");
const Metrics = struct {
requests: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
failures: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
pub fn record(self: *Metrics, failed: bool) void {
_ = self.requests.fetchAdd(1, .monotonic);
if (failed) {
_ = self.failures.fetchAdd(1, .monotonic);
}
}
};
Aqui cada contador é independente. Um painel pode observar requests = 100 e failures = 4, depois requests = 101 e ainda failures = 4 por um instante. Isso é aceitável para telemetria aproximada. Se a aplicação exige um snapshot em que todos os campos correspondam exatamente ao mesmo evento, atomics separados não bastam.
Atomic garante que a operação no valor não seja rasgada nem perdida. A memory ordering define como essa operação se relaciona com outros acessos à memória.
Memory ordering em linguagem prática
A ordenação não é uma escolha de “quanto quero que seja atômico”. A operação já é atômica. A escolha responde: quais outras escritas de uma thread precisam ficar visíveis para outra thread quando ela observa este valor?
monotonic: contador sem mensagem escondida
Use monotonic quando o valor só representa a si mesmo. Um contador de chamadas é o exemplo clássico:
_ = metrics.requests.fetchAdd(1, .monotonic);
A aplicação precisa de incrementos indivisíveis, mas observar o número 42 não promete que algum outro buffer ou objeto está pronto.
release e acquire: publicar dados prontos
Considere uma thread que preenche uma configuração e depois marca ready = true. Outra espera a flag e então lê a configuração.
const std = @import("std");
const Shared = struct {
config: Config = undefined,
ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
};
const Config = struct {
port: u16,
workers: usize,
};
fn publish(shared: *Shared) void {
shared.config = .{ .port = 8080, .workers = 4 };
shared.ready.store(true, .release);
}
fn consume(shared: *Shared) ?Config {
if (!shared.ready.load(.acquire)) return null;
return shared.config;
}
O store(.release) publica as escritas anteriores. O load(.acquire) que observa true passa a enxergar a configuração inicializada antes da publicação.
Esse padrão é correto apenas porque existe um escritor antes da publicação e os leitores não alteram config. Se a configuração puder ser substituída enquanto leitores a usam, surgem problemas de ownership, tempo de vida e recuperação de memória. Nesse caso, um mutex ou um desenho de snapshots imutáveis costuma ser mais seguro.
Ordem forte não conserta protocolo errado
Escolher a ordem mais forte disponível pode simplificar o raciocínio inicial, mas não transforma vários atomics em uma transação. Também não resolve ponteiro liberado cedo demais, acesso não atômico concorrente ao mesmo endereço ou uma invariável dividida entre campos.
Quando a explicação da ordem de memória começa a depender de “provavelmente”, recue. Use mutex, escreva testes de estresse e só retome um protocolo lock-free com uma especificação clara.
Exemplo clássico: flag de parada
Uma flag de shutdown é um bom uso de atomic porque tem um único significado e não exige bloquear uma thread apenas para consultar um booleano.
const std = @import("std");
const Worker = struct {
stopping: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
pub fn run(self: *Worker) void {
while (!self.stopping.load(.acquire)) {
processOneBatch();
}
}
pub fn stop(self: *Worker) void {
self.stopping.store(true, .release);
}
};
fn processOneBatch() void {
// Trabalho limitado: o loop precisa voltar a consultar stopping.
}
O detalhe operacional é importante: atomic não acorda uma thread bloqueada em leitura de socket, Condition.wait ou outra chamada demorada. Ele só torna a flag consultável com segurança. Para workers que dormem esperando fila, combine estado protegido e notificação como no guia de fila limitada com backpressure. Para serviços, veja também graceful shutdown com SIGTERM e SIGINT.
Por que atomic pode ficar lento sob contenção
Um contador atômico compartilhado parece mínimo: uma variável e uma instrução. Mas cada escrita precisa obter controle exclusivo da linha de cache que contém o valor. Com muitas threads em CPUs diferentes, essa linha viaja entre caches repetidamente.
O resultado possível é um hot spot de coerência. Adicionar threads aumenta a disputa em vez de aumentar throughput. As alternativas incluem:
- um contador por thread ou por shard, somado apenas na leitura;
- acumular localmente e publicar em lotes;
- proteger várias atualizações relacionadas com um único mutex;
- reduzir a frequência da métrica;
- aceitar amostragem quando precisão total não é necessária.
Um mutex sem contenção também pode ter caminho rápido barato. E, quando existe contenção, o sistema operacional pode estacionar threads em vez de deixá-las competir continuamente pela linha de cache. Portanto, “não bloqueia” não significa “não espera” nem “usa menos CPU”.
Compare-and-swap: útil, mas não gratuito
Algoritmos lock-free frequentemente usam compare-and-swap: ler um estado, calcular o próximo e tentar publicar apenas se ninguém mudou o valor. Se a tentativa falha, o loop recomeça.
O padrão conceitual é:
var current = value.load(.acquire);
while (true) {
const next = calculate(current);
if (tryCompareExchange(&value, current, next)) {
break;
} else |observed| {
current = observed;
}
}
Os nomes e assinaturas exatos das operações de compare-and-swap podem mudar entre versões do Zig; confira a API da versão fixada no projeto. O ponto arquitetural permanece: sob disputa, a função calculate pode rodar várias vezes, e uma thread específica pode perder repetidamente.
Também aparecem problemas que um contador simples não mostra:
- ABA: o valor muda de A para B e volta a A, enganando uma comparação;
- reclamation: quando é seguro liberar um nó que outros leitores talvez ainda alcancem?;
- starvation: uma thread progride, outra repete indefinidamente;
- falsa partilha: atomics independentes ocupam a mesma linha de cache;
- portabilidade: custo e suporte variam por tipo e arquitetura.
Se você precisa de uma fila comum, comece com mutex e condition. Só implemente fila lock-free quando o perfil provar que o lock é o gargalo e o contrato SPSC, MPSC ou MPMC estiver definido. A referência de std.atomic ajuda com as operações, mas não substitui a prova do algoritmo.
Como decidir em cinco perguntas
1. Quantos valores formam a invariável?
Um valor independente favorece atomic. Dois ou mais campos que precisam concordar favorecem mutex.
2. A operação cabe em uma primitiva atômica?
fetchAdd, store, load e swap são bons sinais. “Consultar mapa, validar, alocar, inserir e atualizar LRU” é uma região crítica, não uma operação atômica simples.
3. Leitores precisam de snapshot consistente?
Se sim, mutex é a escolha direta. Para evitar lock em leitura, snapshots imutáveis podem funcionar, mas exigem uma estratégia segura para publicar e liberar versões antigas.
4. Existe espera real ou só uma hipótese de performance?
Sem medição, escolha clareza. Instrumente tempo esperando lock, throughput e latência de cauda antes de reescrever.
5. A equipe consegue explicar a memory ordering?
A explicação precisa nomear a escrita publicada, a operação release, a operação acquire correspondente e o tempo de vida dos dados. Se isso não cabe em um comentário curto e verificável, o protocolo está complexo demais para o benefício esperado.
Benchmark correto para mutex vs atomic
Um microbenchmark que apenas incrementa um valor em um loop mede o pior caso de contenção artificial. Ele é útil, mas não responde sozinho qual desenho funciona na aplicação.
Teste pelo menos estas dimensões:
- uma, duas, quatro e tantas threads quanto CPUs relevantes;
- leituras raras versus frequentes;
- quantidade real de trabalho entre atualizações;
- contador único versus sharding;
- p50, p95 e p99, não apenas operações por segundo;
- build
ReleaseFaste, quando segurança importa,ReleaseSafe; - a arquitetura de produção, porque x86-64 e ARM não têm custos idênticos.
Evite otimizar uma primitiva isolada enquanto o sistema passa a maior parte do tempo em alocação, I/O ou parsing. Use as técnicas do guia de profiling com perf e flamegraph para localizar o gargalo antes de trocar o modelo de sincronização.
Erros comuns
Usar volatile para sincronizar threads. volatile não substitui atomic nem mutex. Ele serve a acessos cujo efeito externo precisa ser preservado, como memória mapeada de dispositivo; não cria o protocolo de sincronização entre threads.
Misturar acesso atômico e não atômico ao mesmo valor. Se um endereço é compartilhado e pode ser escrito concorrentemente, todos os acessos relevantes precisam seguir o mesmo protocolo.
Proteger a escrita, mas não a leitura. Ler uma struct sem lock enquanto outra thread a altera continua errado, mesmo que o escritor use mutex perfeitamente.
Segurar mutex durante I/O. Copie ou retire o estado necessário, solte o lock e só então faça disco, rede, compressão ou callback de usuário.
Usar spin lock para espera longa. Spin consome CPU enquanto espera. Ele só faz sentido em contextos muito específicos, com regiões minúsculas e medição cuidadosa.
Confundir lock-free com simples. Uma implementação curta pode esconder requisitos complexos de ordenação, ownership e progresso.
Checklist para code review
Antes de aprovar código concorrente em Zig, confirme:
- cada variável compartilhada tem um protocolo explícito;
- a invariável protegida pelo mutex está documentada;
- todos os acessos à invariável usam o mesmo lock;
- a região crítica não faz I/O nem chama código arbitrário;
- atomics representam estado pequeno e independente;
-
monotonic,acquireereleasetêm justificativa concreta; - o tempo de vida dos dados publicados é maior que o dos leitores;
- shutdown acorda threads bloqueadas, não apenas muda uma flag;
- o benchmark reproduz a contenção de produção;
- a versão do Zig está fixada no build e no CI.
Conclusão
Na maioria dos projetos, a escolha segura é simples: mutex para estado composto; atomic para estado independente. Comece por correção e legibilidade. Um lock visível em torno de uma invariável é mais fácil de testar, revisar e manter do que um protocolo lock-free cujo benefício ainda não foi medido.
Use atomic para contadores, flags e IDs quando a semântica couber em uma operação. Use mutex para mapas, filas, snapshots consistentes e transações entre campos. Se a contenção aparecer no profiler, reduza a região crítica, faça sharding ou mova trabalho para fora do lock antes de partir para compare-and-swap.
Para continuar, leia concorrência avançada em Zig e o tutorial de threads e concorrência. A melhor primitiva não é a que parece mais sofisticada: é a que mantém o contrato correto sob carga e ainda pode ser explicada pela equipe seis meses depois.
Perguntas frequentes
Atomic é sempre mais rápido que Mutex?
Não. Um atomic muito disputado pode gerar tráfego intenso de coerência de cache. Mutex pode ter caminho rápido sem contenção e permite agrupar operações. Meça o padrão real.
Posso usar atomic para proteger uma struct inteira?
Só quando o tipo e o alvo suportam a operação e toda a invariável cabe naquele valor. Na prática, structs mutáveis com vários campos normalmente ficam mais claras e portáveis sob mutex.
monotonic é seguro para contador?
Sim, quando o contador só representa o próprio número e não publica a disponibilidade de outros dados. Se observar o valor deve tornar outras escritas visíveis, você precisa de um protocolo como release/acquire.
Uma flag atomic acorda uma thread bloqueada?
Não. A flag só pode ser observada quando a thread volta a executar e faz load. Para espera bloqueante, use Condition, event loop, cancelamento da operação de I/O ou outro mecanismo de notificação.
Quando vale implementar lock-free?
Quando um profiler mostra que sincronização é gargalo, o padrão de produtores e consumidores está bem definido, a equipe consegue provar ordenação e tempo de vida, e benchmarks mostram ganho sustentável na arquitetura de produção.