Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable

Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable

Quase sempre o que se quer não é gerenciar uma thread, e sim disparar uma tarefa e colher o resultado depois. É o que o async entrega, com future e exceções transportadas junto. O texto trata também da política de lançamento explícita e da condition_variable, cujo predicado protege dos despertares espúrios.
Linguagem C++

11 min de leitura

Nos artigos Fazendo Duas Coisas ao Mesmo Tempo — std::thread a Operações Indivisíveis — std::atomic gerenciamos threads e sincronização de forma bastante manual: criar std::thread, dar join, proteger dados com mutex ou atomic. Funciona, mas é trabalhoso e cheio de armadilhas. Hoje fechamos a Fase 7 subindo o nível de abstração para duas ferramentas que, seguindo a diretriz "prefira o alto nível", muitas vezes são a resposta mais sábia. O std::async executa uma tarefa em segundo plano e lhe entrega um std::future — uma "promessa de resultado futuro" — de onde você colhe o valor quando estiver pronto, sem gerenciar threads à mão. E a std::condition_variable resolve o problema de esperar eficientemente que uma condição se torne verdadeira, sem desperdiçar o processador. Com elas, você frequentemente evita todo o manejo manual das aulas anteriores.

std::async: um resultado que chega depois

O padrão mais comum de concorrência não é "rode este código numa thread", mas "calcule isto em paralelo e me devolva o resultado quando terminar". O std::async captura exatamente essa ideia: você passa uma função, ela roda (possivelmente em outra thread), e você recebe um std::future<T> — um objeto que representa o resultado que ainda vai chegar:

#include <iostream>
#include <future>   // std::async, std::future
#include <chrono>

int calculo_demorado() {
    std::this_thread::sleep_for(std::chrono::seconds(1));   // simula trabalho pesado
    return 42;
}

int main() {
    std::cout << "iniciando cálculo em segundo plano...\n";

    // async dispara a tarefa e devolve um future de onde colheremos o resultado.
    std::future<int> resultado = std::async(std::launch::async, calculo_demorado);

    // Enquanto o cálculo roda, a thread principal faz OUTRA coisa:
    std::cout << "fazendo outro trabalho enquanto espero...\n";

    // .get() BLOQUEIA até o resultado estar pronto, e então o devolve.
    int valor = resultado.get();
    std::cout << "resultado: " << valor << '\n';   // resultado: 42
    return 0;
}

Repare no que não está aqui: nenhum std::thread, nenhum join, nenhum mutex. O std::async cuida de criar a thread (com std::launch::async, forçamos execução em paralelo) e o std::future cuida de entregar o resultado. O .get() bloqueia até o valor estar pronto e o devolve — e, elegantemente, se a tarefa lançar uma exceção, ela é recapturada e relançada no .get(), integrando concorrência e o tratamento de erros da Fase 6. Para "calcule algo em paralelo e me dê o resultado", async é quase sempre mais simples e seguro que gerenciar threads manualmente.

Paralelizando trabalho independente com vários futures

O async brilha quando você tem várias tarefas independentes: dispare todas, depois colha todos os resultados. Enquanto uma roda, as outras também rodam:

#include <iostream>
#include <future>
#include <vector>
#include <numeric>

// Soma uma fatia de um vetor (trabalho independente por fatia).
long soma_fatia(const std::vector<int>& v, std::size_t inicio, std::size_t fim) {
    return std::accumulate(v.begin() + inicio, v.begin() + fim, 0L);
}

int main() {
    std::vector<int> dados(1000, 1);   // 1000 elementos, todos 1

    // Dispara duas somas parciais em paralelo.
    auto f1 = std::async(std::launch::async, soma_fatia, std::cref(dados), 0, 500);
    auto f2 = std::async(std::launch::async, soma_fatia, std::cref(dados), 500, 1000);

    // Colhe e combina os resultados (cada .get() espera sua tarefa).
    long total = f1.get() + f2.get();
    std::cout << "total: " << total << '\n';   // 1000
    return 0;
}

As duas metades são somadas concorrentemente, e f1.get() + f2.get() reúne os parciais. O std::cref(dados) passa o vetor por referência const de forma segura para o async (a Fase 1 reaparece: evitamos copiar o vetor inteiro). Este é o padrão de paralelização de trabalho independente — exatamente o caso em que, lá no artigo Fazendo Duas Coisas ao Mesmo Tempo — std::thread, dissemos que a concorrência "vale a pena".

condition_variable: esperar sem desperdiçar o processador

