Why Discarded std::async Futures Can Serialize Work

黎 浩然/ 24 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

Why do two calls with std::launch::async appear to run one after the other? Check whether their returned futures are retained. A discarded temporary is destroyed at the end of its statement. Releasing the final reference to an unfinished state created for an asynchronous task can become a waiting point, before the next launch statement is reached.

Contents
  1. A semicolon can be a waiting point
  2. Follow the shared state, not just the variable
  3. Retain handles to make overlap possible
  4. Default and deferred policies change the explanation
  5. Waiting, retrieving, and cleanup have different jobs
  6. Put ownership at a visible boundary

The useful debugging order is launch policy, handle lifetime, the interval in which work can overlap, and finally performance. The C++17 example below separates these questions without sleeps or elapsed-time thresholds.

A semicolon can be a waiting point

Imagine two independent tasks, A and B, launched in consecutive statements that discard the returned future. There is no explicit get, wait, or join in the calling code. That does not mean control can immediately proceed: cleanup is part of the control flow too.

The first statement launches A and then destroys its temporary return object. Only after that full expression finishes can the second statement launch B. If cleanup waits for A, the launch of B follows the completion of A. A can genuinely execute on another thread while the pair still has no opportunity to overlap.

A fast task may finish before its handle is destroyed, hiding any visible pause. A short timing result therefore does not disprove a lifetime problem. Start by identifying ordering constraints instead of inferring scheduling from closely spaced log timestamps.

Launch A → destroy temporary → pass semicolon after A completes
Then launch B → destroy temporary → B completes
Figure 1. Cleanup of the first discarded return object can delay the second launch. Arrows show ordering, not measured durations.

Follow the shared state, not just the variable

A future is a result handle associated with shared state. To reason about its release, identify who created that state, whether it is ready, and whether the release drops its final reference. The name or type of a local variable alone does not answer these questions.

The C++17 draft [futures.state] specifies an exception to nonblocking release for an unfinished state created by async when its last reference is released. [futures.async] provides the additional synchronization rules for the async launch policy. This is not a universal rule that every future destructor joins a task. A future supplied by a promise cannot be assigned the same behavior merely because it has the same type.

Moving a future outward changes its owner. If the receiving scope ends immediately afterward, the waiting point may simply move from a semicolon to that scope boundary. If shared_future copies exist, follow ownership to the last holder. Sketching this ownership chain is often more informative than searching only for get calls.

Retain handles to make overlap possible

The example uses a shared gate to keep two tasks from returning. Each task signals that it has reached the gate. The main thread waits for both notifications before opening it. Reaching that point establishes that both tasks started without cleanup of the first launch preventing the second launch.

This establishes overlapping task lifetimes, not simultaneous execution of machine instructions on two cores. Threads can alternate on one core. We do not measure throughput, latency, or thread creation overhead here. A synchronization protocol answers an ordering question; a performance claim requires a workload and measurements designed for that claim.

Main: retain a and b → receive both started signals → open gate
Workers: signal started → wait at gate → return results
Figure 2. The main thread releases both tasks after receiving their start notifications. This tests overlapping lifetimes rather than performance.

The complete program checks discarded handles, retained tasks, deferred execution, and exception retrieval. Save it as async_future.cpp, compile with clang++ -std=c++17 -pthread async_future.cpp -o async_future, and run the resulting executable. Keep assertions enabled: do not compile with -DNDEBUG. It was compiled and run locally as C++17; passing these checks is not a cross-platform performance benchmark.

#include <atomic>
#include <cassert>
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
#include <thread>
using namespace std::chrono_literals;
int main() {
  std::atomic<int> done{0};
  // A discarded async future is destroyed at the semicolon.
  (void)std::async(std::launch::async, [&] { ++done; });
  assert(done == 1);
  (void)std::async(std::launch::async, [&] { assert(done == 1); ++done; });
  assert(done == 2);

  std::promise<void> release, started_a, started_b;
  auto gate = release.get_future().share();
  auto a = std::async(std::launch::async, [&] {
    started_a.set_value(); gate.wait(); return 10;
  });
  auto b = std::async(std::launch::async, [&] {
    started_b.set_value(); gate.wait(); return 20;
  });
  started_a.get_future().get();
  started_b.get_future().get();
  assert(a.wait_for(0ms) == std::future_status::timeout);
  assert(b.wait_for(0ms) == std::future_status::timeout);
  release.set_value();
  assert(a.get() + b.get() == 30);
  assert(!a.valid() && !b.valid());

  int deferred_calls = 0;
  {
    auto unused = std::async(std::launch::deferred, [&] { ++deferred_calls; });
    assert(unused.wait_for(0ms) == std::future_status::deferred);
  }
  assert(deferred_calls == 0);
  const auto caller = std::this_thread::get_id();
  auto deferred = std::async(std::launch::deferred, [&] {
    ++deferred_calls; return std::this_thread::get_id();
  });
  assert(deferred.get() == caller);
  assert(deferred_calls == 1);

  auto failure = std::async(std::launch::async, []() -> int {
    throw std::runtime_error("teaching failure");
  });
  failure.wait();
  assert(failure.valid());
  bool caught = false;
  try { (void)failure.get(); }
  catch (const std::runtime_error&) { caught = true; }
  assert(caught && !failure.valid());
  std::cout << "async lifetime, retained overlap, deferred, exceptions: passed\n";
}

