Skip to main content

Welcome to a Grasp lesson

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

Lesson 60

Understanding `std::promise` and `std::future` Shared State

Recommended resources

Hello! Welcome to the next lesson in our exploration of advanced C++ concurrency.

In the last lesson, we examined std::shared_mutex as a specialized tool for optimizing synchronization in read-heavy scenarios. We saw how it provides a higher-level abstraction than a simple std::mutex to solve a specific performance pattern.

Today, we continue this theme by looking at another crucial high-level concurrency feature: the mechanism for communicating results between threads. We will move beyond just synchronizing access to shared memory and focus on how a "producer" thread can safely and efficiently hand off a computed value or an exception to a "consumer" thread.

This lesson is all about std::promise and std::future. You've used these before, but our goal today is to tear them apart and see how they work. In line with your goal of understanding C++ from the ground up, we will analyze their internal mechanics, focusing specifically on the central component that makes them work: the shared state.

1. The Core Problem: Asynchronous Communication

Imagine a simple scenario: you start a new thread to perform a complex calculation, and your main thread needs the result of that calculation to proceed. How do you transfer the result back?

A naive C-style approach might involve a shared pointer for the result and a boolean flag, both protected by a mutex. The worker thread would set the value and the flag, and the main thread would spin, repeatedly locking the mutex to check the flag. This is inefficient (due to polling) and verbose.

A better approach uses a condition variable. The main thread would wait on the condition variable, and the worker thread would notify it after setting the value. This works, but it still requires you to manually manage the mutex, condition variable, shared data, and a "ready" flag. This boilerplate is error-prone.

std::promise and std::future are designed to encapsulate this entire communication pattern—value transfer, exception propagation, and synchronization—into a clean, high-level API.

Let's begin by watching a short segment that frames this problem and introduces the mailbox metaphor, which is a great way to conceptualize the underlying mechanism.

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

Watch this clip from Arthur O'Dwyer's CppCon talk, 'Futures from Scratch', where he explains the problem of manual thread communication and introduces the 'Pat and Frosty' mailbox analogy. This sets the stage for understanding the roles of the mutex, condition variable, and ready flag.

Watch from 1:15 to 7:33. The first part (until 2:53) discusses moving from manual thread management to an asynchronous model. The second part (from 4:09) explains the communication protocol using the mailbox metaphor, which is implemented with a mutex, condition variable, and a ready flag.

This "mailbox" is the key. In C++, it isn't a physical object, but a concept implemented as a shared state.


2. The Shared State: The Heart of the Mechanism

Neither the std::promise nor the std::future object actually stores the data directly. Instead, they are both lightweight handles that point to a single, dynamically allocated control block on the heap. This control block is the "shared state."

Internal Structure of std::future in libc++
This image from the libc++ standard library implementation shows that a `std::future` object is primarily a wrapper around a pointer to an `_assoc_state`, which is the internal name for the shared state.

This design is crucial. Because the shared state lives independently on the heap, the std::promise can be moved to a worker thread while the std::future remains in the main thread, and both can still coordinate through this central object.

So, what does this shared state contain?

  • A place to store the value (or nothing, if T is void).
  • An std::exception_ptr to store an exception if one is thrown.
  • A std::mutex to protect access to the state.
  • A std::condition_variable to signal when the state is ready.
  • A flag (e.g., bool or std::atomic<bool>) to indicate readiness and prevent spurious wakeups.
Internal Structure of `std::promise`/`std::future` Shared State in libc++
This image, also from libc++, shows the components of the base class for the shared state. You can clearly see the `_mut_` (mutex), `_cv_` (condition variable), `_state_` (readiness flag), and `_eptr` (exception pointer). This is the real-world implementation of our conceptual 'mailbox'.

To truly understand why this separate, heap-allocated state is necessary, let's explore an excellent article that attempts to build a Future class from scratch. It brilliantly demonstrates how a simpler implementation breaks down, motivating the need for this exact design.

Making a future in C++

The article 'Making a future in C++' walks through the design process of a future. It shows why a naive implementation fails when moved and how this leads to the conclusion that a separate, shared control block is required.

Please read the sections 'Why isn’t the future moveable?' and 'Making the future flexible'. The first section shows a critical flaw with a simple implementation due to the lambda's this capture. The second section solves this by introducing a separate FutureData struct managed by a std::shared_ptr—this is the shared state.

As the article concludes, the std::promise/std::future pair are simply different "views" or "handles" into this shared state:

  • The std::promise is the writer handle.
  • The std::future is the reader handle.

3. The Promise-Future Lifecycle

Now let's trace the flow of information through the shared state.