Um problema recorrente: uma thread precisa esperar até que outra produza algo ou sinalize uma condição. A tentação ingênua é um laço que fica verificando (busy-waiting), mas isso queima o processador à toa. A std::condition_variable permite que a thread durma até ser notificada, sem gastar ciclos:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool pronto = false;   // a condição que esperamos

void consumidor() {
    std::unique_lock<std::mutex> trava(mtx);   // unique_lock: mais flexível que lock_guard
    // wait() DORME até ser notificada E a condição ser verdadeira.
    // Libera o mutex enquanto dorme; readquire ao acordar.
    cv.wait(trava, []{ return pronto; });
    std::cout << "consumidor: recebi o sinal, trabalhando\n";
}

void produtor() {
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    {
        std::lock_guard<std::mutex> trava(mtx);
        pronto = true;   // altera a condição sob proteção do mutex
    }
    cv.notify_one();     // acorda uma thread que espera
    std::cout << "produtor: sinal enviado\n";
}

int main() {
    std::thread c(consumidor);
    std::thread p(produtor);
    c.join();
    p.join();
    return 0;
}

A cv.wait(trava, condição) faz o consumidor dormir — liberando o mutex enquanto espera — até que o produtor chame notify_one() e a condição (pronto) seja verdadeira. O predicado ([]{ return pronto; }) é essencial: ele protege contra os "acordares espúrios" (a thread pode acordar sem notificação), reverificando a condição. Note o std::unique_lock no lugar do lock_guard — a condition_variable precisa de um lock que ela possa liberar e readquirir, e o unique_lock oferece essa flexibilidade. Este é o padrão produtor-consumidor, base de filas de tarefas e pipelines concorrentes.

O std::async troca o gerenciamento manual de thread pelo que quase sempre se quer de fato: uma tarefa que produz um resultado e um future de onde colhê-lo, com exceções transportadas junto. Vale usar std::launch::async explicitamente, como nos exemplos daqui, porque sem política a implementação pode adiar a execução até o get(). A condition_variable resolve o outro lado — esperar sem consumir processador —, e o predicado no wait não é enfeite: ele protege dos despertares espúrios.

A concorrência fecha aqui, e o mais importante que fica não é a API, é o respeito ao terreno. Corrida de dados não quebra de forma confiável: ela produz o resultado errado de vez em quando, sob carga, na máquina de outra pessoa. Por isso a sequência importa — criar threads, ver o contador corromper, domá-lo com mutex, conhecer a alternativa leve do atomic e só então delegar ao async.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/thread/async e /w/cpp/thread/future: as referências de std::async e std::future, com políticas de lançamento e propagação de exceções.
  • cppreference.com/w/cpp/thread/condition_variable: a referência da condition_variable, com wait, notify_one, notify_all e a semântica do predicado.
  • Anthony Williams, C++ Concurrency in Action (2ª ed.), capítulos 2 e 4: o tratamento definitivo de futures, async e variáveis de condição.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seções sobre tarefas e futures: a apresentação das abstrações de alto nível pelo criador.
  • ISO C++ Core Guidelines, regras CP.60/CP.61 (futures e async) e CP.42 ("Don't wait without a condition"): as diretrizes sobre esperar corretamente.

Exercícios

Exercício 1

Use std::async para calcular, em segundo plano, o fatorial de um número enquanto a thread principal imprime uma mensagem. Colha o resultado com .get().

Ver resposta

✓ Resposta: Fatorial em segundo plano:

#include <iostream>
#include <future>
long long fatorial(int n) {
    long long r = 1;
    for (int i = 2; i <= n; ++i) r *= i;
    return r;
}
int main() {
    auto f = std::async(std::launch::async, fatorial, 20);
    std::cout << "calculando em paralelo...\n";
    std::cout << "20! = " << f.get() << '\n';   // 2432902008176640000
    return 0;
}

Exercício 2

Explique a diferença entre gerenciar uma tarefa com std::thread + join e com std::async + future. Cite duas vantagens do async para o caso "calcule algo e me devolva o resultado".

Ver resposta

✓ Resposta: Com std::thread + join, você gerencia a thread explicitamente: cria, precisa lembrar de dar join, e — se a tarefa produz um resultado — tem de coordenar a entrega desse valor à mão (por exemplo, via uma variável compartilhada protegida por mutex). Com std::async + future, você só descreve a tarefa e recebe um future de onde colhe o resultado com .get(). Duas vantagens do async: primeiro, a entrega do resultado é automática — o future transporta o valor de retorno da função, sem você montar canais de comunicação; segundo, a propagação de exceções é automática — se a tarefa lançar, a exceção é relançada no .get(), integrando-se ao tratamento de erros normal, enquanto com std::thread uma exceção não capturada na thread abortaria o programa. Some-se a isso que o async cuida do ciclo de vida da thread, evitando o risco de esquecer o join.

