Operações Indivisíveis — std::atomic

Operações Indivisíveis — std::atomic

Quando basta um contador ou uma flag, o mutex é peso desnecessário: o atomic costuma virar instrução do processador e não bloqueia ninguém. O limite aparece rápido, porém — ele torna indivisível uma operação, não uma sequência, e checar antes de debitar continua deixando uma janela aberta entre os dois passos.
Linguagem C++

11 min de leitura

No artigo O Cadeado que Protege — std::mutex e lock_guard o mutex consertou nossa corrida de dados, e o lock_guard garantiu a liberação segura do cadeado via RAII. Mas para o caso específico que abriu a fase — um simples contador incrementado por várias threads — usar um mutex é como usar uma chave de cofre para trancar uma gaveta: funciona, mas há algo mais leve. Esse algo é o std::atomic. Um tipo atômico torna certas operações indivisíveis por hardware: o processador garante que o incremento aconteça como uma unidade que nenhuma outra thread pode interromper no meio, sem a necessidade de um cadeado explícito. Hoje você aprende essa ferramenta, entende por que ela é mais eficiente que o mutex para casos simples, e — crucialmente — quando ela basta e quando você ainda precisa do mutex. É a aula que afina seu julgamento sobre qual instrumento de sincronização escolher.

O contador, agora atômico

Lembre-se do problema: ++contador são três micro-operações (ler, somar, escrever) que, entrelaçadas entre threads, perdem incrementos. O std::atomic<int> resolve isso fazendo o incremento ser uma operação única e indivisível, garantida pelo hardware:

#include <iostream>
#include <thread>
#include <vector>
#include <atomic>   // std::atomic

int main() {
    std::atomic<int> contador{0};   // um int atômico, inicializado em 0

    auto incrementa = [&contador]() {
        for (int i = 0; i < 100000; ++i)
            ++contador;   // INDIVISÍVEL: o hardware garante que ninguém interrompa no meio
    };

    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i)
        threads.emplace_back(incrementa);
    for (auto& t : threads)
        t.join();

    std::cout << "contador = " << contador << '\n';   // 400000 — sempre correto!
    return 0;
}

Compare com a versão do mutex: sumiram o std::mutex e o std::lock_guard. O ++contador sobre um std::atomic<int> já é seguro por si só — o hardware executa a sequência ler-somar-escrever como um bloco indivisível, então nenhum incremento se perde. O resultado é sempre 400000, e o código é mais curto e mais rápido. Isso é o que "atômico" significa: uma operação que ou acontece por inteiro, ou não acontece — nunca pela metade, do ponto de vista das outras threads.

Por que atomic é mais leve que mutex

A diferença de custo é real e vale entender. Um mutex, quando disputado, pode fazer a thread dormir (ceder o processador ao sistema operacional) e ser acordada depois — operações que envolvem o núcleo do sistema e são relativamente caras. Um std::atomic, para tipos simples, costuma usar instruções especiais do próprio processador (como compare-and-swap) que realizam a operação atômica diretamente no hardware, sem envolver o sistema operacional nem colocar a thread para dormir. Para um contador disputado por muitas threads, isso é significativamente mais rápido. A regra prática que emerge: para uma única variável simples compartilhada (um contador, uma flag, um ponteiro), prefira std::atomic; ele é mais leve que um mutex.

Além do incremento, os atômicos oferecem operações dedicadas que expressam intenções comuns de forma segura:

#include <iostream>
#include <atomic>

int main() {
    std::atomic<int> a{10};

    a.fetch_add(5);         // soma 5 atomicamente; devolve o valor ANTERIOR
    std::cout << a << '\n'; // 15

    int esperado = 15;
    // compare_exchange: "se a valer 'esperado', troque por 20". Base de algoritmos lock-free.
    a.compare_exchange_strong(esperado, 20);
    std::cout << a << '\n'; // 20

    std::atomic<bool> pronto{false};
    pronto.store(true);           // escrita atômica
    std::cout << pronto.load();   // leitura atômica → 1
    return 0;
}

fetch_add, store, load, compare_exchange — são as operações atômicas fundamentais. A store e a load (escrita e leitura atômicas) são especialmente úteis para uma flag compartilhada — por exemplo, um std::atomic<bool> parar que uma thread liga para sinalizar às outras que devem encerrar.

O limite honesto: atomic só serve para operações simples

Agora a fronteira crucial, e prometo a honestidade de sempre. O std::atomic só torna atômicas operações individuais sobre uma única variável. Ele não resolve o caso em que você precisa manter várias variáveis consistentes entre si, ou realizar uma sequência de passos como uma unidade. Considere uma conta bancária com saldo e limite: verificar o saldo e então debitar são dois passos que precisam acontecer juntos, sem interferência. Nenhum tipo atômico único cobre isso:

