O Cadeado que Protege — std::mutex e lock_guard

O Cadeado que Protege — std::mutex e lock_guard

O cadeado resolve a corrida serializando o acesso, e o lock_guard garante que ele seja devolvido mesmo quando uma exceção atravessa a região crítica. O artigo trata de por que a leitura também precisa travar, de como encapsular o mutex junto do dado, e do deadlock que nasce de travar dois cadeados em ordens diferentes.
Linguagem C++

12 min de leitura

No artigo Fazendo Duas Coisas ao Mesmo Tempo — std::thread criamos threads e presenciamos um crime: quatro threads incrementando um contador compartilhado, e o resultado saindo imprevisivelmente errado a cada execução — a corrida de dados. Prometi consertá-lo, e hoje entregamos a ferramenta. O mutex (de mutual exclusion, exclusão mútua) é um cadeado: ele garante que apenas uma thread por vez entre numa região crítica de código, onde um dado compartilhado é acessado. E, fiel ao espírito do curso, veremos que a forma correta de usá-lo em C++ não é travar e destravar à mão — é deixar o RAII da Fase 2 cuidar disso com o std::lock_guard, garantindo que o cadeado seja sempre liberado, mesmo diante de uma exceção. Aquele contador quebrado sai desta aula funcionando.

O mutex: um cadeado para a região crítica

Um std::mutex tem duas operações fundamentais: lock(), que adquire o cadeado (e bloqueia se outra thread já o tem, esperando até ela liberar), e unlock(), que o libera. Entre o lock e o unlock está a região crítica — o trecho onde só uma thread pode estar por vez. Vamos primeiro ver a forma manual, para entender o mecanismo, e depois a forma correta:

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

int main() {
    int contador = 0;
    std::mutex mtx;   // o cadeado que protege 'contador'

    auto incrementa = [&contador, &mtx]() {
        for (int i = 0; i < 100000; ++i) {
            mtx.lock();     // adquire o cadeado (espera se outra thread o tem)
            ++contador;     // região crítica: só uma thread por vez aqui
            mtx.unlock();   // libera o cadeado
        }
    };

    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 — CORRETO agora!
    return 0;
}

Rode: agora o resultado é sempre 400000, exato. O mutex garante que o ++contador — aquela sequência ler-somar-escrever que se corrompia — aconteça sem interferência: enquanto uma thread está entre lock e unlock, as outras esperam. A corrida foi eliminada. Mas essa versão manual tem um defeito grave, e é aí que entra o RAII.

O problema do lock/unlock manual (e a solução RAII)

O lock/unlock à mão é perigoso pela mesma razão que o malloc/free e o fopen/fclose eram: se algo entre eles der errado — um return antecipado, ou uma exceção — o unlock() nunca é chamado, o cadeado fica travado para sempre, e todas as outras threads esperam eternamente. Um deadlock por esquecimento:

// PERIGOSO: se processa() lançar exceção, unlock() nunca roda → cadeado travado para sempre.
mtx.lock();
processa();      // e se isto lançar?
mtx.unlock();    // esta linha é pulada durante o desenrolamento da pilha

A solução é exatamente o RAII do artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática, aplicado ao cadeado. O std::lock_guard é um objeto que trava o mutex em seu construtor e o destrava em seu destrutor. Como o destrutor roda automaticamente ao fim do escopo — inclusive durante o desenrolamento de uma exceção (artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII) —, o cadeado é sempre liberado:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

int main() {
    int contador = 0;
    std::mutex mtx;

    auto incrementa = [&contador, &mtx]() {
        for (int i = 0; i < 100000; ++i) {
            // lock_guard trava mtx AGORA e o destrava ao sair deste escopo {}.
            std::lock_guard<std::mutex> trava(mtx);
            ++contador;   // região crítica
        }   // ← aqui 'trava' é destruída e o mutex é liberado, SEMPRE
    };

    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
    return 0;
}

Este é o C++ idiomático. Você nunca escreve mtx.unlock() — o lock_guard o faz por você, no fim do escopo, em qualquer caminho de saída. É o mesmo padrão do unique_ptr e do std::string: um objeto que adquire um recurso (aqui, o cadeado) no nascimento e o libera na morte. A concorrência segura em C++ é construída sobre o RAII que você aprendeu lá na Fase 2 — mais uma vez, os conceitos se costuram.

Protegendo dados de verdade: encapsulando o mutex

Na prática, o mutex e o dado que ele protege andam juntos, e a forma limpa é encapsulá-los numa classe (Fase 3), onde os métodos cuidam da trava e o usuário nem sabe que há concorrência por baixo:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

