C++'s std::async Discarded Future Trap: The Parallel Tasks That Run One at a Time

2026-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)
}

The Bug

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:

  1. Launches slow_task(i) on a new thread.
  2. Immediately reaches the end of the statement.
  3. Destroys the temporary future, which blocks for 2 seconds waiting for that thread.
  4. Only then proceeds to iteration i+1.

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.

The Fix

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:

Key Takeaway: A future returned by 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.

All newsletters