Exercício 3

Divida a soma de um std::vector<int> grande em quatro fatias e some cada uma com um std::async separado, combinando os quatro resultados. Compare conceitualmente com fazer a soma sequencial.

Ver resposta

✓ Resposta: Soma em quatro fatias:

#include <iostream>
#include <future>
#include <vector>
#include <numeric>
int main() {
    std::vector<int> v(4000, 1);
    auto soma = [&v](std::size_t a, std::size_t b) {
        return std::accumulate(v.begin()+a, v.begin()+b, 0L);
    };
    auto f1 = std::async(std::launch::async, soma, 0, 1000);
    auto f2 = std::async(std::launch::async, soma, 1000, 2000);
    auto f3 = std::async(std::launch::async, soma, 2000, 3000);
    auto f4 = std::async(std::launch::async, soma, 3000, 4000);
    long total = f1.get() + f2.get() + f3.get() + f4.get();
    std::cout << "total: " << total << '\n';   // 4000
    return 0;
}

Conceitualmente, a soma sequencial percorreria os 4000 elementos numa única thread; a versão paralela divide o trabalho em quatro partes que rodam simultaneamente em núcleos diferentes, reduzindo o tempo (idealmente para cerca de um quarto, descontado o overhead). O ganho só é real porque as fatias são independentes — cada uma soma sua parte sem tocar nas outras, sem dados compartilhados mutáveis, o cenário ideal para concorrência.

Exercício 4

Explique por que a cv.wait() recebe um predicado ([]{ return pronto; }) e o que poderia dar errado se você usasse cv.wait(trava) sem ele. O que é um "acordar espúrio"?

Ver resposta

✓ Resposta: A cv.wait() recebe um predicado porque a notificação de uma condition_variable não é uma garantia absoluta de que a condição desejada seja verdadeira: uma thread esperando pode acordar por dois motivos além da notificação legítima — um acordar espúrio (a thread acorda sozinha, sem que ninguém a tenha notificado, um comportamento permitido pela implementação por razões de eficiência) ou uma notificação que ocorreu antes de a condição estar realmente pronta. O predicado ([]{ return pronto; }) faz a wait reverificar a condição a cada despertar: se for falsa, ela volta a dormir; só prossegue quando for verdadeira. Sem o predicado, cv.wait(trava) acordaria em qualquer notificação ou acordar espúrio e prosseguiria mesmo com a condição ainda falsa — um bug clássico em que o consumidor age antes de o dado estar pronto. O predicado torna a espera robusta.

Exercício 5

Discuta quando std::async é preferível ao gerenciamento manual de threads e quando você ainda precisaria de std::thread diretamente. Dê um exemplo de cada situação.

Ver resposta

✓ Resposta: std::async é preferível quando o padrão é "execute uma tarefa que produz um resultado e me entregue esse resultado" — ele cuida da thread, do transporte do valor e da propagação de exceções, tornando o código mais simples e seguro. Exemplo: calcular em paralelo o resultado de várias consultas independentes e combiná-los ao final — cada async devolve um future, e você colhe todos. Já std::thread diretamente é necessário quando você precisa de controle fino sobre a thread ou quando a tarefa não segue o modelo "roda e devolve um valor": threads de longa duração que rodam um laço contínuo em segundo plano (um servidor que atende conexões indefinidamente, uma thread de interface que processa eventos), threads que você precisa nomear, configurar prioridade, ou gerenciar explicitamente o ciclo de vida. Exemplo: uma thread que fica escutando mensagens de rede enquanto o programa vive — não há um "resultado" único a colher com .get(), então std::thread (ou std::jthread) é o modelo adequado. Resumo: async para tarefas com resultado; thread para atividades contínuas ou que exigem controle direto.

Comentários

Mais em Linguagem C++

Um Nome, Vários Sentidos — Sobrecarga de Funções
Um Nome, Vários Sentidos — Sobrecarga de Funções

Ter três nomes para valor absoluto não era capricho da biblioteca do C: dois…

O Mapa da Jornada: de C a C++ em 52 Passos
O Mapa da Jornada: de C a C++ em 52 Passos

Quem vem do C chega com vantagem e com risco: a fundação está pronta, mas o…

A Vida Começa Aqui — Inicialização de Membros a Fundo
A Vida Começa Aqui — Inicialização de Membros a Fundo

Inicializar membros parece assunto resolvido até aparecerem os casos em que…