std::async Discarded Future Trap: The Parallel Tasks That Run One at a Time2026-07-24
This function is supposed to fan out three slow tasks in parallel, then report the total elapsed time. Each task sleeps for two seconds, so the whole thing should finish in about two seconds. It doesn't — it takes six.
#include <future>
#include <chrono>
#include <iostream>
#include <thread>
void slow_task(int id) {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Task " << id << " done\n";
}
int main() {
auto start = std::chrono::steady_clock::now();
// Launch 3 tasks "in parallel"
for (int i = 0; i < 3; ++i) {
std::async(std::launch::async, slow_task, i);
}
auto elapsed = std::chrono::steady_clock::now() - start;
std::cout << "Elapsed: "
<< std::chrono::duration_cast<std::chrono::seconds>(elapsed).count()
<< "s\n";
// Prints: Elapsed: 6s (expected: 2s)
}
std::async returns a std::future. In the loop, that future is a temporary — nobody stores it, so it's destroyed at the end of the full expression (the semicolon on the same line).
Here's the gotcha buried in the standard: the destructor of a future returned by std::async with std::launch::async blocks until the associated task finishes. This is the only case in the whole language where a future's destructor waits. It exists specifically to prevent silent detachment of async work, but it turns "fire and forget" into "fire and stand at attention."
So each iteration:
slow_task(i) on a new thread.Three tasks × two seconds = six seconds, executed strictly serially despite the std::launch::async policy. The parallelism is real — the threads genuinely run concurrently with the main thread — but the main thread refuses to move on.
Worse, this trap is invisible in review. The code looks like a textbook fan-out. There's no warning, no diagnostic, and unit tests with fast mock tasks won't reveal the serialization at all. It only shows up as mysterious latency in production.
Keep the futures alive until after all tasks are launched. Store them in a container; the destructors run together when the container goes out of scope, so the waits overlap:
#include <vector>
int main() {
auto start = std::chrono::steady_clock::now();
std::vector<std::future<void>> futures;
for (int i = 0; i < 3; ++i) {
futures.push_back(std::async(std::launch::async, slow_task, i));
}
// Futures destroyed here, all at once — waits overlap.
auto elapsed = std::chrono::steady_clock::now() - start;
std::cout << "Elapsed: "
<< std::chrono::duration_cast<std::chrono::seconds>(elapsed).count()
<< "s\n"; // Prints: Elapsed: 2s
}
A few adjacent hazards worth knowing:
std::launch::async, the default policy is async | deferred, meaning the implementation may run your "background" task on the calling thread when you call .get() — another way parallelism silently disappears.std::async. Use std::thread with .detach(), or a proper thread pool. std::async's blocking destructor is a feature; you're fighting it.std::async. Futures obtained from std::promise or std::packaged_task have ordinary non-blocking destructors — a subtle inconsistency that catches people twice.std::async blocks in its destructor, so discarding it at the end of a statement serializes your "parallel" work — always bind the futures to names that outlive the launch loop.
