Cleverton Bueno
← Voltar para todos os artigos

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:

C++ Criando uma Thread Básica
#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:

C++ Condição de Corrida (Exemplo Perigoso)
#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:

Trecho 3: Solução com Mutex
#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?".