Skip to main content

Welcome to a Grasp lesson

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

Lesson 83

Retrieving Task Results with `std::future`

Recommended resources

Hello! Welcome back to the next step in building our C++ thread pool.

In the last lesson, we implemented a submit method that could accept any task and return a std::future, finally allowing us to retrieve results from our asynchronous computations. This was a major step forward, turning our simple task runner into a powerful parallel execution engine.

Today, we'll dive deep into the mechanism that makes this possible. Our learning outcome is to use std::future to provide a mechanism for clients to retrieve results from submitted tasks. Instead of just using the API, we're going to peel back the layers of abstraction and understand how std::future and its counterpart, std::promise, work "under the hood." This aligns perfectly with your goal of understanding C++ from first principles, connecting high-level features to their low-level mechanics.

The Asynchronous Communication Channel: Promise and Future

At its heart, the std::promise / std::future pair is a one-time, one-way communication channel between two threads.

  • One thread, the producer, holds the std::promise. It performs a computation and eventually fulfills the promise by setting a value or an exception.
  • Another thread, the consumer, holds the std::future. It can query the future for the result. If the result isn't ready, the future will block the consumer thread until the producer fulfills the promise.

This communication happens through a shared state that is typically allocated on the heap. Let's start with a great video that uses a memorable analogy to explain this core concept.

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

The CppCon talk 'Futures from Scratch' by Arthur O'Dwyer provides one of the best explanations of the promise/future model. We'll start by watching the section that introduces the core problem and the 'Pat and Frosty' mailbox analogy for the shared state.

Watch from 1:15 to 6:49. The first part (up to 2:53) sets up why we need asynchronous computation. The second part (starting at 4:09) introduces the mailbox analogy, which represents the shared state, a mutex, a ready flag, and a condition variable—all key components we'll be dissecting.

The key elements from the "mailbox" analogy map directly to C++ concurrency primitives:

  • The Mailbox: The heap-allocated shared state object.
  • Touching the Mailbox: Access is protected by a std::mutex.
  • The Flag: A boolean flag indicating if the result is ready.
  • Banging the Cymbals: Notifying the waiting thread via a std::condition_variable.

This sequence diagram formalizes the interaction:

std::promise and std::future work flow
This diagram illustrates the typical workflow. Thread 1 creates the promise/future pair, passes the promise to Thread 2, and can then perform other work. When it needs the result, it calls `future.get()`, which blocks until Thread 2 fulfills the promise by calling `promise.set_value()`.

Building a Future: The Internals

Now, let's build our own simplified Future to truly understand the mechanics. A naive approach might be to store the result, thread, and synchronization primitives directly inside the Future object itself. However, this runs into a critical issue, especially when you want your Future objects to be movable.

The following article walks through this exact design process, identifying the pitfalls and arriving at the correct, robust solution.

Making a future in C++

The article 'Making a future in C++' by Scott Roy provides an excellent step-by-step guide to implementing a future. It clearly explains why a naive approach fails and how separating the data (control block) from the view (future object) is essential.

Read the sections 'Why isn’t the future moveable?' and 'Making the future flexible'. Pay close attention to the explanation of the dangling this pointer when a lambda captures this and the future object is moved. This is a subtle but critical bug that motivates the final design.

As the article explains, the solution is to decouple the Future object from the data it represents.

  1. The Control Block (Shared State): A non-movable, non-copyable object allocated on the heap that contains the actual result, exception pointer, mutex, condition variable, and ready flag.
  2. The Future/Promise Objects: Lightweight, movable "view" objects that hold a std::shared_ptr to the single control block.

This design ensures that even if the Future or Promise objects are moved between functions or threads, the shared_ptrs inside them always point to the same stable memory location—the control block.

Dissecting the Control Block

Let's examine the implementation of this shared state, or control block. The video from Arthur O'Dwyer and the article both provide excellent code examples.

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

Let's return to the 'Futures from Scratch' video, where Arthur O'Dwyer details the implementation of the SharedState object and the promise and future methods that interact with it.

Watch from 8:38 to 15:41. This segment covers: The SharedState struct definition (8:38). The implementation of promise::set_value() and set_exception() (9:45). The implementation of future::get() and wait() (14:25). Focus on how the mutex, condition variable, and ready flag are orchestrated.

Let's summarize the logic for the two most important operations:

promise::set_value(value) (Producer Thread)

  1. Acquire a lock on the shared state's mutex.
  2. Check if the value has already been set. If so, throw an exception (a promise can only be fulfilled once).
  3. Move the value into the shared state's result member.
  4. Set the ready flag to true.
  5. Unlock the mutex.
  6. Call notify_one() on the condition variable to wake up the (potentially waiting) future.

