Understanding Futures and Promises in Modern C++
Modern C++ puts powerful concurrency tools in your hands, but raw threads still make it easy to race on shared state, forget to join, or swallow exceptions. Futures and promises give you a higher-level contract: one side produces a value once, the other side waits for it exactly when needed. This post walks through the mental model, standard library types, and a couple of realistic patterns so you can apply them with confidence.
Ready to coordinate multiple futures? Continue with Composing Futures in Modern C++. Want to understand how move semantics power these APIs? Read Understanding Reference Types in Modern C++.
Why Futures and Promises Exist
Threading APIs traditionally expose two sharp edges:
- Synchronization: you need explicit locks, condition variables, or atomics to coordinate shared data.
- Lifetime: it is on you to ensure a thread finishes before objects it touches disappear.
Futures and promises sidestep both issues by separating execution from consumption. The producer promises to deliver a value (or an exception). The consumer owns a future that blocks only when it calls get()
or checks readiness. Because the future owns the lifetime handshake, you gain a disciplined way to move results, including errors, across threads.
Anatomy of a Future/Promise Pair
The bare essentials look like this:
#include <future>
#include <thread>
int heavy_calculation();
int main() {
std::promise<int> value_promise;
std::future<int> value_future = value_promise.get_future();
std::thread worker([p = std::move(value_promise)]() mutable {
try {
int result = heavy_calculation();
p.set_value(result);
} catch (...) {
p.set_exception(std::current_exception());
}
});
int result = value_future.get(); // blocks until the worker sets a value or exception
worker.join();
return result;
}
Key takeaways:
std::promise<T>
lives with the producer. Callset_value
,set_exception
, orset_value_at_thread_exit
exactly once.std::future<T>
lives with the consumer and becomes ready when the producer fulfills the promise.- Moves matter. Promises are move-only; capture them by value in lambdas via
std::move
. - Exceptions cross threads. If the worker calls
set_exception
, the consumer sees that exception when it callsget()
.
Delivering Results with std::async
std::async
is the quickest way to obtain a future without manually wiring a promise:
#include <future>
#include <numeric>
#include <vector>
int main() {
std::vector<int> data = {/* ... */};
auto sum_future = std::async(std::launch::async, [data] {
return std::accumulate(data.begin(), data.end(), 0);
});
// do other work ...
int sum = sum_future.get();
}
Important flags:
std::launch::async
requests a new thread.std::launch::deferred
defers execution untilget
/wait
.- The default policy may choose either; specify the launch policy when determinism matters.
Because std::async
already wraps the callable in a packaged task backed by a promise/future pair, you get the same exception propagation rules for free.
Sharing Results Safely
Only one consumer can call get()
on a std::future
. If multiple readers need the result, convert it to a std::shared_future
:
std::future<std::string> title_future = std::async(std::launch::async, fetch_title);
std::shared_future<std::string> shared = title_future.share();
#if defined(__cpp_lib_futures) && __cpp_lib_futures >= 202306L
auto render_ui = shared.then([](auto f) { draw(f.get()); }); // C++23 .then extension
auto log_ui = shared.then([](auto f) { log(f.get()); });
#else
std::thread renderer([copy = shared]() { draw(copy.get()); });
std::thread logger([copy = shared]() { log(copy.get()); });
renderer.join();
logger.join();
#endif
In C++11/14/17, the standard library lacks future::then
, but you can still copy shared
into multiple threads and call get()
from each. The point remains: std::shared_future
lets many consumers observe one promised result without data races.
Error Propagation and Timeouts
Futures make it straightforward to surface failures and respond to delays:
auto future = std::async(std::launch::async, perform_rpc);
if (future.wait_for(std::chrono::milliseconds(200)) == std::future_status::ready) {
handle(future.get());
} else {
cancel_rpc(); // optional cleanup hook
throw std::runtime_error("RPC timed out");
}
- The producer can call
set_exception
directly or simply throw; the exception reappears atget()
. wait_for
/wait_until
let you enforce deadlines without burning CPU on busy loops.
Building Higher-Level Pipelines
Real applications combine several asynchronous steps. One portable approach is to chain futures manually with helper functions:
template <typename T, typename Func>
auto then(std::future<T> f, Func cont) {
return std::async(std::launch::async, [f = std::move(f), cont = std::move(cont)]() mutable {
return cont(f.get());
});
}
std::future<std::string> download_and_parse(std::string url) {
auto raw_future = std::async(std::launch::async, [url] { return http_get(url); });
return then(std::move(raw_future), [](auto response) {
return parse_document(response.body);
});
}
While C++20 and C++23 start introducing executors and .then()
for std::shared_future
, rolling small helpers like this keeps intent clear today. Libraries such as Folly, Boost, or HPX offer richer continuations, but the standard tools remain the lowest common denominator.
Practical Guidance
- Prefer futures over naked thread handles when you only need a result.
- Always join or detach threads spawned manually; futures from
std::async
manage it automatically. - Capture promises carefully. Using
std::move
in the lambda capture avoids dangling references. - Avoid blocking in UI or latency-sensitive threads;
wait_for
can help implement responsive timeouts. - Beware of implicit copies. Moving a future invalidates the source; share explicitly when you need fan-out.
Wrapping Up
Futures and promises bring structure to cross-thread communication: one writer, one (or more via shared_future
) reader, and automatic error transport. Once you internalize the contract, you can mix std::async
, packaged tasks, and custom continuations to express concurrency in a way that is easier to test and reason about than raw threads. As executors mature in future standards, these primitives stay foundational, and mastering them now will prepare you for the next wave of C++ concurrency. For an in-depth look at launch policies and best practices, see Mastering std::async in Modern C++.