Cleverton Bueno
← Voltar para todos os artigos

Threads que Coordenam - condition_variable, future e Semáforos

Este texto explora ferramentas de sincronização avançadas, com foco na std::condition_variable para resolver o clássico problema produtor-consumidor de forma eficiente e sem desperdiçar CPU.

Até agora, o nosso foco principal com o std::mutex foi garantir que as threads não se "atropelassem" ao aceder a dados partilhados. Mas e se o nosso objetivo for precisamente o contrário? E se quisermos que uma thread espere deliberadamente por outra?

1. O Problema Clássico: Produtor-Consumidor

Este é o padrão de design de concorrência mais comum e útil que existe. A ideia é simples:

  • Uma ou mais threads Produtoras criam "itens de trabalho" e colocam-nos numa fila partilhada.
  • Uma ou mais threads Consumidoras retiram itens dessa fila e processam-nos.

Este padrão é a base de inúmeros sistemas: num servidor web, uma thread "produz" ligações de clientes e um conjunto de threads "consome" essas ligações. Num sistema de processamento de imagens, por exemplo, uma thread poderia 'produzir' nomes de arquivos de fotos a serem redimensionadas, enquanto um grupo de threads 'consumia' esses nomes para realizar o trabalho (leia também o artigo "Nos Bastidores da Performance - Uma Análise Técnica do Código-Fonte").

O recurso partilhado aqui é a fila de trabalho. Já sabemos que, para evitar condições de corrida, qualquer acesso a esta fila deve ser protegido por um std::mutex.

Mas isso cria uma nova pergunta: o que é que a thread consumidora deve fazer se chegar para trabalhar e a fila estiver vazia?

2. A Espera Ineficiente vs. A Espera Inteligente (std::condition_variable)

A Solução Ineficiente (Busy-Waiting):

O primeiro instinto poderia ser criar um loop while(true) que constantemente tranca o mutex, verifica se a fila está vazia e, se estiver, destranca e tenta novamente.

Snippet de Código 1:

C++: Busy-Waiting (Exemplo a Evitar)
// NÃO FAÇA ISTO!
while (true) {
    std::lock_guard<std::mutex> guard(mtx);
    if (!fila_de_trabalho.empty()) {
        // ... processar item ...
        break;
    }
    // se não, o loop repete-se, trancando e destrancando o mutex sem parar.
}

Isto é terrivelmente ineficiente. A thread consumidora vai queimar 100% de um núcleo da CPU apenas para perguntar repetidamente "Já chegou alguma coisa?".

A Solução Inteligente (std::condition_variable):

A forma correta é fazer a thread "dormir" até que haja trabalho a ser feito. A ferramenta para isso é a std::condition_variable, do cabeçalho <condition_variable>.

  • A Analogia:
    Pense numa cozinha de restaurante. O empregado de mesa (Consumidor) vai até à zona de passe para pegar num prato. Se não houver pratos prontos (fila vazia), ele não fica ali a perguntar ao chef (Produtor) "Está pronto? Está pronto?". Em vez disso, ele avisa "Chame-me quando houver um prato!" e vai atender a outras mesas (a thread "dorme"). Quando o chef termina um prato, ele toca uma sineta (notify_one()). O empregado ouve a sineta, volta ao passe e pega no prato.

  • Os Componentes:
    • std::condition_variable:
      O mecanismo da "sineta".

    • std::unique_lock:
      É necessário um tipo de lock mais flexível do que o std::lock_guard para usar com uma condition_variable, pois ele precisa de ser capaz de trancar e destrancar o mutex atomicamente durante a espera.

    • cv.wait(lock, predicado):
      Esta é a ação de "esperar". A thread faz o seguinte:
      Destranca o mutex e entra em estado de "sono" (não consome CPU).
      Quando é "acordada" (pela sineta), ela volta a trancar o mutex e verifica a condição (predicado, uma função lambda como []{ return !fila.empty(); }). Se a condição for verdadeira, a função retorna. Se não (um "despertar espúrio"), ela volta a dormir.

    • produtor.notify_one():
      O ato de a thread produtora "tocar a sineta" para acordar uma thread que esteja à espera.

