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:
// 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.
- std::condition_variable:
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:
#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.