// Um contador seguro para uso concorrente: o mutex é detalhe interno.
class ContadorSeguro {
public:
    void incrementa() {
        std::lock_guard<std::mutex> trava(mtx_);
        ++valor_;
    }
    int valor() const {
        std::lock_guard<std::mutex> trava(mtx_);   // proteger a LEITURA também
        return valor_;
    }
private:
    int valor_ = 0;
    mutable std::mutex mtx_;   // 'mutable': pode ser travado mesmo em métodos const
};

int main() {
    ContadorSeguro c;
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i)
        threads.emplace_back([&c]() {
            for (int j = 0; j < 100000; ++j) c.incrementa();
        });
    for (auto& t : threads) t.join();
    std::cout << "valor final: " << c.valor() << '\n';   // 400000
    return 0;
}

Duas sutilezas valiosas. Primeira: proteja também a leitura (valor()), não só a escrita — ler um dado enquanto outra thread o modifica também é corrida de dados. Segunda: o mutable no mutex permite travá-lo dentro de um método const (a leitura); sem ele, o const de valor() impediria modificar o mutex. Encapsular assim é a melhor prática: quem usa ContadorSeguro não precisa saber de mutexes — a classe é a guardiã da própria consistência, o encapsulamento da Fase 3 servindo à segurança de threads.

A honestidade sobre mutexes: custo e deadlock à espreita

Prometo sempre os perigos, e o mutex tem dois. Primeiro, custo: travar e destravar tem overhead, e uma região crítica grande serializa as threads — se todas passam a maior parte do tempo esperando o cadeado, você perdeu o benefício da concorrência. A diretriz: mantenha regiões críticas pequenas, protegendo só o acesso ao dado compartilhado, não o trabalho pesado ao redor. Segundo, e mais grave, o deadlock: quando você precisa de dois mutexes e duas threads os adquirem em ordens opostas (A trava o mutex 1 e espera o 2; B trava o 2 e espera o 1), ambas esperam para sempre. Este é um dos bugs mais temidos da concorrência, e o C++ oferece ferramentas para evitá-lo — como std::scoped_lock (C++17), que trava vários mutexes de uma vez sem risco de ordem inconsistente, e std::unique_lock, um cadeado mais flexível que o lock_guard. Voltaremos a eles; por ora, a regra de ouro: se precisar de dois mutexes, trave-os sempre na mesma ordem, ou use std::scoped_lock.

O mutex resolve a corrida serializando o acesso, e o lock_guard garante que o cadeado seja devolvido mesmo se uma exceção atravessar a região crítica — RAII outra vez, aplicado a um recurso que não é memória. Dois pontos merecem atenção permanente: a leitura também precisa travar, porque ler enquanto outra thread escreve é igualmente corrida; e travar dois mutexes em ordens diferentes produz o impasse clássico, que std::scoped_lock evita adquirindo ambos de uma vez.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/thread/mutex e /w/cpp/thread/lock_guard: as referências de std::mutex e std::lock_guard, com a semântica de aquisição e liberação.
  • cppreference.com/w/cpp/thread/scoped_lock: std::scoped_lock (C++17), a forma segura de travar múltiplos mutexes evitando deadlock.
  • Anthony Williams, C++ Concurrency in Action (2ª ed.), capítulos 3 e 4: o tratamento definitivo de mutexes, lock guards e prevenção de deadlock.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre mutexes e locks: a apresentação da sincronização por exclusão mútua.
  • ISO C++ Core Guidelines, regras CP.20 ("Use RAII, never plain lock()/unlock()") e CP.21 ("Use std::lock() or std::scoped_lock to acquire multiple mutexes"): as diretrizes centrais da aula.

Exercícios

Exercício 1

Pegue o programa do contador com corrida de dados do artigo Fazendo Duas Coisas ao Mesmo Tempo — std::thread e conserte-o usando std::lock_guard e um std::mutex. Confirme que o resultado é sempre 400000.

Ver resposta