3. Código Prático e o Desafio de Parar uma Thread Adormecida

O código abaixo implementa um sistema simples de produtor-consumidor e também resolve o nosso desafio anterior: como parar uma thread que pode estar "a dormir"?

Snippet de Código 2:

Trecho 2: Padrão Produtor-Consumidor com Variável de Condição
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>

std::queue<int> fila_de_trabalho;
std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> deve_parar = false;

void produtor() {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
        {
            std::lock_guard<std::mutex> guard(mtx);
            fila_de_trabalho.push(i);
            std::cout << "Produtor: produziu o item " << i << std::endl;
        }
        cv.notify_one(); // Toca a sineta: acorda um consumidor
    }
}

void consumidor() {
    while (!deve_parar) {
        std::unique_lock<std::mutex> lock(mtx);
        // A thread vai dormir aqui se a fila estiver vazia E deve_parar for falso
        cv.wait(lock, []{ return !fila_de_trabalho.empty() || deve_parar; });

        if (deve_parar && fila_de_trabalho.empty()) {
            break; // Sai se for para parar e não houver mais nada para processar
        }

        int item = fila_de_trabalho.front();
        fila_de_trabalho.pop();
        std::cout << "Consumidor: consumiu o item " << item << std::endl;
    }
     std::cout << "Consumidor a terminar." << std::endl;
}

int main() {
    std::thread p(produtor);
    std::thread c(consumidor);

    p.join(); // Espera o produtor terminar de produzir

    // Pede para o consumidor parar e notifica-o caso esteja a dormir
    deve_parar = true;
    cv.notify_one(); 

    c.join();

    std::cout << "Programa terminado." << std::endl;
    return 0;
}

A parte crucial é que, para parar, não basta definir deve_parar = true. Se o consumidor estiver a dormir, ele nunca verá a flag. Por isso, após definir a flag, temos de chamar cv.notify_one() (ou notify_all() se houver múltiplos consumidores) para garantir que acordamos a thread e lhe damos a oportunidade de verificar a flag e terminar.

4. Outras Ferramentas de Coordenação

std::promise e std::future:

  • Conceito:
    Uma forma mais simples de comunicação para quando uma thread precisa de devolver um único resultado para quem a chamou.

  • Analogia:
    Encomendar uma pizza. A loja dá-lhe um talão (std::future). O pizzaiolo tem a ordem (std::promise). Você pode fazer outras coisas e, mais tarde, usar o seu talão para pegar na pizza. O .get() do future espera até que a pizza esteja pronta.

O Semáforo:

  • Conceito:
    Um mecanismo de sincronização mais antigo e geral que um mutex. Em vez de proteger uma única "região crítica", um semáforo controla o acesso a um número limitado de recursos.

  • Analogia:
    Um parque de estacionamento com 10 vagas. O semáforo começa com o valor 10. Cada carro que entra decrementa o contador. Quando chega a 0, os novos carros têm de esperar. Cada carro que sai incrementa o contador, libertando uma vaga.

  • Em C++:
    Padronizado em C++20 como std::counting_semaphore. É a ferramenta certa quando você tem um pool de recursos (ex: 5 ligações à base de dados) e quer limitar o número de threads que os podem usar simultaneamente.

Conclusão Parcial:

Com std::condition_variable e semáforos, as nossas threads podem agora coordenar-se de forma eficiente. Mas todas estas ferramentas (mutex, cv) têm um custo de performance. E se precisarmos da máxima velocidade para operações muito simples?

No nosso último artigo técnico(A Velocidade da Luz - Programação Lock-Free com std::atomic), vamos explorar o nível especialista da programação lock-free com std::atomic e o temido Modelo de Memória.