Setting the Value (The Promise's Role)

When you call prom.set_value(result) on a std::promise, the following happens internally:

  1. A lock is acquired on the shared state's mutex.
  2. The code checks if a value has already been set. If so, it throws a std::future_error with the code promise_already_satisfied.
  3. The result is moved or copied into the value storage within the shared state.
  4. The "ready" flag is set to true.
  5. The lock is released (often before the notification for performance).
  6. The condition variable is notified (notify_all() is common) to wake up any waiting future.

If prom.set_exception(eptr) is called instead, the exception_ptr is stored instead of the value, but the synchronization steps are the same.

Getting the Value (The Future's Role)

When you call fut.get() on a std::future, the internal logic is:

  1. A unique lock is acquired on the shared state's mutex.
  2. The thread waits on the condition variable, checking the "ready" flag in a loop to handle spurious wakeups. (cv.wait(lock, [&]{ return ready_flag; });)
  3. Once the flag is true and the thread is woken up, it checks if the shared state contains an exception. If so, it rethrows the exception in the current thread's context.
  4. If there is no exception, it moves the value out of the shared state and returns it.
  5. The shared state is now considered "consumed." Calling get() a second time is undefined behavior, and the future becomes invalid (fut.valid() returns false).

This detailed interaction is explained very clearly in the following segment.

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

Let's return to the 'Futures from Scratch' talk. This part details the actual implementation of the promise and future methods interacting with the shared state.

Watch from 9:45 to 15:55. This segment covers: promise::set_value() and promise::set_exception() logic. future::get() logic, including waiting and retrieving the value or rethrowing the exception.


4. Handling Edge Cases: The Broken Promise

What happens if the std::promise object is destroyed before set_value() or set_exception() is ever called? This could happen if the worker thread terminates prematurely. The std::future would wait forever, deadlocking the program.

To prevent this, the std::promise destructor has a special role. If it is being destroyed without having been fulfilled, it sets an exception in the shared state itself.

  1. The std::promise destructor locks the shared state.
  2. It checks if the promise has been fulfilled.
  3. If not, it stores a std::future_error with the code broken_promise in the exception storage.
  4. It sets the ready flag and notifies the condition variable.

Now, when fut.get() is called on the corresponding future, it will wake up and throw this broken_promise exception, allowing the consumer to gracefully handle the failure instead of deadlocking.

This final clip explains this crucial part of the lifecycle.

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

This final clip from the CppCon talk explains the 'broken promise' scenario and how the promise's destructor ensures that a waiting future is never abandoned.

Watch from 18:43 to 21:03 to understand how destroying an unfulfilled promise works and why it's a critical part of the design.

Test your understanding!

Consider the shared state object we've discussed. Describe the sequence of state changes (value, exception, ready flag) and thread actions (locking, waiting, notifying) for the following scenario:

  1. Thread A creates a std::promise<int> and gets its std::future<int>.
  2. Thread A moves the promise to Thread B and immediately calls fut.get().
  3. Thread B sleeps for 1 second and then calls prom.set_value(42).

What is the state of the shared object just before Thread B calls set_value? What happens step-by-step when set_value is called?

Show answer

State just before set_value:

  • Thread A is blocked inside fut.get(). It has acquired the shared state's mutex and is now waiting on the condition variable. The mutex is unlocked while waiting.
  • The shared state contains:
    • value: uninitialized.
    • exception_ptr: nullptr.
    • ready_flag: false.

Step-by-step actions during prom.set_value(42):

  1. Lock: Thread B calls set_value, which attempts to lock the shared state's mutex. Since Thread A is waiting on the CV, the mutex is available, so Thread B successfully acquires the lock.
  2. Store Value: The integer 42 is stored in the value member of the shared state.
  3. Set Flag: The ready_flag is set to true.
  4. Notify: Thread B calls notify_all() on the condition variable.
  5. Unlock: The set_value function returns, releasing the lock.
  6. Wake up: The notification from Thread B wakes up Thread A.
  7. Re-acquire Lock: Thread A's wait() call re-acquires the lock on the mutex and checks the predicate (ready_flag). It is now true, so the wait completes.
  8. Retrieve Value: Thread A's get() function proceeds to move the value 42 out of the shared state.
  9. Return: fut.get() returns 42, and Thread A continues execution.

Conclusion

Today, we've dissected the std::promise and std::future mechanism, revealing that it's a sophisticated abstraction built on the fundamental concurrency primitives you're already familiar with.

Key Takeaways:

  • std::promise and std::future are lightweight handles for a producer-consumer communication channel.
  • The core of the mechanism is a heap-allocated shared state object, which contains the result (or exception), a mutex, a condition variable, and a readiness flag.
  • The std::promise acts as the writer handle, responsible for setting the value/exception and notifying the consumer.
  • The std::future acts as the reader handle, responsible for waiting on the result and retrieving it.
  • The system is designed to be robust, handling exceptions and "broken promises" gracefully to prevent deadlocks. This internal complexity is precisely what makes the public API so simple and powerful.

What's Next?

We've seen how high-level primitives like std::shared_mutex and std::promise/std::future are built on top of locks. While effective, locking always carries the risk of blocking and the overhead of OS context switches. In our next lesson, we will finally dive into the world of lock-free programming with std::atomic. You will learn how to coordinate threads without using mutexes, relying instead on special atomic CPU instructions to ensure data consistency.