Cleverton Bueno
← Voltar para todos os artigos

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, do cabeçalho . As operações atómicas são um tópico avançado (que veremos no Artigo 5.5), mas para uma simples flag booleana, podemos pensar nela como uma variável global que é magicamente segura para ser lida e escrita por múltiplas threads ao mesmo tempo, sem causar condições de corrida.

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:

Parada com std::atomic
#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.