Skip to content
C++

std::future and std::promise

The traditional approach to concurrent communication is shared variables protected by a mutex — one thread writes, another reads, the mutex keeps them from stepping on each other. C++11 offers a higher-level model: tasks. A task separates producing a result (the promise) from consuming it (the future). The data channel between them is implicitly protected — you need no mutex, no condition variable, and no shared state. The consumer simply calls future.get() and blocks until the result is ready. Exceptions propagate automatically across thread boundaries. All of this lives in the <future> header.

Tasks versus threads: a different communication model

With std::thread, the caller and the new thread communicate via shared state: a variable declared outside both, protected by a mutex if it can be accessed concurrently. The caller waits for the thread to finish with join(). If the thread throws an exception that escapes its callable, std::terminate is called — the program dies. With tasks, all of this is different. The communication channel is created automatically when the promise and future are linked. No mutex is needed because the channel synchronises access internally. And if the working thread throws, the exception is captured and re-thrown when the caller calls get() — an enormously more manageable failure model.

Aspectstd::threadTasks (future/promise)
CommunicationShared variablesData channel (implicitly protected)
Synchronization needed?Yes — mutex / condition variableNo — get() handles it
Caller waits viajoin()future.get()
Exception in workerstd::terminate — program diesStored; re-thrown at get()
Result transportManual (write to shared variable)Automatic via the channel
One result to manyManual with shared statestd::shared_future
// Thread approach: manual shared state and synchronization
int result;
std::thread t([&]{ result = 2000 + 11; });
t.join();
std::println("{}", result);          // 2011

// Task approach: automatic channel, no shared variable needed
auto fut = std::async([]{ return 2000 + 11; });
std::println("{}", fut.get());       // 2011

std::async — your first choice for async work

std::async is the simplest way to run work asynchronously. It takes a callable and its arguments, and returns a std::future that will eventually hold the result. The C++ runtime decides whether to execute the work in a new thread or in the caller's thread, based on the number of available cores and current system utilization. You never have to create, join, or manage a thread yourself. The result is retrieved (blocking if not yet ready) by calling fut.get().

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

// Compute partial sum asynchronously
auto fut = std::async([](int lo, int hi) {
    int sum = 0;
    for (int i = lo; i < hi; ++i) sum += i;
    return sum;
}, 1, 10001);

// Do other work here while the async task runs...

int result = fut.get();   // blocks until ready if not done yet
std::println("sum 1..10000 = {}", result);   // 50005000

You can control when the work runs by passing a launch policy as the first argument. std::launch::async forces the work to start immediately in a new thread. std::launch::deferred is lazy: the work is postponed until you call get() or wait(), and it runs in the caller's thread. Deferred is essentially a way to write lazy evaluation.

// Force a new thread — starts immediately
auto eager = std::async(std::launch::async, heavy_computation);

// Deferred — runs in caller thread when get() is called
auto lazy  = std::async(std::launch::deferred, heavy_computation);

// Default (no policy): runtime decides — equivalent to async|deferred
auto auto_ = std::async(heavy_computation);

// Proof that deferred runs later:
auto begin = std::chrono::steady_clock::now();
auto deferred = std::async(std::launch::deferred,
                           [] { return std::chrono::steady_clock::now(); });
std::this_thread::sleep_for(std::chrono::seconds(1));
auto start_time = deferred.get();    // only now does the lambda run
// start_time - begin ≈ 1 second

std::packaged_task — decouple packaging from execution

std::async launches work immediately (or defers it but runs it for you). Sometimes you need to prepare a task, store it, and decide later when and where to run it — perhaps in a thread pool, or triggered by an event. That is what std::packaged_task is for. It wraps a callable and gives you a future for its result, but it does not run the callable — you invoke it explicitly, either directly or by moving it into a thread. This decoupling of packaging from execution is the central feature.

Using a packaged_task always follows four steps: wrap the callable, obtain the future, trigger execution, then query the result via the future.

#include <future>

// Step 1: wrap the callable — specify signature as template arg
std::packaged_task<int(int, int)> task([](int a, int b) {
    return a + b;
});

// Step 2: get the future before triggering execution
std::future<int> result = task.get_future();

// Step 3: execute — either here, or move to a thread
task(2000, 11);   // invoke directly (runs in this thread)
// OR: std::thread t(std::move(task), 2000, 11); t.detach();

// Step 4: query the result (blocks if not done yet)
std::println("{}", result.get());   // 2011
// Real use-case: thread pool / task queue
std::deque<std::packaged_task<int(int, int)>> task_queue;
std::vector<std::future<int>> futures;

for (auto [a, b] : work_items) {
    std::packaged_task<int(int, int)> t([](int x, int y) { return x + y; });
    futures.push_back(t.get_future());
    task_queue.push_back(std::move(t));
}

// Worker thread drains the queue
while (!task_queue.empty()) {
    auto t = std::move(task_queue.front());
    task_queue.pop_front();
    std::thread(std::move(t), a, b).detach();   // run each in a new thread
}

// Collect results
for (auto& f : futures)
    std::println("{}", f.get());

std::promise and std::future — full manual control

