1. Para que servem std::promise e std::packaged_task
Se std::async é "chame a função em algum lugar e me devolva um future", então std::promise e std::packaged_task são ferramentas para casos em que o cálculo não se parece com uma chamada de função comum. Às vezes o resultado aparece não porque nós usamos return, mas porque "algo aconteceu": chegou uma resposta, terminou uma espera, um arquivo foi processado, um temporizador disparou, o usuário apertou um botão. Nesses cenários é conveniente separar papéis: um trecho de código produz o resultado, outro espera e consome.
E é aí que std::async deixa de ser tão universal. Ele é bom quando você tem um callable que retorna um valor. Mas se o produtor do resultado vive à parte, onde o "retorno" não se parece com sair de uma função, você quer um canal separado de entrega do resultado. Esse canal é fornecido pelo par promise/future.
Uma pequena pista prática: promise no nome é literalmente "promessa". Você meio que diz: "Eu prometo que mais tarde vou colocar o resultado aqui". Só que essa promessa é verificada pela biblioteca padrão (e às vezes de forma bem rigorosa).
Shared state: ligação entre promise e future
Antes de escrever código é útil entender o modelo interno em nível de ideia. Tanto std::future quanto std::promise trabalham em torno de uma entidade: o estado compartilhado do resultado (muitas vezes chamado de shared state). Imagine uma pequena "caixa de correio" onde se pode colocar ou um valor ou informação sobre um erro; o outro lado pode abrir essa caixa, aguardar até que esteja preenchida e então retirar o conteúdo.
flowchart LR
P["std::promise<T><br/>fornecedor (producer)"] -->|"set_value(...)"| S[(shared state<br/>celula de resultado)]
S -->|"get() / wait()"| F["std::future<T><br/>consumidor (consumer)"]
Ponto-chave: o future por si só não produz o resultado. Ele apenas espera e obtém. E o promise por si só não espera. Ele apenas coloca o resultado. Ambos são como duas metades de um cabo: separados são quase sem sentido, juntos são muito úteis.
2. std::promise<T>: entregando o resultado manualmente
Protocolo mínimo: promise → future → set_value
Agora vem o "esqueleto" importante de uso de std::promise. Ele é quase sempre o mesmo e se repete em muitos programas. Primeiro você cria um promise<T>, depois obtém um future<T> a partir dele, e então em outro lugar chama set_value(...) para gravar o resultado.
#include <future>
#include <iostream>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
p.set_value(123);
std::cout << f.get() << '\n'; // 123
}
Note a coisa que pode parecer "ilógica" à primeira vista: get_future() é chamado no promise, não no future. Isso porque é o promise que tem o direito de criar o futuro consumidor do resultado. E mais um detalhe: normalmente get_future() só pode ser chamado uma vez — não é uma máquina de bilhetes infinita.
promise em outra thread: std::move e mutable
Na prática o promise geralmente vive em outra thread: onde o trabalho é realmente feito. Por isso o promise costuma ser movido (std::move) para dentro da lambda da thread. Esse é um padrão clássico.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
pr.set_value(42);
});
std::cout << f.get() << '\n'; // 42
}
Por que aqui usamos mutable? Porque a lambda por padrão torna o seu operator() const, e set_value modifica o estado do promise. Sem mutable o compilador corretamente reclamaria: "Você está tentando modificar pr dentro de um operador const".
Sinal de conclusão: std::promise<void>
Às vezes você não precisa de um valor, apenas do fato da conclusão. É como "quando chegarmos ao fim, acenamos com a bandeira". Para isso é conveniente usar a especialização void.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<void> p;
std::future<void> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
// ... fazemos o trabalho
pr.set_value(); // sinal "pronto"
});
f.get(); // apenas esperamos
std::cout << "done\n"; // done
}
Esse código é legível: future<void> é literalmente "espere a conclusão".
3. TaskConsole: separando execução da entrega do resultado
Vamos combinar um contexto para que os exemplos não pareçam fragmentos desconexos. Imagine que temos um aplicativo de console didático simples chamado TaskConsole que sabe iniciar "tarefas" em background e obter o resultado. Não vamos construir um pool de threads completo (isso é um grande tópico à parte), mas queremos aprender a separar: quem calcula e quem espera o resultado.
Comecemos com a "tarefa" que calcula algo. Seja uma função simples e didática: uma "soma lenta" (apenas para termos algo a executar).
#include <chrono>
#include <thread>
int slow_add(int a, int b) {
std::this_thread::sleep_for(std::chrono::milliseconds{200});
return a + b;
}
Agora façamos uma função que inicia um worker e retorna future<int>. Esse será o primeiro "bloco de arquitetura" do nosso TaskConsole: submit_* retorna um future, e o main decide quando aguardá-lo.
#include <future>
#include <thread>
std::future<int> submit_slow_add(int a, int b) {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread([pr = std::move(p), a, b]() mutable {
pr.set_value(slow_add(a, b));
}).detach(); // no código real detach é perigoso; aqui é só demonstração
return f;
}
Aqui mostramos intencionalmente detach() como uma "jogada de demo" para manter o exemplo pequeno. Em projetos reais normalmente você armazenaria a thread, usaria std::jthread sem detach, ou aplicaria outra disciplina de tempo de vida. Mas a ideia da palestra não é "como gerenciar threads corretamente", e sim "como entregar o resultado".
Mini-teste em main:
#include <iostream>
int main() {
auto f = submit_slow_add(10, 32);
std::cout << "waiting...\n";
std::cout << f.get() << '\n'; // 42
}
Se você agora pensa: "Espere, mas std::async também faz isso!" — você está certo. Por ora o exemplo se parece com async. Mas demos um passo importante: separamos o canal do resultado do mecanismo de execução. Isso será útil quando o resultado aparecer não via return, mas por um evento mais complexo.
Fila "tarefa + future" sem pool de threads
Agora vamos dar um pequeno passo em direção à "despachação", mas com muito cuidado: sem pool de threads, sem sincronização complexa e sem um monstro arquitetural. Vamos apenas mostrar a ideia de por que std::packaged_task é útil: a tarefa pode ser guardada como objeto, e o resultado fica acessível via future.
Criaremos um "conjunto de tarefas" e as executaremos uma a uma em threads separadas (não eficiente, mas claro).
#include <future>
#include <thread>
std::future<int> run_detached(std::packaged_task<int()> task) {
auto f = task.get_future();
std::thread([t = std::move(task)]() mutable { t(); }).detach();
return f;
}
Agora é possível criar tarefas como lambdas e obter futures:
#include <iostream>
int main() {
auto f1 = run_detached(std::packaged_task<int()>([] { return 40; }));
auto f2 = run_detached(std::packaged_task<int()>([] { return 2; }));
std::cout << (f1.get() + f2.get()) << '\n'; // 42
}
Repare como a API fica agradável: "aqui está a tarefa, devolva um future". Esse é exatamente o motivo pelo qual packaged_task é frequentemente usado: quando você quer separar a criação da tarefa e sua execução, mantendo um canal simples para o resultado.
4. std::packaged_task: callable que preenche o future
Por que usar packaged_task se já existe promise
std::packaged_task é um invólucro conveniente em torno de um callable (função, lambda, objeto funcional). Ele faz duas coisas: guarda o callable e gerencia o estado compartilhado do resultado de modo que, quando a task é invocada, o resultado vai automaticamente para o future associado.
Se promise é o "entregador manual" (você mesmo decide o que e quando colocar), então packaged_task é "entregador + instrução", onde a instrução é o callable. Você chama a task e ela entrega o resultado.
Mini-exemplo: empacotamos a função e obtemos um future
#include <future>
#include <iostream>
#include <thread>
int mul(int a, int b) { return a * b; }
int main() {
std::packaged_task<int(int, int)> task(mul);
std::future<int> f = task.get_future();
std::jthread worker([t = std::move(task)]() mutable {
t(6, 7); // após a chamada o future fica ready
});
std::cout << f.get() << '\n'; // 42
}
O sentido fica quase literal: "empacote a multiplicação em uma task, pegue o future, depois em algum lugar invoque a task — e você terá o resultado".
E sim: packaged_task, assim como promise, normalmente é um objeto movível. Faz sentido: internamente há estado que não pode ser copiado livremente.
Mini-exemplo: packaged_task com lambda sem parâmetros
Às vezes é mais conveniente fazer uma tarefa sem parâmetros (por exemplo, você já capturou tudo que precisa na lambda). Então a assinatura será R().
#include <future>
#include <iostream>
#include <string>
int main() {
std::string name = "C++";
std::packaged_task<std::string()> task([name] {
return "Hello, " + name;
});
auto f = task.get_future();
task(); // pode ser chamado na thread atual
std::cout << f.get() << '\n'; // Hello, C++
}
Esse é um bom exemplo de que packaged_task não precisa ser executado em outra thread. Ela apenas "vincula" o resultado ao future. Onde você executará a task é uma decisão de design do seu programa.
5. Como escolher a ferramenta e não quebrar a disciplina
promise vs packaged_task vs async
Aqui é fácil entrar em pânico: três ferramentas, todas "sobre assincronicidade", parecidas mas diferentes. Vamos estabilizar a imagem. Abaixo está uma tabela útil para manter em mente e evitar usar uma ferramenta errada para a tarefa.
| Ferramenta | O que você escreve | O que você obtém | Quem "coloca o resultado" | Quando é conveniente |
|---|---|---|---|---|
|
callable + argumentos | future<R> | a biblioteca (via return/exceção do callable) | "Execute isto e devolva o resultado depois" |
|
você mesmo chama set_value | future<T> | você manualmente | "O resultado aparecerá como um evento, não como um return" |
|
callable empacotado em um objeto | future<R> + objeto task | a task ao ser chamada | "Quero guardar/transferir a tarefa como objeto e obter um future" |
Resumindo grosseiramente: async é o mais "alto nível", promise é o mais "manual", e packaged_task é quando você quer representar a tarefa como uma entidade que pode ser armazenada e executada depois.
Disciplina de tempo de vida e responsabilidades
Ao começar a usar promise e packaged_task surge uma nova responsabilidade quase ausente no código "simples". Você deve garantir que o estado compartilhado seja finalizado: seja com um valor ou com um erro. Caso contrário o consumidor ficará esperando e receberá uma surpresa desagradável.
Na prática isso significa o seguinte. Se você criou um future, em algum lugar no mundo existe seu fornecedor (promise ou task). O fornecedor deve gravar um valor ou reportar um erro. Se o fornecedor desaparecer sem cumprir a promessa, o consumidor descobrirá isso ao chamar get() (será lançada uma exceção de "promessa quebrada", broken promise).
Como regra prática, não crie um canal de resultado se você não pode garantir que ele terá um fim. A biblioteca padrão age como um professor rigoroso: "prometeu — cumpra". E, em geral, isso é justo.
6. Erros típicos
Erro #1: tentar chamar get_future() duas vezes.
Iniciantes às vezes esperam obter "vários futures" de um único promise ou packaged_task. No modelo básico isso não funciona: get_future() normalmente fornece um único consumidor. Se você precisa de múltiplos consumidores, use outra construção (não é o tema desta palestra).
Erro #2: esquecer mutable ao mover um promise/packaged_task para dentro de uma lambda.
A lambda é const por padrão, e set_value() ou a chamada de packaged_task::operator() modifica o estado do objeto. O resultado é um erro de compilação que parece "do nada". A solução é adicionar mutable e lembrar que "dentro da lambda estamos modificando o objeto capturado".
Erro #3: capturar por referência dados que não sobrevivem ao trabalho da thread.
História muito típica: você cria um promise, dispara uma thread, e dentro da lambda usa uma referência para uma variável local do main. O main segue em frente, a variável morre, e a thread ainda está viva. No melhor caso você terá valores estranhos, no pior caso um crash. Em código assíncrono isso acontece com facilidade; capturar por valor e planejar o tempo de vida é proteção, não frescura.
Erro #4: confundir "empacotar a tarefa" com "executar a tarefa".
std::packaged_task por si só não faz nada. Ela não é executada automaticamente. Se você criou a task, obteve um future e esqueceu de chamar a task, o future ficará eternamente "não pronto". É como "comprar um despertador e não dar corda".
Erro #5: construir arquitetura excessivamente complexa onde std::async basta.
Às vezes promise e packaged_task parecem "mais adultos", e a mão coça para usá-los por toda parte. Isso é uma armadilha. Se a tarefa se expressa naturalmente como uma função "calcule e retorne", std::async será geralmente mais simples e claro. promise e packaged_task brilham quando você precisa realmente separar producer/consumer ou armazenar a tarefa como objeto (por exemplo, para despacho).
GO TO FULL VERSION