// std::atomic NÃO resolve isto:
std::atomic<int> saldo{100};
// Thread quer: "se saldo >= valor, debitar valor". São DOIS passos.
if (saldo >= 50)      // passo 1: lê
    saldo -= 50;      // passo 2: modifica
// Entre o passo 1 e o 2, outra thread pode ter debitado! O saldo pode ficar negativo.
// Cada operação é atômica, mas a SEQUÊNCIA não é.

Aqui, cada acesso a saldo é atômico, mas a lógica composta ("checar e então debitar") não é — outra thread pode debitar entre os dois passos, quebrando a regra. Para proteger uma sequência de operações ou manter múltiplas variáveis coerentes, você precisa de um mutex. A diretriz completa, então: use std::atomic para operações simples e indivisíveis sobre uma variável única (contadores, flags); use std::mutex quando precisar proteger uma região de código, uma sequência de operações, ou a consistência entre várias variáveis. Escolher errado — usar atomic onde precisava de mutex — recria a corrida de dados de forma sutil.

Uma nota sobre a complexidade que estamos poupando

Devo uma transparência: o std::atomic tem uma profundidade que estou deliberadamente evitando — os memory orderings (como memory_order_relaxed, memory_order_acquire), que controlam finamente como as operações atômicas se ordenam em relação a outras operações de memória entre threads. Esse é um dos cantos mais difíceis do C++, terreno de especialistas em programação lock-free, e usar os padrões (o comportamento default, memory_order_seq_cst, o mais seguro) é o correto para a esmagadora maioria dos casos — inclusive todos os desta série. Menciono isso não para ensiná-lo agora, mas para você saber que existe e não se assustar ao encontrá-lo: os exemplos desta aula usam o comportamento padrão, que é seguro e suficiente. A regra de ouro do C++ concorrente para não-especialistas: use os defaults, prefira mutex e atomic simples, e só desça aos memory orderings se um profiler provar que você precisa e você souber exatamente o que está fazendo.

O atomic é mais leve que o mutex porque costuma virar instrução do processador, sem bloquear thread nenhuma — e é exatamente por isso que seu alcance é limitado. Ele torna indivisível uma operação, não uma sequência: checar o estoque e depois debitá-lo são dois passos atômicos com uma janela entre eles, e essa janela basta para o erro. A regra prática separa bem os casos — contador e flag pedem atomic; qualquer invariante que envolva mais de uma variável pede mutex.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/atomic/atomic: a referência completa de std::atomic, com fetch_add, compare_exchange, load, store e os memory orderings.
  • Anthony Williams, C++ Concurrency in Action (2ª ed.), capítulo 5 ("The C++ memory model and operations on atomic types"): o tratamento definitivo de atômicos e ordenação de memória.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre operações atômicas: a apresentação do atomic no contexto do modelo de concorrência.
  • cppreference.com/w/cpp/atomic/memory_order: os detalhes dos memory orderings, para quando você precisar (e para saber quando não precisa).
  • ISO C++ Core Guidelines, regras CP.100CP.201 (sobre programação lock-free): as diretrizes que, sabiamente, recomendam cautela com atômicos avançados.

Exercícios

Exercício 1

Reescreva o contador da aula usando std::atomic<int> (sem mutex) e confirme que o resultado é sempre 400000. Compare o código com a versão do mutex do artigo O Cadeado que Protege — std::mutex e lock_guard.

Ver resposta

✓ Resposta: Contador atômico:

#include <iostream>
#include <thread>
#include <vector>
#include <atomic>
int main() {
    std::atomic<int> contador{0};
    auto inc = [&]{ for (int i = 0; i < 100000; ++i) ++contador; };
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i) ts.emplace_back(inc);
    for (auto& t : ts) t.join();
    std::cout << contador << '\n';   // 400000
    return 0;
}

Comparado à versão do mutex, sumiram o std::mutex e o std::lock_guard — o ++contador atômico é seguro por si só, resultando em código mais curto e mais rápido.

Exercício 2

Explique, em termos das micro-operações do incremento, por que ++contador sobre um std::atomic<int> não perde incrementos, enquanto sobre um int comum perde.

Ver resposta

✓ Resposta: Sobre um int comum, ++contador é ler-somar-escrever em três etapas separadas; duas threads podem ler o mesmo valor antigo antes de qualquer uma escrever, e um incremento se perde. Sobre um std::atomic<int>, o ++ é executado pelo hardware como uma operação indivisível: a leitura, a soma e a escrita acontecem como um bloco único que nenhuma outra thread pode interromper ou observar pela metade. Enquanto uma thread faz o incremento atômico, nenhuma outra consegue ler-e-escrever no meio dele, então cada incremento é contabilizado — nenhum se perde. A diferença é a indivisibilidade garantida pelo processador.

