Ferramentas Essenciais - Gerenciando std::thread e std::mutex
Este texto apresenta as ferramentas básicas de concorrência em C++, mostrando como criar threads com std::thread e como resolver condições de corrida protegendo dados compartilhados com std::mutex.
Se a teoria nos mostrou o campo minado, a prática agora vai nos entregar o mapa e o detetor de metais. O C++ moderno (a partir do C++11) fornece um conjunto de ferramentas poderosas e de alto nível na sua Biblioteca Padrão para lidar com a concorrência. Neste artigo, vamos focar-nos nas duas mais essenciais.
1. Lançando a Primeira Thread com std::thread
A primeira coisa que precisamos é da capacidade de criar novas linhas de execução. Em C++, isso é feito através da classe std::thread, disponível no cabeçalho <thread>.
A ideia é simples: você escreve uma função normal, como se fosse para ser executada sequencialmente, e depois instrui o C++ para a executar numa nova thread.
Snippet de Código 1:
#include <iostream>
#include <thread> // O cabeçalho essencial para threads
// Esta é a tarefa que a nossa nova thread irá executar.
void funcao_trabalhadora() {
std::cout << "Olá do universo paralelo! Eu sou uma thread trabalhadora." << std::endl;
}
int main() {
std::cout << "Main: Vou lançar uma nova thread." << std::endl;
// Criamos um objeto std::thread, passando a nossa função como argumento.
// A thread começa a executar imediatamente!
std::thread minha_thread(funcao_trabalhadora);
std::cout << "Main: A nova thread foi lançada. O programa principal continua." << std::endl;
// AGUARDE! Este passo é vital.
minha_thread.join(); // A thread principal irá pausar aqui até que 'minha_thread' termine.
std::cout << "Main: A thread trabalhadora terminou. O programa vai encerrar." << std::endl;
return 0;
}
O que é o .join()?
Pense na sua função main como o gerente e na minha_thread como um funcionário a quem foi atribuída uma tarefa. O .join() é o ato de o gerente esperar na porta do escritório até que o funcionário termine a sua tarefa antes de fechar tudo e ir para casa. Se o gerente (a thread main) for embora mais cedo, o funcionário (minha_thread) é terminado abruptamente no meio do seu trabalho. Em C++, isso causa um crash no programa. Regra fundamental: para cada std::thread que você cria, você deve, em algum momento, chamar .join() (ou .detach(), um conceito mais avançado) antes que o objeto std::thread seja destruído.
2. A Condição de Corrida na Prática
Agora que sabemos como criar threads, vamos recriar o nosso problema da conta bancária em código para vermos o caos a acontecer.
Snippet de Código 2:
#include <iostream>
#include <thread>
#include <vector>
struct ContaBancaria {
int saldo = 0;
// Método perigoso que causa uma condição de corrida
void depositar(int valor) {
// O ciclo Ler-Modificar-Escrever está aqui!
saldo += valor;
}
};
int main() {
ContaBancaria conta_partilhada;
std::vector<std::thread> threads;
// Vamos criar 10 threads, e cada uma vai depositar 1000 (100x10)
for (int i = 0; i < 10; ++i) {
threads.push_back(std::thread([&]() {
for (int j = 0; j < 100; ++j) {
conta_partilhada.depositar(10);
}
}));
}
// Esperamos que todas as 10 threads terminem o seu trabalho
for (auto& th : threads) {
th.join();
}
// O resultado esperado é 10 * 100 * 10 = 10000
std::cout << "Saldo final: " << conta_partilhada.saldo << std::endl;
return 0;
}
Se você compilar e executar este código várias vezes, obterá resultados diferentes e quase nunca o valor correto de 10000. Poderá ver 9840, 9910, 10000 (se tiver sorte), 9750... Este é o comportamento não-determinístico de uma condição de corrida na vida real.
3. A Solução - Exclusão Mútua com std::mutex
Para resolver o problema, precisamos de garantir a exclusão mútua. Ou seja, garantir que apenas uma thread de cada vez possa executar a secção crítica do código (o saldo += valor). A ferramenta para isso em C++ é o std::mutex, do cabeçalho <mutex>.
Pense num mutex como a chave de uma casa de banho num avião. Apenas uma pessoa pode ter a chave e entrar de cada vez. Quem quiser usar a casa de banho e encontrar a porta trancada, tem de esperar na fila.
A forma moderna e segura de usar um mutex é através de um padrão chamado RAII (Resource Acquisition Is Initialization), implementado pela classe std::lock_guard.
Quando você cria um std::lock_guard, ele "tranca" o mutex. Quando o lock_guard é destruído (no final do escopo da função, por exemplo), ele "liberta" automaticamente o mutex. Isto garante que o mutex é sempre libertado, mesmo que ocorram erros (exceções) no seu código.
4. O Código Corrigido e Seguro
Vamos adicionar um std::mutex à nossa ContaBancaria e usar std::lock_guard para proteger a operação de depósito.
Snippet de Código 3:
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // O cabeçalho para o mutex
struct ContaBancariaSegura {
int saldo = 0;
std::mutex mtx; // O mutex que protegerá o nosso saldo
void depositar(int valor) {
// Criamos um lock_guard. Ao ser criado, ele tranca o mutex 'mtx'.
// Agora, apenas uma thread pode passar desta linha de cada vez.
std::lock_guard<std::mutex> guard(mtx);
// --- Início da Região Crítica ---
saldo += valor;
// --- Fim da Região Crítica ---
} // O 'guard' é destruído aqui, libertando automaticamente o mutex.
};
int main() {
ContaBancariaSegura conta_partilhada;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.push_back(std::thread([&]() {
for (int j = 0; j < 100; ++j) {
conta_partilhada.depositar(10);
}
}));
}
for (auto& th : threads) {
th.join();
}
// O resultado agora será sempre 10000!
std::cout << "Saldo final seguro: " << conta_partilhada.saldo << std::endl;
return 0;
}
Com esta simples adição, eliminámos a condição de corrida. O lock_guard garante que o ciclo "Ler-Modificar-Escrever" no saldo é agora uma operação logicamente atómica. Embora o sistema operativo ainda possa pausar e alternar entre as threads, apenas uma delas pode ter a "chave" (lock) do mutex de cada vez, garantindo um resultado correto e previsível.
Conclusão Parcial
Já demos um passo de gigante. Aprendemos a criar threads e a usar a ferramenta de sincronização mais fundamental, o std::mutex, para proteger dados partilhados.
Mas e se as threads precisarem de mais do que apenas não se atrapalharem? E se precisarem de esperar umas pelas outras ou coordenar tarefas complexas? Proteger dados é apenas o primeiro passo. No próximo artigo, exploraremos como gerir o ciclo de vida das nossas threads, respondendo às perguntas cruciais: "quantas threads usar?" e "como mandá-las parar de forma segura?".