std::async, future, promise, packaged_task
"C++11 async primitives: std::async for task-based concurrency, future/promise as a one-shot cross-thread value channel, packaged_task for callable wrapping."
std::future / std::async / std::promise / std::packaged_tasksince C++11A family of cooperating types in <future> that implement task-based concurrency: async launches a callable and returns a future; promise/future form a one-shot cross-thread value channel; packaged_task wraps a callable into a promise/future pair for use with any scheduler.
Overview
These four primitives form C++11's task-based concurrency layer, sitting above raw std::thread and below full executors or coroutines. They all share a shared state β a reference-counted object carrying the value, exception, and readiness flag between producer and consumer.
| Primitive | Problem solved | Launches thread? |
|---|---|---|
std::async | Run a callable, retrieve result later | Implementation-defined (see pitfalls) |
std::promise | Push a value or exception to a waiting thread | No β you manage execution |
std::future | Receive a value or exception from another thread | No β read end only |
std::packaged_task | Wrap a callable to give it a future interface | No β you control scheduling |
std::async
#include <future>
#include <vector>
#include <numeric>
std::vector<int> data(1'000'000);
std::iota(data.begin(), data.end(), 0);
// launch::async guarantees a new thread (C++11)
auto fut = std::async(std::launch::async, [&data] {
return std::accumulate(data.begin(), data.end(), 0LL);
});
// Do other work while the accumulation runs...
long long total = fut.get(); // blocks; rethrows any exception thrown by the taskLaunch policy
// std::launch::async β new thread, starts immediately
// std::launch::deferred β no thread; task runs on the calling thread at the first get() or wait()
// Default (omitted) β implementation chooses either; commonly deferred under light load,
// which silently serialises what you intended to parallelise
auto eager = std::async(std::launch::async, compute); // definitely parallel
auto lazy = std::async(std::launch::deferred, compute); // definitely deferred
lazy.get(); // compute() runs HERE, on this thread, synchronouslyAlways specify a launch policy. The default launch::async | launch::deferred lets the implementation run your task on the calling thread inside get(), defeating the intent.
Destructor blocking β the most common trap
A future returned by std::async(launch::async, ...) blocks its destructor until the task completes. No other kind of future does this; it is unique to async-launched shared states.
{
auto fut = std::async(std::launch::async, long_running_task);
} // β destructor blocks here until long_running_task() returns
// The silent variant β discarding the return value makes the call synchronous:
std::async(std::launch::async, long_running_task);
// Temporary future destroyed immediately: this is fully synchronous, not async.This also serialises loops:
// WRONG: each iteration blocks before the next task launches
for (int i = 0; i < 8; ++i)
std::async(std::launch::async, work, i); // temporary future blocks here
// RIGHT: keep futures alive; collect results after all tasks are running
std::vector<std::future<void>> futs;
futs.reserve(8);
for (int i = 0; i < 8; ++i)
futs.push_back(std::async(std::launch::async, work, i));
for (auto& f : futs) f.get();promise and future
promise is the write end; future is the read end of a one-shot channel. Use this pair when you need to communicate a value (or exception) from a thread you manage manually.
#include <future>
#include <thread>
std::promise<int> prom;
std::future<int> fut = prom.get_future(); // call get_future() exactly once
std::thread worker([p = std::move(prom)]() mutable {
try {
p.set_value(expensive_computation());
} catch (...) {
p.set_exception(std::current_exception()); // exception surfaces on fut.get()
}
});
int result = fut.get(); // blocks; rethrows if set_exception() was called
worker.join();Critical constraints:
futureis move-only; copying it fails to compile.get()may be called exactly once. A second call throwsstd::future_error(future_errc::no_state).- Destroying a
promisewithout callingset_valueorset_exceptionstores abroken_promiseexception in the shared state βfut.get()will throw it.
shared_future β broadcast reads
std::promise<std::string> prom;
std::shared_future<std::string> sfut = prom.get_future().share(); // C++11
// safe: multiple threads calling get() on the same shared_future
auto reader = [sfut](int id) {
std::cout << "reader " << id << ": " << sfut.get() << '\n';
};
std::thread t1(reader, 1), t2(reader, 2), t3(reader, 3);
prom.set_value("go");
t1.join(); t2.join(); t3.join();shared_future::get() returns const T& and may be called any number of times from any thread.
Non-blocking status checks
std::future<int> fut = std::async(std::launch::async, slow_fn);
switch (fut.wait_for(std::chrono::milliseconds(50))) {
case std::future_status::ready: std::cout << fut.get(); break;
case std::future_status::timeout: std::cout << "still running\n"; break;
case std::future_status::deferred: std::cout << "not started\n"; break;
}
// Wait until a deadline
fut.wait_until(std::chrono::steady_clock::now() + std::chrono::seconds(5));packaged_task
packaged_task<R(Args...)> wraps a callable and gives it a future interface without launching a thread. It separates packaging work from scheduling it β exactly what thread pools need.
#include <future>
#include <thread>
std::packaged_task<int(int, int)> task([](int a, int b) { return a + b; });
std::future<int> fut = task.get_future();
std::thread t(std::move(task), 21, 21); // execute on worker thread
std::cout << fut.get() << '\n'; // 42
t.join();Thread pool with packaged_task
#include <future>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
#include <condition_variable>
class ThreadPool {
std::vector<std::thread> workers_;
std::queue<std::function<void()>> queue_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
public:
explicit ThreadPool(std::size_t n) {
workers_.reserve(n);
for (std::size_t i = 0; i < n; ++i)
workers_.emplace_back([this] {
for (;;) {
std::function<void()> job;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [this] { return stop_ || !queue_.empty(); });
if (stop_ && queue_.empty()) return;
job = std::move(queue_.front());
queue_.pop();
}
job();
}
});
}
template<typename F, typename... Args>
auto submit(F&& f, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>> {
// invoke_result_t: C++17; use result_of_t in C++11/14
using Ret = std::invoke_result_t<F, Args...>;
// shared_ptr needed because packaged_task is move-only but std::function requires copyability
auto task = std::make_shared<std::packaged_task<Ret()>>(
[f = std::forward<F>(f),
tup = std::make_tuple(std::forward<Args>(args)...)]() mutable {
return std::apply(std::move(f), std::move(tup)); // std::apply: C++17
});
auto fut = task->get_future();
{
std::lock_guard lock(mtx_);
if (stop_) throw std::runtime_error("pool is stopped");
queue_.emplace([task] { (*task)(); });
}
cv_.notify_one();
return fut;
}
~ThreadPool() {
{ std::lock_guard lock(mtx_); stop_ = true; }
cv_.notify_all();
for (auto& t : workers_) t.join();
}
};
// Usage
ThreadPool pool(std::thread::hardware_concurrency());
std::vector<std::future<int>> futs;
for (int i = 0; i < 16; ++i)
futs.push_back(pool.submit([i] { return i * i; }));
for (auto& f : futs)
std::cout << f.get() << ' ';Common Pitfalls
Calling get() twice throws std::future_error(future_errc::no_state). If multiple consumers need the value, convert to shared_future first.
Broken promise from early destruction:
std::future<int> make_broken() {
std::promise<int> p;
return p.get_future();
// p destroyed here without set_value/set_exception
// β broken_promise exception stored in shared state
}
make_broken().get(); // throws std::future_error: broken_promiseNo cancellation. None of these primitives support cancellation once started. For cooperative cancellation, use C++20 std::stop_token with std::jthread:
#include <stop_token> // C++20
#include <thread> // C++20: std::jthread
std::jthread worker([](std::stop_token st) { // C++20
while (!st.stop_requested())
do_unit_of_work();
});
// jthread destructor calls request_stop() then join() automaticallystd::async tasks cannot be cancelled at all after launch β there is no handle to the underlying thread.
Best Practices
- Always specify
launch::asyncexplicitly. Default policy may run your task on the calling thread insideget(). - Never discard the
futurefromstd::async. A discarded future blocks at the discard point, serialising concurrent work. - Prefer
packaged_task+ a thread pool overstd::asyncfor production throughput βstd::asyncprovides no guarantee of thread reuse or bounded resource consumption. - Convert to
shared_futurebefore sharing across threads. Races onfutureitself are undefined behaviour. - Always observe exceptions via
get(). Exceptions stored in shared states are silent until retrieved;wait()alone does not rethrow. - Keep
promisealive untilset_valueorset_exception. Early destruction causesbroken_promiseon the consumer side.
std::async launch policies
The default launch policy is a trap. Passing std::launch::async | std::launch::deferred (the default) allows the implementation to run the task lazily on the calling thread, which silently converts concurrent code into sequential code under load.
// C++11
#include <future>
// Bad: implementation may choose deferred, serialising all "parallel" work
auto f1 = std::async(compute_a);
auto f2 = std::async(compute_b); // might run on THIS thread at f2.get()
// Good: both tasks guaranteed to launch on new threads immediately
auto g1 = std::async(std::launch::async, compute_a);
auto g2 = std::async(std::launch::async, compute_b);
long long result = g1.get() + g2.get();The deferred policy is useful for lazy evaluation: the callable captures its arguments and runs only if and when get() or wait() is actually called β not before.
// C++11
// Lazy memoisation pattern: only compute if the result is needed
auto maybe = std::async(std::launch::deferred, [] { return expensive_table(); });
if (need_table)
use(maybe.get()); // runs here; skipped entirely if need_table is falseChaining futures: when_all pattern
The standard library has no built-in when_all, but the idiom is straightforward: collect all futures into a vector, then iterate with get().
// C++17
#include <future>
#include <vector>
#include <numeric>
#include <algorithm>
template<typename T>
std::vector<T> gather(std::vector<std::future<T>>& futs) {
std::vector<T> results;
results.reserve(futs.size());
for (auto& f : futs)
results.push_back(f.get()); // blocks on each in turn
return results;
}
// Launch all tasks first, then gather β true parallelism
std::vector<std::future<int>> tasks;
for (int i = 0; i < 8; ++i)
tasks.push_back(std::async(std::launch::async, [i] { return i * i; }));
auto squares = gather(tasks);
// squares = {0, 1, 4, 9, 16, 25, 36, 49}Note that gather processes results in submission order, not completion order. If tasks have wildly different runtimes and you want to process each result as soon as it is ready, use a shared_future + polling loop or a proper task queue with a completion channel.
Timeouts with wait_for and wait_until
Blocking forever on get() is rarely correct in production. Both future and shared_future expose wait_for and wait_until to bound how long a thread will wait.
// C++11
#include <future>
#include <chrono>
std::future<int> fut = std::async(std::launch::async, slow_network_call);
// Poll with a relative timeout
auto status = fut.wait_for(std::chrono::seconds(2));
if (status == std::future_status::ready) {
handle(fut.get());
} else if (status == std::future_status::timeout) {
log_warning("network call timed out");
// fut is still valid; you can try wait_for again later
}
// Alternatively, wait until an absolute deadline
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
if (fut.wait_until(deadline) == std::future_status::ready)
handle(fut.get());wait_for on a deferred future returns future_status::deferred immediately without running the task β it does not start execution. Only get() or wait() (without a duration) triggers a deferred task.
Futures and exception propagation
Any exception thrown inside a task is silently captured and stored in the shared state. It surfaces β and is rethrown β only when get() is called.
// C++11
#include <future>
#include <stdexcept>
auto fut = std::async(std::launch::async, [] -> int {
throw std::runtime_error("task failed");
return 0;
});
try {
int val = fut.get(); // rethrows std::runtime_error here
} catch (const std::exception& e) {
std::cerr << "caught: " << e.what() << '\n';
}You can also inject an exception manually through a promise:
// C++11
std::promise<int> prom;
auto fut = prom.get_future();
std::thread([p = std::move(prom)]() mutable {
try {
p.set_value(might_throw());
} catch (...) {
p.set_exception(std::current_exception()); // stores any in-flight exception
}
}).detach();
// Consumer β exception emerges at get(), not at set_exception()
int result = fut.get();Calling wait() alone never rethrows; you must call get() to observe stored exceptions.
shared_future for multiple consumers
std::future is a single-consumer channel. Converting to shared_future allows any number of threads to call get() on the same shared state without a race condition.
// C++11
#include <future>
#include <thread>
#include <vector>
std::promise<std::string> config_prom;
// .share() consumes the future and returns a copyable shared_future
std::shared_future<std::string> config = config_prom.get_future().share();
// Fan-out: every worker gets its own copy of shared_future
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([config, i] {
// Multiple threads calling get() on shared_future: safe, returns const T&
std::cout << "worker " << i << " config: " << config.get() << '\n';
});
}
config_prom.set_value("debug=false;threads=4");
for (auto& t : workers) t.join();shared_future::get() returns const T& (not a moved value) and can be called repeatedly after the state is ready. The underlying object lives until the last shared_future copy is destroyed.
std::future vs std::execution (C++26)
std::future blocks the calling thread; std::execution composes work without blocking. C++26 introduces std::execution (formerly P2300), a sender/receiver model that describes asynchronous pipelines as values rather than blocking handles.
// C++26 β std::execution (P2300)
#include <execution>
// A "sender" describes work; it does not execute until connected to a receiver
auto work = stdexec::just(42)
| stdexec::then([](int v) { return v * 2; })
| stdexec::then([](int v) { return v + 1; });
// sync_wait blocks once, at the edge β not at each step
auto [result] = stdexec::sync_wait(std::move(work)).value();
// result == 85The key differences from std::future:
| Feature | std::future (C++11) | std::execution (C++26) |
|---|---|---|
| Composition | Manual chaining, each step blocks | .then() / pipe operators, no intermediate blocks |
| Cancellation | None | First-class via stop tokens on schedulers |
| Lazy / eager | Eager by default (launch::async) | Lazy until connected |
| Thread model | std::async uses implementation threads | Explicit scheduler (thread pool, GPU, I/O ring) |
For new code targeting C++26 runtimes, prefer std::execution for any pipeline with more than one async step. Retain std::future / std::promise for simple fire-and-retrieve patterns and for interoperability with older codebases.
See Also
std::threadβ direct thread management without futuresstd::condition_variableβ lower-level thread signallingstd::jthread/std::stop_tokenβ C++20 cooperative cancellation- Coroutines β C++20
co_await-based async with continuations and no blocking destructors - Parallel algorithms β
std::execution::parfor data parallelism without manual futures