Exercício 3

Crie um std::atomic<bool> parar{false} e duas threads: uma que fica num laço while (!parar) { ... } fazendo trabalho, e outra que, após um tempo, faz parar.store(true) para sinalizar o fim. Explique por que atomic<bool> é a ferramenta certa aqui.

Ver resposta

✓ Resposta: Flag de parada:

#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>
int main() {
    std::atomic<bool> parar{false};
    std::thread trabalhador([&parar]() {
        long long contagem = 0;
        while (!parar) ++contagem;   // trabalha até ser sinalizado
        std::cout << "parei após " << contagem << " iterações\n";
    });
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    parar.store(true);   // sinaliza o fim
    trabalhador.join();
    return 0;
}

atomic<bool> é a ferramenta certa porque a flag é acessada por duas threads (uma lê no while, outra escreve com store), e sem atomicidade isso seria uma corrida de dados — comportamento indefinido, podendo inclusive fazer o compilador "otimizar" a leitura e nunca ver a mudança. O atômico garante que a escrita de uma thread seja corretamente visível à leitura da outra, e que não haja corrida. Para uma única flag booleana, é mais leve que um mutex.

Exercício 4

O código abaixo usa std::atomic mas ainda contém uma corrida lógica. Explique por que o atomic não basta e o que seria necessário para corrigir.

std::atomic<int> estoque{10};
void vende(int qtd) {
    if (estoque >= qtd)    // passo 1
        estoque -= qtd;    // passo 2
}
Ver resposta

✓ Resposta: O atomic não basta porque a operação "vender" é composta de dois passos — checar estoque >= qtd (passo 1) e debitar estoque -= qtd (passo 2) — e, embora cada um seja atômico isoladamente, a sequência não é. Entre o passo 1 e o passo 2, outra thread pode debitar o estoque, de modo que a checagem que aprovou a venda já não vale quando o débito ocorre — o estoque pode ficar negativo (vende-se mais do que há). Para corrigir, é preciso tornar os dois passos uma unidade indivisível, o que exige um std::mutex protegendo toda a função vende:

std::mutex mtx;
int estoque = 10;
void vende(int qtd) {
    std::lock_guard<std::mutex> g(mtx);
    if (estoque >= qtd) estoque -= qtd;   // checar e debitar como uma unidade
}

(Alternativamente, um laço com compare_exchange implementaria isso de forma lock-free, mas o mutex é mais claro e correto para a maioria dos casos.)

Exercício 5

Formule a regra de decisão entre std::atomic e std::mutex. Para cada cenário, diga qual usar e por quê: (a) um contador de requisições; (b) uma flag de "servidor rodando"; (c) mover dinheiro entre duas contas mantendo a soma constante; (d) inserir elementos num std::vector compartilhado.

Ver resposta

✓ Resposta: Regra de decisão: use std::atomic para operações individuais e indivisíveis sobre uma única variável; use std::mutex para proteger sequências de operações, regiões de código, ou a consistência entre múltiplas variáveis. Aplicando: - (a) Contador de requisições: std::atomic<int> — é uma única variável com incrementos simples, o caso ideal do atômico, mais leve que mutex. - (b) Flag "servidor rodando": std::atomic<bool> — uma única flag booleana lida e escrita por threads; atomic basta e é eficiente. - (c) Mover dinheiro entre duas contas: std::mutex (ou std::scoped_lock sobre dois mutexes) — envolve duas variáveis que precisam permanecer consistentes (a soma constante), e a transferência é uma sequência (debitar uma, creditar outra) que deve ser atômica como um todo; atomic não cobre isso. - (d) Inserir num std::vector compartilhado: std::mutexpush_back é uma operação composta que pode realocar e mover todos os elementos, e o vector não é uma variável atômica simples; toda a inserção precisa ser protegida por um mutex para evitar corrupção da estrutura.

Comentários

Mais em Linguagem C++

Extensão — Páginas Dinâmicas: HTML Gerado com Templates
Extensão — Páginas Dinâmicas: HTML Gerado com Templates

Montar HTML concatenando string quebra por dois motivos ao mesmo tempo: fica…

Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores
Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores

Somar com mais, comparar com igual, imprimir direto no cout: operadores são…

Um Valor que Pode Não Existir — std::optional
Um Valor que Pode Não Existir — std::optional

Combinar que -1 significa ausência funciona até alguém esquecer de verificar…