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."

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
Tisvoid). - An
std::exception_ptrto store an exception if one is thrown. - A
std::mutexto protect access to the state. - A
std::condition_variableto signal when the state is ready. - A flag (e.g.,
boolorstd::atomic<bool>) to indicate readiness and prevent spurious wakeups.

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.
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::promiseis the writer handle. - The
std::futureis 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:
- A lock is acquired on the shared state's mutex.
- The code checks if a value has already been set. If so, it throws a
std::future_errorwith the codepromise_already_satisfied. - The
resultis moved or copied into the value storage within the shared state. - The "ready" flag is set to
true. - The lock is released (often before the notification for performance).
- The condition variable is notified (
notify_all()is common) to wake up any waitingfuture.
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:
- A unique lock is acquired on the shared state's mutex.
- The thread waits on the condition variable, checking the "ready" flag in a loop to handle spurious wakeups. (
cv.wait(lock, [&]{ return ready_flag; });) - Once the flag is
trueand 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. - If there is no exception, it moves the value out of the shared state and returns it.
- The shared state is now considered "consumed." Calling
get()a second time is undefined behavior, and the future becomes invalid (fut.valid()returnsfalse).
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.
- The
std::promisedestructor locks the shared state. - It checks if the promise has been fulfilled.
- If not, it stores a
std::future_errorwith the codebroken_promisein the exception storage. - 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:
- Thread A creates a
std::promise<int>and gets itsstd::future<int>. - Thread A moves the promise to Thread B and immediately calls
fut.get(). - 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):
- 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. - Store Value: The integer
42is stored in thevaluemember of the shared state. - Set Flag: The
ready_flagis set totrue. - Notify: Thread B calls
notify_all()on the condition variable. - Unlock: The
set_valuefunction returns, releasing the lock. - Wake up: The notification from Thread B wakes up Thread A.
- Re-acquire Lock: Thread A's
wait()call re-acquires the lock on the mutex and checks the predicate (ready_flag). It is nowtrue, so the wait completes. - Retrieve Value: Thread A's
get()function proceeds to move the value42out of the shared state. - Return:
fut.get()returns42, 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::promiseandstd::futureare 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::promiseacts as the writer handle, responsible for setting the value/exception and notifying the consumer. - The
std::futureacts 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.