future::get() (Consumer Thread)

  1. Acquire a lock on the shared state's mutex.
  2. Use the condition variable's wait() method. The wait function will atomically unlock the mutex and suspend the thread until it is notified. Crucially, it waits on a predicate: while (!ready) { wait(); }. This handles spurious wakeups; the thread only proceeds if the ready flag is actually true.
  3. Once woken up and the ready flag is true, the wait returns, with the lock re-acquired.
  4. Check if an exception was stored instead of a value. If so, rethrow it.
  5. Move the result out of the shared state and return it.
  6. The lock is released when the unique_lock goes out of scope.
Test your understanding!

In the future::get() implementation, why is it essential to use condition_variable.wait() with a predicate (e.g., cv.wait(lock, []{ return ready; });) instead of just calling cv.wait(lock); in a loop? What problem does the predicate solve?

Show answer

The predicate solves the problem of spurious wakeups. A thread waiting on a condition variable can sometimes wake up even if it was not explicitly notified. If we only called cv.wait(lock) and assumed the result was ready upon waking, we might try to access the result before the producer thread has actually set it.

By using a predicate ([]{ return ready; }), the wait function internally checks the condition (ready == true) after waking up. If the wakeup was spurious and the condition is false, it automatically goes back to sleep. This guarantees that wait only returns when the producer has actually set the ready flag, making the code correct and robust.


An Alternative Thread Pool API

In our last lesson, we used a std::shared_ptr<std::packaged_task<...>> to achieve type erasure. Now that we understand the promise/future mechanism so well, we can explore an alternative design that uses std::promise more directly. This approach is inspired by modern APIs like Boost.Asio.

This design involves:

  1. A task queue that only stores std::packaged_task<void()> to keep the worker logic simple.
  2. An overloaded global post function that serves as the submission interface.
  3. A special "tag" struct (use_future_tag) to select the overload that returns a std::future.

Building a Thread Pool with C++ and STL

Let's examine a different, very elegant thread pool design. This article presents an implementation that uses std::promise explicitly for tasks that return a value, providing a clear illustration of the concepts we just learned.

Read the sections 'Rewrite It with STL' and the subsequent explanation of the global post functions. Focus on the second post function overload—the one that takes a std::tuple<use_future_tag, Fn>. Notice how it manually creates a std::promise, gets the future, and then enqueues a new lambda that will call the original function and fulfill the promise.

Let's break down the key part of that implementation for a task that returns a value:

// Simplified from the article
template <class Executor, class Fn>
auto post(Executor& exec, std::tuple<use_future_tag, Fn>&& tpl)
{
    // 1. Get the original function
    auto&& [_, func] = tpl;
    using return_type = std::invoke_result_t<Fn>;

    // 2. Create the promise/future pair
    auto promise = std::make_shared<std::promise<return_type>>();
    auto ret_future = promise->get_future();

    // 3. Create a type-erased task for the queue
    std::packaged_task<void()> task([promise, func = std::move(func)] () mutable {
        try {
            promise->set_value(func());
        } catch (...) {
            promise->set_exception(std::current_exception());
        }
    });

    // 4. Submit the task and return the future
    exec.post(std::move(task));
    return ret_future;
}

This approach is more explicit than the shared_ptr<packaged_task> pattern. It directly demonstrates the principle: to get a future, you create a promise, and then you give that promise to the asynchronous worker so it can be fulfilled later. This is a powerful pattern to have in your toolbox for the thread pool project.


Conclusion

Today, we've gone far beyond simply using std::future and delved into its core mechanics. You now have a deep, first-principles understanding of how asynchronous results are communicated in C++.

Key Takeaways:

  • std::promise and std::future communicate via a heap-allocated shared state (control block), which is managed by std::shared_ptr.
  • This shared state uses a std::mutex, a std::condition_variable, and a ready flag to safely and efficiently synchronize the producer and consumer threads.
  • The promise::set_value() method signals the completion, while future::get() blocks using cv.wait() until the result is ready.
  • std::packaged_task is a convenient utility that wraps this promise/future mechanism for a given callable.
  • There are multiple valid design patterns to integrate this mechanism into a thread pool, including both the shared_ptr<packaged_task> approach and the more explicit std::promise approach.

Next Lesson Preview:

Our thread pool is becoming quite robust, but it still has one significant flaw: if the pool is destroyed while tasks are still in the queue, that work is lost. In our next lesson, we will implement a graceful shutdown mechanism that ensures all pending tasks are completed before the worker threads are joined and the pool is destroyed.