std::async and packaged_task both create the promise and future for you. For maximum control — setting a result at an arbitrary point in a thread, propagating an exception across threads, or sending just a notification with no value — you manage the promise and future directly. std::promise<T> is the write end of the channel; you create it, call get_future() to obtain the associated future, then move the promise into whichever thread does the work. That thread calls set_value() when it has a result, or set_exception() to propagate a failure. The waiting side calls future.get() to receive the value — or re-throws the exception if one was stored.

#include <future>

void compute_product(std::promise<int>&& prom, int a, int b) {
    prom.set_value(a * b);   // deposit result in the channel
}

int a = 20, b = 10;
std::promise<int> p;
std::future<int>  fut = p.get_future();

// Move the promise into the worker thread
std::thread worker(compute_product, std::move(p), a, b);

// Blocking get — receives the value once set_value() is called
std::println("{}*{}= {}", a, b, fut.get());   // 20*10= 200

worker.join();

A promise of void is a pure synchronization signal — it carries no value, just the notification that some event has occurred. The receiving side calls fut.wait() (or get() which returns void). This is a safe, simple replacement for a condition variable: no lock needed, no spurious wakeups possible.

// Synchronization using promise<void>: no value, just a signal
std::promise<void> ready_promise;
auto ready_future = ready_promise.get_future();

void sender(std::promise<void>&& p) {
    std::println("Sender: data is ready.");
    p.set_value();              // send the notification
}

void receiver(std::future<void>&& f) {
    std::println("Receiver: waiting for signal...");
    f.wait();                   // block until set_value() is called
    std::println("Receiver: got the signal, proceeding.");
}

std::thread t1(sender,   std::move(ready_promise));
std::thread t2(receiver, std::move(ready_future));
t1.join(); t2.join();

API reference

The promise and future APIs are small and symmetrical. Setting the value or exception on a promise that already has one throws std::future_error. Calling get() on a future more than once also throws std::future_error — the result can only be consumed once. Use std::shared_future(obtained via fut.share()) if multiple threads need to receive the same result.

std::promise<T>

MemberDescription
get_future()Returns the associated std::future (call once)
set_value(val)Stores val; unblocks any waiting future.get()
set_exception(ex)Stores exception_ptr; re-thrown at future.get()
set_value_at_thread_exit(val)Stores val; makes ready when current thread exits
set_exception_at_thread_exit(ex)Stores exception; makes ready when thread exits
swap(prom2) / std::swapSwaps two promises

std::future<T>

MemberDescription
get()Blocks until ready, returns value or re-throws exception. valid() becomes false.
valid()Returns true if future has a shared state (not yet consumed by get())
wait()Blocks until result is ready; does not consume it
wait_for(duration)Waits up to duration; returns future_status (ready/timeout/deferred)
wait_until(time_point)Waits until an absolute time point
share()Returns std::shared_future<T>; this future becomes invalid

Exception propagation across threads

One of the most valuable properties of the future/promise model is that exceptions thrown in the worker thread are not lost. With std::async and packaged_task, this happens automatically: an unhandled exception in the callable is captured and stored in the future's shared state. When the caller calls get(), the stored exception is re-thrown there, in the caller's thread, where it can be caught normally. With a raw std::promise, you store the exception manually using set_exception(std::current_exception()).

// Automatic exception propagation via std::async
auto fut = std::async([] {
    throw std::runtime_error("worker failed");
    return 42;
});

try {
    int val = fut.get();   // re-throws the stored exception
} catch (const std::runtime_error& e) {
    std::println("caught: {}", e.what());   // "caught: worker failed"
}

// Manual exception propagation via promise
std::promise<int> p;
auto f = p.get_future();

std::thread([&p] {
    try {
        throw std::runtime_error("compute error");
    } catch (...) {
        p.set_exception(std::current_exception());  // store any active exception
    }
}).join();

try {
    f.get();   // re-throws
} catch (const std::runtime_error& e) {
    std::println("caught: {}", e.what());
}

std::shared_future — one result, many receivers

A std::future can only be consumed once — calling get() a second time throws std::future_error. If you need multiple threads to all receive the same result, convert the future to a std::shared_future by calling fut.share(). A shared future can be copied, and every copy can independently call get(). All copies share the same underlying state — the result is computed once by the promise and made available to all readers.

auto fut = std::async(compute_config);
std::shared_future<Config> shared = fut.share();   // fut is now invalid

// Multiple threads can each hold a copy and call get()
std::vector<std::thread> workers;
for (int i = 0; i < 8; ++i) {
    workers.emplace_back([shared] {   // capture by value (copy of shared_future)
        const Config& cfg = shared.get();   // all 8 threads get the same config
        do_work(cfg);
    });
}
for (auto& t : workers) t.join();

Which facility to use

Start with std::async

It is the simplest: pass a callable, get a future, call get(). The runtime handles thread management. Reach for it first unless you have a specific reason not to.

Use packaged_task for deferred or queued execution

When you need to separate the creation of a task from its execution — thread pools, event loops, or work queues.

Use promise/future for fine-grained control

When the result is produced by complex logic that doesn't fit neatly into a single callable, or when you need to signal from one point in a thread and receive from another.

Use promise<void> as a safer condition variable

For pure synchronisation signals with no value. Unlike condition variables, no lock needed, no spurious wakeups.

Use shared_future when multiple threads need the same result

fut.share() makes get() callable on copies. Design for it upfront — converting late is easy but copy semantics must be clear.

Sign in to track progress