Passamos da metade da jornada e abrimos a Fase 7, dedicada a um dos temas mais poderosos e mais traiçoeiros de toda a programação: a concorrência — fazer várias coisas ao mesmo tempo. Você conhece de C os pthreads, com sua API verbosa: pthread_create recebendo um ponteiro para função e um void* de argumento, pthread_join para esperar, e todo o desconforto de empacotar dados em structs para passar adiante. O C++ oferece o std::thread, uma abstração mais limpa que aceita lambdas (Fase 5), integra-se ao RAII (Fase 2) e elimina boa parte da cerimônia. Mas preciso ser honesto desde a primeira linha desta fase: concorrência é onde os bugs mais difíceis da carreira de um programador se escondem. Vou ensinar o poder e, no mesmo fôlego, os perigos — porque aqui, mais do que em qualquer outro lugar, o cuidado importa.
Criando uma thread
Uma thread é uma linha de execução independente: um segundo fluxo de código rodando "ao mesmo tempo" que o principal. Criar uma com std::thread é notavelmente mais simples que com pthreads — você passa qualquer coisa chamável, e uma lambda é perfeita:
#include <iostream>
#include <thread> // std::thread
void tarefa(int id) {
std::cout << "thread " << id << " trabalhando\n";
}
int main() {
// Cria uma thread que executa 'tarefa(1)'. Ela começa a rodar IMEDIATAMENTE.
std::thread t1(tarefa, 1);
// Também aceita uma lambda — muito mais flexível que o void* dos pthreads.
std::thread t2([]() {
std::cout << "thread 2 via lambda\n";
});
// join() ESPERA a thread terminar antes de prosseguir. Obrigatório!
t1.join();
t2.join();
std::cout << "todas as threads terminaram\n";
return 0;
}
O construtor de std::thread recebe a função (ou lambda) e seus argumentos, e a thread começa a executar de imediato, em paralelo com o main. O join() faz a thread principal esperar aquela thread terminar — é o pthread_join, mas como um método limpo. A ordem de impressão das threads é imprevisível: elas rodam concorrentemente, então "thread 1" pode aparecer antes ou depois de "thread 2". Essa imprevisibilidade é a primeira coisa a interiorizar sobre concorrência.
join ou detach: você é obrigado a decidir
Aqui está a primeira regra rígida, e ela liga concorrência ao RAII. Toda thread precisa ser resolvida antes de o objeto std::thread ser destruído: ou você a join() (espera terminar) ou a detach() (a desliga para rodar sozinha). Se você destruir um objeto std::thread sem fazer nenhum dos dois, o programa aborta na hora — chama std::terminate:
#include <thread>
int main() {
std::thread t([]{ /* ... */ });
// return 0; // ERRO GRAVE: 't' destruída sem join() nem detach() → std::terminate!
t.join(); // resolvido: espera a thread terminar
return 0;
}
Por que essa dureza? Porque uma thread ainda rodando quando seu objeto morre é uma situação perigosa e ambígua — ela pode estar usando dados que estão prestes a ser destruídos. O C++ prefere abortar ruidosamente a deixar você num estado indefinido silencioso. detach() existe para threads que devem rodar em segundo plano de forma independente, mas é raro e arriscado (a thread perde a ligação com quem a criou). Na prática, join() é quase sempre a resposta. Uma consequência prática: como você deve join() em todos os caminhos de saída — inclusive quando uma exceção é lançada —, o padrão moderno é usar o std::jthread (C++20), que faz join() automaticamente no destrutor. Falo dele no fim.
O perigo central: a corrida de dados
Agora o parágrafo mais importante de toda a fase. Quando duas threads acessam o mesmo dado ao mesmo tempo, e ao menos uma delas o modifica, sem coordenação, você tem uma corrida de dados (data race) — e o comportamento é indefinido. Não "às vezes errado": indefinido, o que significa que pode funcionar mil vezes e falhar na milésima primeira, em produção, de forma impossível de reproduzir. Veja o exemplo clássico, um contador incrementado por várias threads:
#include <iostream>
#include <thread>
#include <vector>
int main() {
int contador = 0; // dado COMPARTILHADO entre as threads
auto incrementa = [&contador]() {
for (int i = 0; i < 100000; ++i)
++contador; // CORRIDA DE DADOS! Várias threads mexem em 'contador' sem coordenação.
};
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i)
threads.emplace_back(incrementa); // 4 threads incrementando o MESMO contador
for (auto& t : threads)
t.join();
// Esperávamos 400000. Mas o resultado é IMPREVISÍVEL e quase sempre MENOR.
std::cout << "contador = " << contador << '\n'; // ex: 287341 (varia a cada execução!)
return 0;
}
Você esperaria 400000 (4 threads × 100000). Rode e verá um número menor e diferente a cada execução. Por quê? Porque ++contador não é uma operação única: é ler o valor, somar 1, escrever de volta. Quando duas threads fazem isso ao mesmo tempo, ambas leem o mesmo valor antigo, ambas somam 1, ambas escrevem — e um incremento se perde. Isso é a corrida de dados em sua forma mais pura, e ela é a raiz de uma classe inteira de bugs de concorrência. O código compila, roda, e está errado de um jeito não-determinístico. Guarde este exemplo: consertá-lo é o assunto dos próximos dois artigos.
A honestidade que abre a fase
Prometo transparência sobre trade-offs em todo artigo, mas nesta fase a transparência é a lição principal. Concorrência traz ganhos reais — usar múltiplos núcleos, manter uma interface responsiva enquanto um trabalho pesado roda —, mas cobra um preço alto em complexidade. Os bugs de concorrência (corridas de dados, deadlocks que veremos adiante) são não-determinísticos, difíceis de reproduzir e difíceis de depurar. A diretriz honesta, que vale para toda esta fase: não use concorrência a menos que precise, e quando precisar, prefira as abstrações de mais alto nível que o C++ oferece (como o std::async do próximo bloco) sobre o gerenciamento manual de threads. E, acima de tudo: sempre que threads compartilharem dados mutáveis, você precisa de sincronização. O contador acima não é um caso raro — é o problema fundamental, e os próximos artigos são as ferramentas para resolvê-lo.
Criar uma thread é fácil; o difícil aparece logo em seguida. O C++ obriga a decidir entre join e detach antes de a std::thread ser destruída — esquecer disso chama std::terminate e derruba o programa, o que é rude, mas melhor que o silêncio. A corrida de dados é o perigo de fundo: ++contador parece indivisível e são três operações que podem se intercalar, produzindo perda de incrementos que não se reproduz de forma confiável.
Fontes e leituras recomendadas
- cppreference.com/w/cpp/thread/thread: a referência completa de
std::thread, comjoin,detache a semântica de destruição. - cppreference.com/w/cpp/thread/jthread:
std::jthread(C++20), que faz join automático e suporta cancelamento cooperativo. - Anthony Williams, C++ Concurrency in Action (2ª ed., 2019): o livro de referência sobre concorrência em C++ moderno, cobrindo tudo desta fase em profundidade.
- Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre concorrência: a introdução do modelo de threads do C++ pelo criador.
- ISO C++ Core Guidelines, seção "CP" (Concurrency), regras CP.1 ("Assume that your code will run as part of a multi-threaded program") e CP.2 ("Avoid data races"): as diretrizes fundamentais da fase.
Exercícios
Exercício 1
Crie duas threads, cada uma imprimindo uma mensagem diferente cinco vezes num laço. Faça join() em ambas e observe que a ordem das mensagens varia entre execuções.
Ver resposta
✓ Resposta: Duas threads com saída intercalada:
#include <iostream>
#include <thread>
int main() {
std::thread a([]{ for (int i = 0; i < 5; ++i) std::cout << "A"; });
std::thread b([]{ for (int i = 0; i < 5; ++i) std::cout << "B"; });
a.join();
b.join();
std::cout << '\n'; // ex: "AABABBABAB" — ordem varia a cada execução
return 0;
}
A intercalação dos "A" e "B" muda entre execuções porque as threads rodam concorrentemente, sem ordem garantida entre elas. (A própria saída pode até se misturar caractere a caractere, ilustrando a falta de coordenação.)
Exercício 2
Escreva um programa que crie uma thread mas "esqueça" de chamar join() ou detach(). Compile e rode, e descreva o que acontece. Depois conserte-o.
Ver resposta
✓ Resposta: Thread não resolvida:
#include <thread>
int main() {
std::thread t([]{ /* faz algo */ });
return 0; // 't' destruída sem join/detach
}
Ao rodar, o programa aborta com uma mensagem como terminate called without an active exception — o destrutor de std::thread chama std::terminate quando a thread não foi resolvida. Correção: adicionar t.join(); antes do return, fazendo a thread principal esperar a t terminar. O C++ força essa decisão para evitar threads órfãs rodando sobre dados que podem ser destruídos.
Exercício 3
Explique, com suas palavras, por que ++contador executado por múltiplas threads sem sincronização causa uma corrida de dados. Detalhe as três micro-operações que compõem o incremento.
Ver resposta
✓ Resposta: ++contador parece atômico mas é composto de três micro-operações: (1) ler o valor atual de contador da memória para um registrador; (2) somar 1 a esse valor no registrador; (3) escrever o resultado de volta na memória. A corrida ocorre porque essas três etapas não são indivisíveis: se a thread A lê contador (digamos, 100) e, antes de escrever de volta, a thread B também lê contador (ainda 100), ambas somam 1 (obtendo 101) e ambas escrevem 101 — quando o correto seria 102. Um incremento se perdeu. Com muitas threads e muitas iterações, milhares de incrementos se perdem, e o total final fica imprevisivelmente abaixo do esperado. O problema é a ausência de garantia de que a sequência ler-somar-escrever de uma thread complete sem interferência de outra.
Exercício 4
Crie um std::vector<std::thread> com 3 threads, cada uma recebendo seu índice i e imprimindo "sou a thread i". Use emplace_back e depois join() em todas com um laço. Atenção: capture i por valor na lambda — explique por quê.
Ver resposta
✓ Resposta: Threads num vetor:
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 3; ++i)
threads.emplace_back([i]() { // captura i POR VALOR
std::cout << "sou a thread " << i << '\n';
});
for (auto& t : threads)
t.join();
return 0;
}
i deve ser capturado por valor ([i]) porque a variável i do laço muda a cada iteração e deixa de existir ao fim do laço. Se fosse capturado por referência ([&i]), todas as threads compartilhariam a mesma variável i, que poderia já ter mudado ou sido destruída quando a thread finalmente rodasse — causando leitura de valores errados ou uma referência pendente. Capturar por valor dá a cada thread sua própria cópia congelada de i, correta e segura. É a regra de tempo de vida das lambdas (artigo Funções Anônimas e Capturas — Lambdas de Verdade) sendo crucial no contexto de threads.
Exercício 5
Discuta a diretriz "não use concorrência a menos que precise". Dê um exemplo de tarefa que se beneficia genuinamente de threads e outro em que adicionar threads traria complexidade sem ganho real, justificando cada caso.
Ver resposta
✓ Resposta: A diretriz reconhece que concorrência custa complexidade (bugs não-determinísticos, sincronização, depuração difícil), então só vale quando traz ganho real. Exemplo que se beneficia genuinamente: processar um lote de milhares de imagens independentes (redimensionar, aplicar filtros) — as tarefas não dependem umas das outras, então distribuí-las por vários núcleos com threads reduz o tempo total proporcionalmente ao número de núcleos; o ganho é concreto e a coordenação é mínima (cada thread trabalha em sua própria imagem, sem dados compartilhados mutáveis). Exemplo sem ganho real: um script que lê um arquivo de configuração pequeno, valida três campos e imprime um resumo — a tarefa é rápida, sequencial por natureza (cada passo depende do anterior) e não tem trabalho paralelizável; adicionar threads aqui só introduziria complexidade, risco de bugs e overhead de criação de threads, sem acelerar nada. A regra: paralelize trabalho independente e substancial; mantenha sequencial o que é rápido ou interdependente.