Mastering std::async in Modern C++
std::async
is the standard library’s shortest path to running work asynchronously and receiving the result as a std::future
. Used well, it hides thread management, propagates exceptions, and helps structure CPU-bound pipelines. Misunderstood, it quietly serializes work or launches threads you never join. Let’s walk through how to make the most of it.
You may want to review Understanding Futures and Promises in Modern C++ first, then follow up with Composing Futures in Modern C++. For a deep dive into the move semantics that make futures efficient, see Understanding Reference Types in Modern C++.
What std::async Actually Does
#include <future>
std::future<int> future = std::async(std::launch::async, [] {
return heavy_computation();
});
// later
int answer = future.get();
std::async
wraps the callable in a packaged task backed by a promise/future pair. When you call get()
, you either receive the return value or the exception the callable threw. No explicit threads, no manual promise, just the result.
Launch Policies Matter
The first template argument controls when and where work runs:
std::launch::async
: start immediately on a new thread.std::launch::deferred
: delay execution until the first.get()
or.wait()
. Runs synchronously in that call.- Default (no policy): implementation may pick either. You must not depend on one behavior.
auto maybe_async = std::async([] { return compute(); });
auto definitely_async = std::async(std::launch::async, [] { return compute(); });
auto deferred = std::async(std::launch::deferred, [] { return compute(); });
Use std::launch::async
explicitly when latency overlaps matter. Reserve deferred
for expensive work that might not be needed.
Lifetime and Blocking Rules
- The destructor of the returned
std::future
blocks if the task was launched withstd::launch::async
and you haven’t calledget()
orwait()
. Always consume the future before it goes out of scope. - If the task was
std::launch::deferred
, the destructor does nothing—execution happens duringget()
instead. - Copying is illegal. Move the future if you need to transfer ownership.
std::future<void> fire_and_wait() {
auto fut = std::async(std::launch::async, [] { work(); });
// do other stuff
fut.wait(); // ensures task finished before returning
return fut; // UB: returning moved-from future. Instead, return after join
}
Avoid returning by value unless you’re happy with move-semantics and the caller takes ownership.
Exception Propagation
If the callable throws, the exception is stored and rethrown by future.get()
:
auto fut = std::async(std::launch::async, []() -> int {
throw std::runtime_error("bad");
});
try {
fut.get();
} catch (const std::runtime_error& e) {
handle_error(e);
}
Because std::async
already manages the promise, you don’t need explicit try/catch in the lambda unless you want to translate errors.
Example: Parallel Accumulate
template <typename It>
int parallel_sum(It begin, It end) {
auto length = std::distance(begin, end);
if (length < 1'000) {
return std::accumulate(begin, end, 0);
}
It mid = begin;
std::advance(mid, length / 2);
auto lower = std::async(std::launch::async, parallel_sum<It>, begin, mid);
int upper = parallel_sum(mid, end);
return lower.get() + upper;
}
- One branch recurses asynchronously.
- The other runs inline.
- The recursive
get()
ensures all child tasks complete before returning.
This pattern keeps the task tree bounded and avoids exhausting the thread implementation.
Example: Overlapping CPU and I/O
auto cpu_future = std::async(std::launch::async, run_simulation);
auto io_future = std::async(std::launch::async, read_disk_snapshot);
simulate_ui();
auto state = io_future.get(); // wait for I/O
auto result = cpu_future.get(); // wait for simulation
Launching both tasks with std::launch::async
overlaps CPU work with disk or networking. Be sure to call get()
in all paths to avoid leaving background threads alive in destructors.
Handling Timeouts
auto fut = std::async(std::launch::async, run_rpc);
if (fut.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout) {
cancel_rpc();
// Future still needs to be consumed to avoid blocking destructor
try {
fut.get(); // likely throws or blocks until completion
} catch (...) {
// handle cancellation result
}
}
You cannot truly cancel the underlying task with standard std::async
, but you can structure your code so the worker checks a shared atomic flag.
Composition Strategies
std::async
returns std::future
. To combine results:
- For simple chains, call
.get()
and feed results to the nextstd::async
. - For fan-out, hand the futures to
std::when_all
(C++20) or helpers from the compound futures article. - When you need to schedule follow-up work, wrap the call in a helper that launches another
std::async
after.get()
completes.
Common Pitfalls
- Ignoring the return future: letting it go out of scope blocks (async) or leaves work undone (deferred).
- Mixing policies accidentally: implementations may choose
deferred
by default. Specify the policy explicitly. - Long-running CPU loops: saturating
std::async
with hot loops may spawn more threads than your system can handle. Consider using a thread pool or task scheduler. - Shared state:
std::async
doesn’t eliminate data races. Protect shared data with synchronization or confine ownership to the task scope.
Wrapping Up
std::async
shines when you want convenient, exception-safe task launching without writing a custom thread wrapper. Couple it with strong future handling practices—from basic promise/future contracts to the compound patterns we explored—and you get clean, maintainable concurrency for both small utilities and production code. Grow from here by experimenting with executors (std::jthread
, C++23 continuations) and dedicated task frameworks when you need more control.