✓ Resposta: Contador consertado (já mostrado na aula, aqui em forma mínima):

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
int main() {
    int contador = 0;
    std::mutex mtx;
    auto inc = [&]{ for (int i = 0; i < 100000; ++i) { std::lock_guard<std::mutex> g(mtx); ++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;
}

O lock_guard serializa o acesso a contador, eliminando a corrida.

Exercício 2

Explique por que usar mtx.lock()/mtx.unlock() manualmente é perigoso, e como o std::lock_guard resolve o problema. Ligue a resposta ao conceito de RAII do artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática.

Ver resposta

✓ Resposta: O lock()/unlock() manual é perigoso porque, se qualquer coisa entre eles interromper o fluxo — um return antecipado ou uma exceção lançada — o unlock() é pulado, e o mutex fica travado para sempre, congelando todas as outras threads (um deadlock por esquecimento). O std::lock_guard resolve aplicando RAII (artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática): ele trava o mutex no construtor e o destrava no destrutor, que roda automaticamente ao fim do escopo, inclusive durante o desenrolamento da pilha por uma exceção. Assim, o mutex é sempre liberado, em qualquer caminho de saída — a mesma garantia que o unique_ptr dá para memória, aplicada ao cadeado.

Exercício 3

No exemplo ContadorSeguro, explique por que o método valor() (uma leitura) também precisa travar o mutex. O que poderia dar errado se a leitura não fosse protegida?

Ver resposta

✓ Resposta: valor() precisa travar porque ler um dado enquanto outra thread o modifica também é uma corrida de dados — comportamento indefinido. Se valor() não travasse, ela poderia ler valor_ no exato momento em que outra thread está no meio de um ++valor_ (entre o ler e o escrever), obtendo um valor inconsistente ou parcialmente atualizado. Em tipos maiores que uma palavra da máquina, a leitura desprotegida pode até enxergar um estado "meio-escrito", fisicamente corrompido. Travar a leitura garante que ela veja sempre um valor completo e coerente, nunca um flagrante de modificação em andamento.

Exercício 4

O código abaixo tem um risco de deadlock. Descreva o cenário em que ele ocorre e proponha uma correção usando std::scoped_lock.

std::mutex m1, m2;
void thread_a() { std::lock_guard<std::mutex> a(m1); std::lock_guard<std::mutex> b(m2); /*...*/ }
void thread_b() { std::lock_guard<std::mutex> a(m2); std::lock_guard<std::mutex> b(m1); /*...*/ }
Ver resposta

✓ Resposta: O deadlock ocorre quando thread_a trava m1 e, ao mesmo tempo, thread_b trava m2; então thread_a tenta travar m2 (que b tem) e thread_b tenta travar m1 (que a tem) — cada uma espera o cadeado que a outra segura, para sempre. A causa é a ordem inconsistente de aquisição. Correção com std::scoped_lock, que trava ambos atomicamente e sem risco de ordem:

#include <mutex>
std::mutex m1, m2;
void thread_a() { std::scoped_lock trava(m1, m2); /*...*/ }
void thread_b() { std::scoped_lock trava(m1, m2); /*...*/ }

std::scoped_lock (C++17) adquire todos os mutexes passados de uma vez, usando um algoritmo que evita deadlock — mesmo que as duas threads os listem em ordens diferentes, ele os trava de forma consistente internamente. É a ferramenta recomendada quando mais de um mutex é necessário.

Exercício 5

Discuta a diretriz "mantenha regiões críticas pequenas". Mostre um exemplo de região crítica desnecessariamente grande e reescreva-o para travar apenas o essencial, explicando o impacto no desempenho concorrente.

Ver resposta

✓ Resposta: "Manter regiões críticas pequenas" significa travar o mutex apenas durante o acesso ao dado compartilhado, não durante trabalho que não precisa de proteção. Exemplo desnecessariamente grande:

// RUIM: o mutex fica travado durante o cálculo pesado, serializando as threads.
{
    std::lock_guard<std::mutex> g(mtx);
    int dado = compartilhado_;
    int resultado = calculo_pesado(dado);   // não mexe em dado compartilhado, mas trava tudo
    compartilhado_ = resultado;
}

Reescrito para travar só o essencial:

// BOM: o cálculo pesado roda FORA da trava; só as leituras/escritas do dado são protegidas.
int dado;
{ std::lock_guard<std::mutex> g(mtx); dado = compartilhado_; }   // trava curta: só lê
int resultado = calculo_pesado(dado);                            // sem trava: paraleliza
{ std::lock_guard<std::mutex> g(mtx); compartilhado_ = resultado; }  // trava curta: só escreve

Impacto no desempenho: na versão ruim, enquanto uma thread faz o calculo_pesado, todas as outras esperam o cadeado — as threads viram sequenciais, e o benefício da concorrência evapora. Na versão boa, o cálculo pesado (a maior parte do tempo) roda em paralelo entre as threads, e o cadeado só é disputado nos instantes curtos de leitura e escrita do dado compartilhado. Regiões críticas menores significam menos contenção e mais paralelismo real. (Ressalva: se o resultado depender de o dado não mudar durante o cálculo, é preciso cuidado adicional — mas o princípio de minimizar a trava permanece.)

Comentários

Mais em Linguagem C++

Do Comando Único ao Projeto Real — Construindo com CMake
Do Comando Único ao Projeto Real — Construindo com CMake

Compilar com uma linha de g++ deixa de servir bem antes do esperado: bastam…

A Sabedoria Codificada — Core Guidelines e Código Limpo em C++
A Sabedoria Codificada — Core Guidelines e Código Limpo em C++

As Core Guidelines destilam experiência de quem projetou a linguagem, mas…

Um Nome, Vários Sentidos — Sobrecarga de Funções
Um Nome, Vários Sentidos — Sobrecarga de Funções

Ter três nomes para valor absoluto não era capricho da biblioteca do C: dois…