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::mutexestd::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 ("Usestd::lock()orstd::scoped_lockto 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.)