O Ciclo de Vida das Threads - Quantas, Quando e Como Parar?
Este artigo aborda a gestão prática de threads, explicando como definir o número ideal de threads com base na tarefa (CPU-bound vs. I/O-bound) e como implementá-las de forma segura usando o padrão de cancelamento cooperativo.
Bem-vindo ao artigo que trata da gestão prática das suas threads. As respostas para as perguntas "quantas?" e "como parar?" são o que distingue um código de demonstração de um software de produção fiável. Vamos desmistificar estes dois desafios.
1. O Número Mágico - Quantas Threads Devo Usar?
Esta é talvez a primeira pergunta que um programador se faz ao usar std::thread. A resposta, como muitas coisas em engenharia, é: depende da natureza da sua tarefa. As tarefas dividem-se em duas grandes categorias:
1.1. Tarefas Ligadas à CPU (CPU-Bound)
O que são? São tarefas que passam a maior parte do tempo a fazer cálculos intensivos, mantendo o processador a 100%. Pense em renderização de vídeo, compressão de arquivos, simulações científicas complexas.
Analogia: Uma equipa de matemáticos a resolver equações numa lousa. Eles estão sempre a pensar (a usar a CPU).
Quantas threads? A regra de ouro é usar um número de threads igual ao número de núcleos de processamento disponíveis. Se tiver mais threads do que núcleos, o sistema operativo terá de gastar tempo a alternar entre elas (troca de contexto), o que na verdade pode tornar o processo mais lento. É como ter 10 matemáticos, mas apenas 4 lousas.
A Ferramenta: O C++ dá-nos uma dica com a função std::thread::hardware_concurrency(), que retorna o número de núcleos lógicos que o seu sistema consegue executar em paralelo. Este é o seu ponto de partida ideal para tarefas ligadas à CPU.
1.2. Tarefas Ligadas ao I/O (I/O-Bound)
O que são? São tarefas que passam a maior parte do tempo à espera de operações de Entrada/Saída (I/O) para serem concluídas. Pense em descarregar arquivos da internet, ler ou escrever em discos lentos, ou fazer consultas a uma base de dados (como no nosso caso de estudo dos XMLs!)(leia também o artigo "Nos Bastidores da Performance - Uma Análise Técnica do Código-Fonte").
Analogia: Uma equipa de arquivistas numa biblioteca gigante. A maior parte do tempo deles é gasta a caminhar até uma prateleira distante (à espera do disco/rede), e apenas uma pequena fração do tempo é gasta a ler o livro (a usar a CPU).
Quantas threads? Aqui, a regra muda completamente. Você pode e deve usar muito mais threads do que o número de núcleos. Porquê? Porque enquanto uma thread está "a caminhar para a prateleira" (à espera da rede), ela não está a usar a CPU. O sistema operativo é inteligente e pode colocar outra thread a usar a CPU nesse tempo. Ter muitas threads garante que a CPU está sempre a ser aproveitada por uma thread que não está em estado de espera.
Quantas exatamente? Não há um número mágico. Começa-se com um valor (ex: 2x o número de núcleos) e mede-se a performance, aumentando o número até que o rendimento pare de melhorar.
Conclusão
Para otimizar, primeiro entenda a sua tarefa. Se for matemática pura, use hardware_concurrency(). Se envolver espera por disco, rede ou base de dados, não tenha medo de experimentar com um número significativamente maior.
2. O Desafio do Encerramento - O Botão de Pânico Seguro
Você criou as suas threads, elas estão a trabalhar arduamente, mas agora o utilizador clica no botão "Sair". Como é que você para tudo de forma limpa e segura?
A primeira tentação, "matar" as threads à força, não existe em C++ por uma razão muito forte. Uma thread terminada abruptamente pode deixar recursos num estado inconsistente: um mutex trancado para sempre, um arquivo meio escrito no disco, uma ligação de rede aberta.
A solução correta é o Cancelamento Cooperativo. A thread principal não manda, ela pede. Ela levanta uma bandeira a sinalizar "pessoal, hora de fechar", e as threads trabalhadoras, que foram programadas para olhar para essa bandeira de vez em quando, devem terminar o que estão a fazer e encerrar graciosamente.
A ferramenta perfeita para esta "bandeira" partilhada é um std::atomic
3. Implementando a Paragem Segura
Vamos ver o padrão de cancelamento cooperativo em ação. O nosso programa vai lançar várias threads que "trabalham" num loop infinito, e a thread principal irá pará-las após alguns segundos.
Snippet de Código 1:
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <atomic> // O cabeçalho para std::atomic
// A nossa "bandeira" de paragem. É global para ser visível por todas as threads.
std::atomic<bool> deve_parar = false;
void funcao_trabalhadora(int id) {
std::cout << "Thread " << id << " iniciou." << std::endl;
// As threads trabalham enquanto a bandeira de paragem for falsa.
while (!deve_parar) {
// Simula algum trabalho...
std::cout << "Thread " << id << " esta a trabalhar..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "Thread " << id << " recebeu sinal e esta a terminar." << std::endl;
}
int main() {
std::vector<std::thread> threads;
const int num_threads = 4;
std::cout << "Main: Lancando " << num_threads << " threads." << std::endl;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(funcao_trabalhadora, i);
}
std::cout << "Main: O programa vai correr por 5 segundos." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(5));
std::cout << "Main: Enviando sinal de paragem para todas as threads..." << std::endl;
// 1. Levantamos a bandeira.
deve_parar = true;
// 2. Esperamos (join) que cada thread termine o seu trabalho graciosamente.
// Cada thread vai terminar a sua iteração atual do loop, ver a bandeira
// e sair do loop, terminando a sua execução.
for (auto& th : threads) {
th.join();
}
std::cout << "Main: Todas as threads terminaram. Encerrando o programa." << std::endl;
return 0;
}
Este padrão é a espinha dorsal de qualquer aplicação multithreaded robusta. A thread principal sinaliza, e depois aguarda pacientemente que todas as trabalhadoras confirmem que receberam a mensagem e arrumaram as suas coisas antes de apagar as luzes.
Conclusão Parcial
Agora as nossas threads são bem-comportadas. Sabemos como dimensioná-las para a nossa tarefa e como mandá-las para casa no final do dia. Mas e se a comunicação precisar de ser mais sofisticada do que um simples "pare"? E se uma thread precisar de esperar por outra para que uma condição específica se torne verdadeira?
É o que veremos no próximo artigo, ao explorarmos o mundo da sincronização com std::condition_variable e semáforos.