The first section checks done after each full expression, observing completion order when handles are discarded. The second section waits for explicit notifications rather than assuming a task started after a convenient delay. The zero-duration waits report timeout because the closed gate prevents the tasks from returning, not because the machine happened to be slow.

Do not modify the gate demonstration to discard A’s future and open the gate later on the main thread. Cleanup may wait for A while A waits for the main thread to open the gate. Retaining both handles until after release avoids that dependency cycle. When a reproduction hangs, draw the wait dependencies before increasing timeouts.

Default and deferred policies change the explanation

Omitting the launch policy does not guarantee a new thread. The default permits async or deferred execution. With deferred execution, a non-timed wait can run the function on the waiting thread. The asynchronous lifetime experiment therefore selects async explicitly, keeping policy selection separate from handle lifetime.

The deferred section follows a different path. It checks the status and destroys an unused handle; the call count remains zero. A second deferred task is invoked by get and returns the caller’s thread ID. Discarding a return object therefore does not universally mean the task will execute. The default overload is not a dependable interface for background work that must start independently.

Record the policy in a reproduction. If deferral is acceptable, retain that choice and account for its behavior. If asynchronous launch is required, select it explicitly and handle failure to obtain resources. An explicit policy defines the launch contract; it does not bound task counts, allocate a service-wide concurrency budget, or solve overload.

Waiting, retrieving, and cleanup have different jobs

The final section stores a worker failure in shared state. wait establishes readiness while leaving the future valid. get retrieves the result or rethrows the stored exception and invalidates this ordinary future. This example tests a worker exception, not injected thread-creation failure; the gate demonstration is not a production task manager. Explicitly handling the worker exception prevents task completion from being mistaken for task success.

Letting a return object eventually disappear is not an application error-reporting strategy. If a failure must be logged, retried, or returned to a caller, retrieve the result at a clear ownership boundary. When collecting several results, also decide who retains the other handles if one get throws and how the remaining work will finish. A short example should not conceal that production control-flow obligation.

Inspect early-return and exception paths in request handlers. Even if the normal path calls get explicitly, another exit may move the wait into local cleanup. A request timeout alone does not specify how its worker ends. For a related lifecycle question in a different language, see async timeouts and background threads; the API-specific rules must still be kept separate.

Put ownership at a visible boundary

For a finite group of independent tasks, retaining the futures before collecting their results provides a minimal structure for overlap. That is what the example demonstrates. For dependent tasks, identify the dependency first; mechanically moving every get to the end can be incorrect.

If a function must return promptly, a longer-lived owner must take responsibility for the work and its results. Throwing away the handle does not create that ownership arrangement. Larger or long-running workloads also need decisions about queues, concurrency limits, shutdown, and failure reporting. This article implements neither a thread pool nor a cancellation protocol.

The concrete debugging question is: at this line, who owns the result and who ensures the work finishes? Answering it lets you distinguish an intentional resource-management boundary from an unexpected delay imposed on the caller.

阅读完整中文版. The article retains its planned date of October 24, 2026, 09:00 Beijing time and was actually backfilled on October 29, 2026. The cited C++17 draft predates the planned slot.

References: C++17 draft N4659, async and shared state.

If this reproducible way of locating a hidden wait saved you debugging time, you are welcome to support future technical writing voluntarily.

Support

If the explanations or examples helped you solve a problem or save time, you’re welcome to support this site with a voluntary tip.

WeChat support QR code; click to enlarge
WeChat
Alipay support QR code; click to enlarge
Alipay
Buy Me a Coffee; support this site
Buy Me a Coffee

Click a QR code to enlarge. More options: support page。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*