Skip to content
C++
Library
since C++11
Intermediate

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++11

A 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.

PrimitiveProblem solvedLaunches thread?
std::asyncRun a callable, retrieve result laterImplementation-defined (see pitfalls)
std::promisePush a value or exception to a waiting threadNo β€” you manage execution
std::futureReceive a value or exception from another threadNo β€” read end only
std::packaged_taskWrap a callable to give it a future interfaceNo β€” you control scheduling

std::async

cpp
#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 task

Launch policy

cpp
// 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, synchronously

Always 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.

cpp
{
    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:

cpp
// 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.

cpp
#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:

  • future is move-only; copying it fails to compile.
  • get() may be called exactly once. A second call throws std::future_error(future_errc::no_state).
  • Destroying a promise without calling set_value or set_exception stores a broken_promise exception in the shared state β€” fut.get() will throw it.

shared_future β€” broadcast reads

cpp
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

cpp
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.

cpp
#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

cpp
#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:

cpp
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_promise

No cancellation. None of these primitives support cancellation once started. For cooperative cancellation, use C++20 std::stop_token with std::jthread:

cpp
#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() automatically

std::async tasks cannot be cancelled at all after launch β€” there is no handle to the underlying thread.

Best Practices

  • Always specify launch::async explicitly. Default policy may run your task on the calling thread inside get().
  • Never discard the future from std::async. A discarded future blocks at the discard point, serialising concurrent work.
  • Prefer packaged_task + a thread pool over std::async for production throughput β€” std::async provides no guarantee of thread reuse or bounded resource consumption.
  • Convert to shared_future before sharing across threads. Races on future itself are undefined behaviour.
  • Always observe exceptions via get(). Exceptions stored in shared states are silent until retrieved; wait() alone does not rethrow.
  • Keep promise alive until set_value or set_exception. Early destruction causes broken_promise on 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.

cpp
// 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.

cpp
// 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 false

Chaining 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().

cpp
// 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.

cpp
// 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.

cpp
// 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:

cpp
// 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.

cpp
// 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.

cpp
// 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 == 85

The key differences from std::future:

Featurestd::future (C++11)std::execution (C++26)
CompositionManual chaining, each step blocks.then() / pipe operators, no intermediate blocks
CancellationNoneFirst-class via stop tokens on schedulers
Lazy / eagerEager by default (launch::async)Lazy until connected
Thread modelstd::async uses implementation threadsExplicit 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