Asynchronous programming in C++

Table of Contents

std::future

A std::future provides a mechanism to retrieve the result of an asynchronous operation. It is typically obtained from std::async, std::packaged_task, or std::promise. In all cases, an asynchronous producer sets a value (or an exception), while the consumer retrieves it through the future.

There are several important characteristics to understand. First,std::future is move-only, meaning it cannot be copied — ownership of the shared state can only be transferred. Second,get() may only be called once. After calling get(), the future becomes invalid. Third, calling get() blocks the calling thread until the result is ready. Finally, if the asynchronous task throws an exception, that exception is stored internally and rethrown when get() is invoked.

Below is a simple example using std::async:

1#include <iostream>
2#include <future>
3#include <thread>
4#include <chrono>
5
6int find_the_answer() {
7    std::this_thread::sleep_for(std::chrono::seconds(2)); 
8    return 42;
9}
10
11int main() {
12    std::future<int> future_result =
13        std::async(std::launch::async, find_the_answer);
14
15    std::cout << "Main thread is doing other work...\n";
16
17    int result = future_result.get(); 
18
19    std::cout << "The answer is: " << result << std::endl;
20    return 0;
21}

In this example,std::launch::async ensures that find_the_answer runs on a separate thread immediately. The main thread continues execution independently until get() is called, at which point it blocks until the computation completes.

If multiple consumers need access to the same result,std::shared_future can be used instead. Unlike std::future, a shared future allows multiple calls to get().

std::async

The std::async function is used for executing a callable asynchronously while automatically managing result retrieval through a std::future. It abstracts away explicit thread management.

When you call std::async, it returns a std::future object associated with a shared state. The callable runs according to a launch policy, and its return value (or any thrown exception) is stored in that shared state. The result can later be retrieved by calling get() on the future.

The behavior of std::async depends on its launch policy. If std::launch::async is specified, the function is executed on a new thread immediately. If std::launch::deferred is specified, the function is not executed right away — instead, it runs synchronously when get() or wait() is called on the associated future.

If no launch policy is explicitly provided, the implementation is free to choose between asynchronous and deferred execution. This means behavior may vary between standard library implementations, which is an important consideration for performance-sensitive or low-latency systems.

std::promise

The std::promise class template provides a mechanism for explicitly setting a value or an exception into a shared state, which can later be retrieved through an associated std::future. Unlike std::async, it does not create or manage threads automatically — you are responsible for managing the execution context.

A typical workflow begins by constructing a std::promise and obtaining its corresponding std::future via get_future(). The promise is then moved into a worker thread (often created with std::thread). Inside that thread, the producer explicitly fulfills the promise by calling set_value() or propagates failure using set_exception().

On the consumer side, the associated std::future retrieves the result using get(), blocking until the shared state becomes ready. If an exception was stored, it is rethrown at this point.

This approach is particularly useful when the computation cannot be neatly expressed as a single callable passed to std::async, or when more explicit control over the producer-consumer relationship is required.

1#include <iostream>
2#include <future>
3#include <thread>
4#include <chrono>
5
6void fulfill_promise(std::promise<int>&& promise) {
7    std::this_thread::sleep_for(std::chrono::seconds(2));
8    try {
9        int result = 42;
10        promise.set_value(result);
11    } catch (...) {
12        promise.set_exception(std::current_exception());
13    }
14}
15
16void use_promise() {
17    std::promise<int> promise;
18
19    // Obtain the associated future before moving the promise
20    std::future<int> future = promise.get_future();
21
22    // Move the promise into the worker thread
23    std::thread t(fulfill_promise, std::move(promise));
24
25    // Blocks until value or exception is set
26    int result = future.get();
27    t.join();
28}

One important detail is that a std::promise must set either a value or an exception before it is destroyed. If it is destroyed without fulfilling the shared state, the associated std::future will throw a std::future_error indicating a broken promise.

std::packaged_task

The std::packaged_task class template wraps any callable target (such as a function, lambda, or function object) so that its return value or any exception it throws can be retrieved asynchronously through an associated std::future.

Conceptually, a std::packaged_task creates and manages the shared state between a producer and a consumer. When the task is executed, its result (or exception) is stored in that shared state. The consumer retrieves it through the std::future obtained by calling get_future().

Unlike std::async, execution is not automatic. The task must be explicitly invoked either by calling its operator() or by moving it into a std::thread. This gives you full control over when and where the callable runs.

A std::packaged_task is move-only, meaning it cannot be copied. This makes it suitable for transferring work between components or threads. In many designs, it acts as a bridge between a task queue and a future-based result retrieval system, making it particularly useful in thread pool implementations.

1#include <iostream>
2#include <future>
3#include <thread>
4#include <chrono>
5
6int calculate_sum(int start, int end) {
7    int sum = 0;
8    for (int i = start; i <= end; ++i) {
9        sum += i;
10    }
11    std::this_thread::sleep_for(std::chrono::milliseconds(500));
12    return sum;
13}
14
15int main() {
16    std::packaged_task<int(int, int)> task(calculate_sum);
17
18    // Obtain the future before moving the task
19    std::future<int> future_result = task.get_future();
20
21    // Execute task on a separate thread
22    std::thread task_thread(std::move(task), 1, 100);
23
24    int result = future_result.get();
25    task_thread.join();
26    return 0;
27}

In this example, the callable is packaged independently from its execution. The main thread retrieves the result through the std::future, while the worker thread is responsible solely for executing the task.

std::shared_future

The std::shared_future class template is a synchronization object that allows the result of an asynchronous operation to be accessed by multiple consumers concurrently. It represents shared ownership of a single shared state.

Unlike std::future, which is move-only and permits get() to be called only once, a std::shared_future is copyable. Multiple std::shared_future instances can refer to the same shared state, and each copy may safely call get() independently.

Calling get() on a std::shared_future does not invalidate the shared state. Instead of transferring ownership of the result, it returns a const reference (or a copy for fundamental types), allowing other copies to retrieve the same value as well. If an exception was stored, each call to get() rethrows it.

A std::shared_future is typically obtained by calling share() on a std::future. After this call, the original std::future becomes invalid, and ownership of the shared state is transferred to the resulting std::shared_future.

This abstraction is ideal when a single background computation produces a result that must be consumed by multiple threads, such as broadcasting a configuration value, initialization result, or precomputed dataset.

1#include <iostream>
2#include <future>
3#include <thread>
4#include <vector>
5#include <chrono>
6
7int calculate_value() {
8    std::this_thread::sleep_for(std::chrono::seconds(1));
9    return 42;
10}
11
12int main() {
13    std::promise<int> promise;
14
15    std::future<int> future = promise.get_future();
16
17    // Convert to shared_future (future becomes invalid)
18    std::shared_future<int> shared_future = future.share();
19
20    std::vector<std::thread> threads;
21
22    for (int i = 0; i < 5; ++i) {
23        threads.emplace_back([shared_future, i] {
24            int value = shared_future.get();
25            std::cout << "Thread " << i 
26                      << " received value: " 
27                      << value << std::endl;
28        });
29    }
30    promise.set_value(calculate_value());
31
32    for (auto& t : threads) {
33        t.join();
34    }
35    return 0;
36}