Handling results with future, promise, and async
How do you get back the one result a thread computed? future/promise, which strips away the mutex+cv plumbing, std::async, packaged_task, and exception propagation across thread boundaries.
The previous parts started threads directly (part 1), protected sharing with locks (part 2), and signalled between threads with condition variables (part 3). Those tools are powerful but laborious. Even when all you want is one result back from a thread, you have to wire up a mutex, a condition variable, and a shared variable by hand. This part covers the higher-level tools that strip away that repetitive work: future, promise, and async.
The problem: how do you get the result back?
Running a function with std::thread gives you no way to receive its return value; the thread constructor discards it. To get a result you have to lay the plumbing yourself — a shared variable, a mutex protecting it, and a condition variable signalling “the computation is done”.
// all of this, just to receive one result...
int result;
bool done = false;
std::mutex mtx;
std::condition_variable cv;
std::thread t([&]{
int r = heavyComputation();
{
std::lock_guard<std::mutex> lock(mtx);
result = r;
done = true;
}
cv.notify_one();
});
int value;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return done; });
value = result;
}
t.join();
That much code to receive one result. Computing once and handing the result over once is such a common pattern that the standard abstracted it into dedicated tools.
future and promise: a channel for a result
std::future<T> represents “one future value that may not be ready yet”. std::promise<T> is the side that fills it in. They come as a pair: put a value into the promise and you can take it out of the connected future.
#include <future>
std::promise<int> prom;
std::future<int> fut = prom.get_future(); // the future tied to this promise
// producing thread
std::thread t([&prom]{
int r = heavyComputation();
prom.set_value(r); // fill in the result
});
int value = fut.get(); // wait until ready, then receive the result
t.join();
The whole mutex+cv plumbing above has vanished. fut.get() takes care of “wait until the result is ready, then take the value”. Internally there is still waiting and signalling, but we no longer have to write that plumbing.
get() has one rule: it can be called only once per future. Taking the value empties the future, and calling get() again is undefined behaviour. If you need to read the result several times, use std::shared_future.
Exceptions travel too
One big advantage of future/promise is exception propagation. If the producing side throws, you can load the exception into the promise and pass it to the consuming side, where get() rethrows it.
std::promise<int> prom;
std::future<int> fut = prom.get_future();
std::thread t([&prom]{
try {
int r = riskyComputation(); // may throw
prom.set_value(r);
} catch (...) {
prom.set_exception(std::current_exception()); // hand the exception over
}
});
try {
int value = fut.get(); // the exception raised over there is rethrown here
} catch (const std::exception& e) {
std::cerr << "computation failed: " << e.what() << "\n";
}
t.join();
Writing this with mutex+cv by hand means handling the transfer of an exception across a thread boundary yourself. future does it for free. An exception raised in a thread is caught by the consumer’s try/catch as if it had been raised in the same thread.
std::async: thread management included
If even handling a promise yourself is tedious, there is std::async. Hand it a function and it runs that function (in a new thread, depending) and immediately returns a future holding the result. It creates the thread and wires up the promise for you.
#include <future>
std::future<int> fut = std::async(std::launch::async, []{
return heavyComputation(); // just return a value
});
// ... do other work meanwhile ...
int value = fut.get(); // wait until ready, then receive
This is the most concise form. You do not create a thread, join it, or set a value on a promise. The function simply returns a value, and simply throws on error. Both reach the consumer through the future.
Launch policies: async and deferred
The first argument to std::async is the launch policy. There are two, and not knowing the difference lands you in a trap.
std::launch::async: runs immediately on a new thread. This is usually what we want.
std::launch::deferred: creates no new thread. It runs lazily at the exact moment get() is called, on the calling thread. In other words, the function does not run at all until you call get().
Omitting the policy gives std::launch::async | std::launch::deferred, which means the implementation decides which of the two it will be. It may run on a new thread, or it may be deferred. This is where the trap of “I intended parallel execution but the implementation picked deferred and it effectively ran sequentially” comes from. If you want parallelism, state std::launch::async explicitly.
The async trap: destroying the future blocks
If you discard the future returned by std::async(std::launch::async, ...) instead of storing it in a variable, something subtle happens. That future’s destructor blocks until the task finishes.
std::async(std::launch::async, longTask); // the temporary future is destroyed at once
std::async(std::launch::async, anotherTask);
// effectively sequential: the first line's future waits for longTask on destruction
You meant to run two tasks in parallel, but the temporary future created by the first line is destroyed at the end of that line and waits for longTask to finish. The result is sequential execution. To get parallelism you have to extend the lifetimes by storing each returned future in a variable.
auto f1 = std::async(std::launch::async, longTask);
auto f2 = std::async(std::launch::async, anotherTask);
// now the two run in parallel
This blocking-destructor behaviour is a special rule that applies only to futures created by std::async. A future obtained from a promise does not block on destruction.
packaged_task: deferring execution
std::packaged_task bundles “a callable plus a future to hold its result” into one object. Unlike async, which starts running right away, packaged_task lets me decide when and where it runs. You can build the task, put it on a queue, and pull it off to run on whichever thread you like.
#include <future>
std::packaged_task<int()> task([]{
return heavyComputation();
});
std::future<int> fut = task.get_future(); // the channel for the result
std::thread t(std::move(task)); // run it on whichever thread you want
// when task() runs, its return value flows into fut
int value = fut.get();
t.join();
That property makes packaged_task a good fit for a thread pool’s work queue. Wrap work in a packaged_task, put it on the queue, let a worker thread pull it off and run it, and the submitting side receives the result through the corresponding future. Where part three’s work queue was a primitive queue of values, a packaged_task queue extends naturally into a work queue that hands results back.
What to use when
The three tools sit at different levels of abstraction.
std::async: the highest level. “Run this function asynchronously and give me the result.” No need to think about threads or promises. The default choice for a one-off asynchronous computation.
std::packaged_task: the middle level. You control when and where it runs, while the result-delivery plumbing (the future connection) is automatic. Suited to thread pools and work queues.
std::promise / std::future: the lowest level. For when the result does not fall naturally out of a function’s return value and arbitrary code has to fill it in at an arbitrary moment. The most flexible and the most laborious.
What they share is that all three carry one result safely across a thread boundary (exceptions included) without your writing a mutex and a condition variable by hand.
Summary
- Motivation: handle the common pattern of getting one computed result back from a thread, without mutex+cv plumbing.
- future/promise: the future is the future value, the promise is the side that fills it.
get()waits until ready and takes the value (or the exception).get()is once per future (useshared_futurefor more). - Exception propagation: an exception on the producing side is rethrown by the consumer’s
get(). Cross-thread exception handling for free. - std::async: thread creation through result delivery in one call. State
std::launch::asyncif you want parallelism. Beware the trap where discarding the returned future makes the destructor block and execution serialise. - packaged_task: a unit of work whose time and place of execution you decide. Suited to a thread pool’s work queue.
- Choosing:
asyncfor a one-off computation,packaged_taskfor a work queue,promisefor injecting a value at an arbitrary moment.
That covers the high-level result delivery the standard provides. The next part turns the other way, descending into the low-level world of sharing without even using locks: atomic and the memory model.