Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 57

Asynchronous Data Transfer with `std::promise` and `std::future`

Recommended resources

Hello! Welcome to the next lesson in our journey through C++ concurrency.

In our last session, we delved into the low-level hardware mechanics of CPU caches and discovered how a phenomenon called "false sharing" can sabotage the performance of otherwise correct concurrent code. We learned that understanding the machine's behavior is critical for writing truly scalable software.

Today, we're moving up the abstraction stack from hardware-level concerns to a powerful C++ library feature designed to simplify asynchronous programming: std::promise and std::future. These tools provide a clean, high-level mechanism for one thread to get a result from another, without manually managing the complex boilerplate of mutexes, condition variables, and shared flags. This lesson will help you master the transfer of data between threads in a modern and robust way.

1. The Core Problem: Getting a Result from Another Thread

Imagine you start a task on a new thread—perhaps a complex calculation or a network request. The main thread continues its own work but eventually needs the result of that background task. How does it get it?

You could implement this manually with a shared variable protected by a mutex and a condition variable to signal when the result is ready. However, this is boilerplate code that's easy to get wrong. C++ provides a much cleaner solution with the std::promise and std::future pair.

Conceptually, you can think of them as two ends of a communication channel:

  • std::promise: The "writer" or "producer" end. The thread performing the calculation uses the promise to deliver the result.
  • std::future: The "reader" or "consumer" end. The thread that needs the result holds the future and uses it to retrieve the value once it's available.

A great analogy from the Stack Overflow resource you'll see later is that of a microphone and a speaker. The std::promise is the microphone you give to the worker thread. The std::future is the speaker you keep, from which you'll eventually hear the result.

resource_title

Analogy of std::promise as a microphone and std::future as a speaker for one-to-one asynchronous communication. Caption adapted from a Stack Overflow answer by user Narcolessico.


2. Under the Hood: The Shared State

The "wire" connecting the promise and future is a piece of memory on the heap, often called the shared state. This is the central mechanism that makes the whole system work. Your strong foundation in C and OS concepts will make understanding this straightforward.

C++ Shared State for Asynchronous Operations
This diagram shows how different C++ concurrency features, including `std::promise`, `std::packaged_task`, and `std::async`, set a value into a central shared state, from which a `std::future` can retrieve it.

The shared state typically contains:

  1. The result value (or a place for it).
  2. An exception pointer, in case the worker thread throws.
  3. A "ready" flag to indicate whether the value/exception has been set.
  4. A std::mutex and a std::condition_variable to synchronize access between the producer (promise) and consumer (future).

To see how these pieces fit together, let's watch a segment of a talk that builds this mechanism from scratch. This aligns perfectly with your preference for understanding things from first principles.

CppCon 2015: Arthur O'Dwyer “Futures from Scratch..."

In this CppCon talk, Arthur O'Dwyer demonstrates how to implement futures from scratch. His explanation of the 'mailbox' analogy and the SharedState object provides a clear mental model for how promise and future communicate internally.

Watch from 4:09 to 12:49. This segment covers: The 'mailbox' analogy for thread communication (4:09 - 6:49). The structure of the SharedState object on the heap (8:45 - 9:37). How the Promise's set_value() method works: it locks the mutex, sets the value, raises the ready flag, and notifies the waiting future (9:37 - 12:49).

As you saw, the implementation is essentially a standardized, robust version of the manual synchronization pattern we discussed earlier. The promise calls notify_all() on the condition variable, and the future calls wait() on it.


3. Using the std::promise and std::future Pair

Now that you understand the internal mechanics, let's look at how to use the standard library versions.

The Producer Thread: Using std::promise

The thread that will produce the result holds the std::promise object.

  1. Create a std::promise<T>, where T is the type of the result.
  2. Get the associated std::future<T> by calling promise.get_future(). This future is then given to the consumer thread.
  3. Move the std::promise into the producer thread. Promises, like many C++ concurrency objects, are move-only to enforce clear ownership semantics.
  4. Once the result is ready, call promise.set_value(result).
  5. If an error occurs, call promise.set_exception(std::current_exception()) to store the exception in the shared state.

The Consumer Thread: Using std::future

The thread that needs the result holds the std::future object.

  1. To get the result, call future.get(). This call is blocking:
    • If the result is ready, it returns the value immediately.
    • If the result is not ready, it blocks the thread until the promise is fulfilled.
    • If the promise was fulfilled with set_exception(), future.get() will re-throw that exception in the consumer thread's context.
  2. future.get() can only be called once on a given std::future object. After the call, the future becomes invalid. You can check this with the valid() member function.

The following document provides a concise summary of these concepts.

Untitled

This chapter from the 'Simplify C++' mini-booklet gives a clear, structured overview of std::future and std::promise.

