Skip to content

std::async, std::future & std::promise

Use higher-level concurrency abstractions: launch asynchronous tasks with std::async, retrieve results with std::future, and manually set values or exceptions with std::promise.

Task-Based Concurrency

While std::thread gives you low-level control, task-based concurrency with std::async and std::future is often easier and less error-prone. Instead of manually managing threads and shared state, you express work as tasks that produce results. The runtime decides how to schedule them.

A std::future represents a value of type T that will be available in the future. You can call get() to block until the result is ready. If the task threw an exception, get() rethrows it — giving you automatic exception propagation across threads.

std::async — Launching Asynchronous Tasks

std::async launches a callable and returns a std::future holding the result. You can specify a launch policy: std::launch::async forces a new thread, while std::launch::deferred delays execution until get() is called.

async_parallel_sum.cpp
#include <iostream>
#include <future>
#include <chrono>
#include <numeric>
#include <vector>

long long parallel_sum(const std::vector<int>& v, size_t start, size_t end) {
    if (end - start < 1000) {
        return std::accumulate(v.begin() + start, v.begin() + end, 0LL);
    }
    size_t mid = start + (end - start) / 2;

    // Launch left half asynchronously
    auto left = std::async(std::launch::async,
                           parallel_sum, std::cref(v), start, mid);

    // Compute right half in this thread
    long long right_sum = parallel_sum(v, mid, end);

    return left.get() + right_sum;  // .get() blocks until left is done
}

int main() {
    std::vector<int> data(1'000'000, 1);

    auto start = std::chrono::high_resolution_clock::now();
    long long result = parallel_sum(data, 0, data.size());
    auto end = std::chrono::high_resolution_clock::now();

    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    std::cout << "Sum: " << result << " in " << ms.count() << "ms\n";
    return 0;
}

Launch Policies: async vs deferred

std::launch::async — The function is executed on a new thread (or one from a thread pool, implementation-defined). Work begins immediately.

std::launch::deferred — The function is not executed until get() or wait() is called on the future. It runs in the calling thread (lazy evaluation). Useful for computations you may not need.

Default (no policy specified) — The implementation chooses. This can be async, deferred, or both (meaning it decides at runtime). If you need guaranteed parallelism, always specify std::launch::async.

std::promise — Manual Result Setting

std::promise is the write end of a future-promise channel. You create a promise, extract its future, and then either set_value() or set_exception() from any thread.

promise_demo.cpp
#include <iostream>
#include <thread>
#include <future>
#include <stdexcept>

void compute(std::promise<int> prom, int x) {
    try {
        if (x < 0) {
            throw std::invalid_argument("negative input");
        }
        int result = x * x;
        prom.set_value(result);  // fulfil the promise
    } catch (...) {
        prom.set_exception(std::current_exception());  // propagate error
    }
}

int main() {
    std::promise<int> prom;
    std::future<int> fut = prom.get_future();  // get the read end

    std::thread t(compute, std::move(prom), 7);

    try {
        int result = fut.get();  // blocks until value or exception
        std::cout << "Result: " << result << "\n";  // prints 49
    } catch (const std::exception& e) {
        std::cout << "Error: " << e.what() << "\n";
    }

    t.join();
    return 0;
}

std::shared_future — Multiple Consumers

A std::future can only be get()-ed once (it moves the value out). std::shared_future allows multiple threads to wait for and read the same result.

shared_future.cpp
#include <iostream>
#include <future>
#include <thread>
#include <vector>

int main() {
    auto shared = std::async(std::launch::async, []() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        return 42;
    }).share();  // convert future to shared_future

    // Multiple threads can all read the same result
    std::vector<std::thread> consumers;
    for (int i = 0; i < 5; ++i) {
        consumers.emplace_back([shared, i]() {
            int val = shared.get();  // all 5 threads get 42
            std::cout << "Consumer " << i << " got: " << val << "\n";
        });
    }

    for (auto& t : consumers) t.join();
    return 0;
}
Pitfall

A std::future returned by std::async has a special property: its destructor blocks until the task completes (like an implicit join). This means if you discard the returned future, the "async" call becomes effectively synchronous:

```cpp
// SURPRISE — this blocks! The temporary future is destroyed
// at the semicolon, which waits for the task to finish.
std::async(std::launch::async, long_computation);

// CORRECT — store the future to let work proceed in parallel
auto fut = std::async(std::launch::async, long_computation);
// ... do other work ...
fut.get(); // now wait for the result
```

This is the most common pitfall with std::async. Always capture the returned std::future.

std::packaged_task

std::packaged_task wraps a callable and provides a std::future for its result. Unlike std::async, it does not automatically run the task — you invoke it manually (typically by moving it into a thread or a task queue). This makes it ideal for building custom thread pools:

1. Wrap work in a std::packaged_task.
2. Extract the std::future and give it to the caller.
3. Move the task into a work queue.
4. Worker threads pull tasks from the queue and invoke them.

Best Practice

Use std::async when you want a result from concurrent work and don't need fine-grained thread control. It automatically propagates exceptions and manages thread lifetimes. Use std::thread (or std::jthread) when you need persistent background workers, custom scheduling, or when you're building infrastructure like thread pools. For fire-and-forget work, always keep the returned std::future alive to avoid accidental blocking.

Key Takeaways
  • std::async launches a task and returns a std::future — a high-level alternative to manual thread management
  • std::launch::async forces a new thread; std::launch::deferred delays until get() is called
  • std::promise is the write end of a future channel — use it for manual value/exception setting
  • std::shared_future lets multiple threads read the same result
  • The std::future from std::async blocks in its destructor — always store it in a variable
  • std::packaged_task wraps a callable with a future, ideal for custom thread pools

Quiz — Test Your Knowledge

(15 XP)

1. What happens if you discard the `std::future` returned by `std::async(std::launch::async, fn)`?

2. What is the difference between `std::future` and `std::shared_future`?

3. How does `std::async` propagate exceptions from the task to the caller?