Please read Chapter 2 'std::future' and Chapter 3 'std::promise and std::packaged_task'. Focus on: Chapter 2: The concepts of valid() state, the waiting functions (wait_for), how exceptions are handled, and the purpose of std::shared_future. Chapter 3: How to produce results with set_value(), the importance of set_exception() for robust error handling, and the concept of a 'broken promise'.

A Complete Example

Let's look at a sequence diagram that shows the entire workflow.

std::promise and std::future work flow
A sequence diagram illustrating the interaction between two threads using `std::promise` and `std::future`. Thread 1 creates the promise/future pair and starts Thread 2, passing it the promise. Thread 1 blocks on `future.get()`, and is unblocked when Thread 2 calls `promise.set_value()`.

Here's how this looks in code:

#include <iostream>
#include <thread>
#include <future>
#include <chrono>

// The worker function that will run on a separate thread
void calculate_pi(std::promise<double> promise, int terms) {
    try {
        std::cout << "Worker thread: calculating Pi...\n";
        double sum = 0.0;
        for (int i = 0; i < terms; ++i) {
            int sign = (i % 2 == 0) ? 1 : -1;
            sum += sign / (2.0 * i + 1.0);
        }
        double pi = sum * 4.0;
        
        // Fulfill the promise with the calculated value
        promise.set_value(pi);

    } catch (...) {
        // If any exception occurs, store it in the promise
        promise.set_exception(std::current_exception());
    }
}

int main() {
    // 1. Create a promise for a double value
    std::promise<double> pi_promise;

    // 2. Get the future associated with the promise
    std::future<double> pi_future = pi_promise.get_future();

    // 3. Start the worker thread, moving the promise into it
    std::cout << "Main thread: starting worker...\n";
    std::thread worker(calculate_pi, std::move(pi_promise), 1000000);

    // Main thread can do other work here...
    std::cout << "Main thread: doing other work...\n";
    std::this_thread::sleep_for(std::chrono::milliseconds(500));

    // 4. Get the result from the future. This will block until the worker is done.
    try {
        std::cout << "Main thread: waiting for the result...\n";
        double pi = pi_future.get();
        std::cout << "Main thread: The calculated value of Pi is " << pi << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Main thread: Caught an exception from worker: " << e.what() << std::endl;
    }

    worker.join();
    return 0;
}
Test your understanding!

What happens if the calculate_pi function's try...catch block is removed and a std::runtime_error is thrown inside it before set_value is called? Where and how is this error handled?

Show answer

If the try...catch block is removed, the exception would cause the calculate_pi function to terminate. This would destroy the std::promise object without set_value or set_exception being called. This is known as a broken promise.

When the main thread calls pi_future.get(), it will throw a std::future_error with the error code std::future_errc::broken_promise. This would be caught by the catch (const std::exception& e) block in main, and an error message about a broken promise would be printed. This demonstrates why it's crucial for the worker thread to always fulfill its promise, either with a value or an exception.


4. Advanced Topics: Shared Futures and Refactoring

What if multiple threads need to consume the same result? std::future is a single-consumer object. For this scenario, C++ provides std::shared_future. You can create one from a std::future using future.share(). A std::shared_future is copyable, and each copy can call get() to retrieve the result.

The following video shows a practical refactoring of a thread pool to use std::promise and std::future, and also touches on the difference between std::future and std::shared_future.

C++ Multithreading [std::future]

This video from the ChiliTomatoNoodle channel provides a great practical demonstration of refactoring a custom thread task system to use the standard library's std::promise and std::future. It also introduces std::shared_future.

Watch the following segments: 0:00 - 3:37: Introduction to standard library futures and a review of their features, contrasting them with a custom implementation. 6:52 - 8:06: An explanation of std::shared_future for multi-consumer scenarios. 8:06 - 13:29: A live coding session showing how to replace a custom implementation with std::promise and std::future, which is great for seeing it in a real-world context.


Conclusion

You have now learned the fundamental mechanism in modern C++ for transferring a single result from a producer thread to a consumer thread. This pattern is central to many asynchronous designs and is a key building block for the project you'll be developing.

Key Takeaways:

  • Producer/Consumer Model: std::promise is for the producer thread to set a value or an exception. std::future is for the consumer thread to retrieve it.
  • Shared State: They communicate via a hidden, synchronized shared state on the heap.
  • Blocking get(): future.get() blocks until the result is ready and can only be called once.
  • Exception Propagation: Exceptions thrown in the producer can be caught in the consumer by wrapping future.get() in a try...catch block. This is a powerful feature for error handling across threads.
  • Ownership: std::promise and std::future are move-only. For multiple consumers, use the copyable std::shared_future.

What's Next?

While std::promise and std::future are powerful, they are still relatively low-level. Often, you don't want to manage the thread and promise manually. In the next lesson, we will explore std::packaged_task and std::async, two higher-level abstractions built directly on top of the promise/future mechanism that further simplify asynchronous programming.