Skip to main content

Welcome to a Grasp lesson

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

Lesson 82

Handling Tasks with Return Values: `std::packaged_task`

Recommended resources

Hello! Welcome back to our project of building a C++ thread pool.

In our last lesson, we constructed the core of our thread pool: the worker threads. We implemented their main loop, which fetches tasks from a queue, and we set up the constructor and destructor to manage their lifecycle. However, our current design is limited—the task queue only accepts functions that return void (std::packaged_task<void()>). This means we can't get any results back from our asynchronous computations.

Today, we will solve that problem. Our learning outcome is to integrate std::packaged_task or a similar mechanism to handle tasks with return values. We will build the public-facing submit method for our thread pool. This method will be the bridge between the client code that submits a task and the worker thread that executes it, providing a channel to send the result back asynchronously.

The Asynchronous Communication Channel: packaged_task and future

To pass a result from a worker thread back to the main thread (or any other thread), we need a communication channel. C++ provides a powerful pair of tools for this: std::packaged_task and std::future.

  • std::packaged_task: A wrapper that bundles a callable object (like a function or lambda). When executed, it calls the underlying object and stores the return value in a shared state.
  • std::future: An object that represents the eventual result of an asynchronous operation. It is the "read end" of the communication channel, allowing you to retrieve the value from the shared state once it's available.

Let's see these concepts in action.

C++ Threading #9: packaged_task

To understand how we'll get results back from our worker threads, let's first look at the main tool for this job: std::packaged_task. This video from Bo Qian's 'C++ Threading' series provides a clear and concise explanation.

Watch the first part (0:00 - 3:42) to understand what a packaged_task is, how to create one, and how to get its associated std::future. Then, watch the second part (4:26 - 10:33), which demonstrates its use in a producer-consumer scenario very similar to our thread pool. Pay close attention to how the future is obtained on one thread before the task is executed on another.

As the video demonstrates, the workflow is:

  1. You create a std::packaged_task wrapping your function.
  2. You call .get_future() on the task to obtain a std::future object. This future is now linked to the eventual result of the task.
  3. You can move the packaged_task to another thread (in our case, via a queue).
  4. The other thread executes the packaged_task. This populates the shared state with the return value.
  5. On the original thread, calling .get() on the future will block until the result is ready and then return it.

Under the Hood: The Promise/Future Model

The packaged_task is a high-level convenience wrapper. Under the hood, it uses the promise/future model.

  • std::promise: The "writer" end of the channel. The asynchronous operation uses a promise to set_value() or set_exception() in the shared state.
  • std::future: The "reader" end, which we've already seen.

A packaged_task essentially encapsulates a std::promise and your callable function. When the task is executed, it calls your function and uses its internal promise to fulfill the future.

This diagram helps visualize the relationships between these components:

C++ Concurrency: Future, Promise, Packaged Task, and Async Relationships
This diagram shows that `std::packaged_task` internally combines a `std::promise` and a `function`. It provides a `get_future()` method to access the reading end of the channel. `std::async` is a higher-level utility that can use these components to run a task on a thread and return its future.

Designing the submit() Method

Now, let's integrate this into our thread pool. We need a submit() method that can:

  1. Accept any function with any number and type of arguments.
  2. Immediately return a std::future that will eventually hold the result.
  3. Package the function and its arguments into a task that our worker threads can execute.

The key challenge here is that our internal task queue stores std::function<void()>. This is a form of type erasure—we hide the specific return type of each task so they can all be stored in the same container. Our submit method must therefore wrap any given task, regardless of its return type, into a generic void() function.

Let's study a full implementation of such a submit method.

Thread pool implementation using c++11 threads

Now, let's look at a complete implementation of a submit method that uses these concepts. The author of the mtrebi/thread-pool repository on GitHub provides an excellent, if initially complex-looking, implementation. We will break it down piece by piece.

First, read the 'Submit function' section to understand the design goals. Then, carefully study the 'Submit implementation' section. The syntax is advanced, but we will dissect it in detail right after you read it.


Dissecting the submit Implementation

The code you just read is dense, but it's a powerful demonstration of modern C++ features. Let's walk through it logically.

Here is the function signature:

template<typename F, typename... Args>
auto submit(F&& f, Args&&... args) -> std::future<decltype(f(args...))>
  • template<typename F, typename... Args>: This makes submit a variadic template. F is the type of the callable (e.g., a function pointer or lambda), and Args... is a parameter pack representing zero or more argument types.
  • F&& f, Args&&... args: These are universal references. Paired with std::forward, they allow the function to accept arguments of any value category (lvalue or rvalue) and forward them perfectly, preserving their original category. This prevents unnecessary copies.
  • -> std::future<decltype(f(args...))>: This is a trailing return type. We use auto and specify the type after the arguments. decltype(f(args...)) is a compile-time operator that deduces the return type of calling f with args.... This makes the submit function automatically return the correct std::future type (e.g., std::future<int>, std::future<std::string>).

Now, let's look at the implementation body, step-by-step:

1. Bind the function and its arguments:

std::function<decltype(f(args...))()> func = 
    std::bind(std::forward<F>(f), std::forward<Args>(args)...);

std::bind creates a new callable object (func) that has the function f and all its arguments args "baked in". The resulting func is a function object that takes no parameters; when you call func(), it will invoke f(args...).

2. Create a packaged task:

auto task_ptr = std::make_shared<std::packaged_task<decltype(f(args...))()>>(func);

Here, we create the packaged_task. Its template argument, decltype(f(args...))(), matches the signature of the zero-argument function object func we just created.
Since std::packaged_task is move-only (it cannot be copied), we wrap it in a std::shared_ptr. This allows us to create a copyable wrapper in the next step.

3. Perform Type Erasure:

std::function<void()> wrapper_func = [task_ptr]() {
    (*task_ptr)();
};

This is the core of the type erasure. We create a simple lambda, wrapper_func, that captures the task_ptr. This lambda has the signature void(), so it can be stored in our queue. When a worker thread executes wrapper_func, it will call the packaged_task inside, which in turn calls the original bound function.

4. Enqueue the Task and Return the Future:

// Enqueue the generic wrapper function
m_queue.enqueue(wrapper_func); 

// ... notify a thread ...

// Return future from the task
return task_ptr->get_future();

Finally, we push our type-erased wrapper_func onto the queue for a worker to pick up. We then call get_future() on our task_ptr and return the resulting future to the client. The client can now hold onto this future and get() the result when needed.

Test your understanding!

Why is the type erasure step (creating a std::function<void()> wrapper) necessary for our thread pool design? What would happen if we tried to create a queue that directly holds std::packaged_tasks of different types, like std::deque<std::packaged_task<int()>> and std::deque<std::packaged_task<std::string()>>?

Show answer

C++ is a statically-typed language. A container like std::deque can only hold objects of a single, specified type. We could not have one queue that holds both std::packaged_task<int()> and std::packaged_task<std::string()> because they are fundamentally different, unrelated types.

By wrapping every task in a std::function<void()>, we give all tasks a uniform type. This allows us to store them in a single queue (std::deque<std::function<void()>>), from which any worker thread can pull any task, regardless of its original return type. The future returned to the client retains the original type information, ensuring type safety on the client's side.


Usage Example

With our submit function in place, the client-side code becomes very clean and intuitive.

Thread pool implementation using c++11 threads

Let's look at how to use this powerful submit function. The same repository provides clear examples.

Read 'Use-Case #1: Function returns the result'. This shows exactly how a client would submit a task and retrieve its value.

As you can see, the complexity is entirely hidden within the submit method. The user simply calls:

// Submit the 'multiply' function with arguments 2 and 3
auto future_result = pool.submit(multiply, 2, 3);

// ... do other work ...

// Block and retrieve the result when needed
int result = future_result.get(); 

This elegant interface is the goal of a well-designed thread pool.


Conclusion

Today, we've unlocked the full potential of our thread pool by enabling asynchronous communication of results. We connected the client code to the worker threads via the powerful std::packaged_task and std::future mechanism.

Key Takeaways:

  • std::packaged_task wraps a callable to store its result in a shared state.
  • std::future provides a handle to retrieve that result asynchronously.
  • A submit function for a thread pool must be a variadic template to accept any function and its arguments.
  • Type erasure is a critical technique used to store tasks with different return types in a single, homogeneous queue (e.g., std::deque<std::function<void()>>).
  • Wrapping the packaged_task in a shared_ptr and then a void() lambda is a common pattern to achieve this type erasure.

Next Lesson Preview:

Our thread pool is now quite functional. However, our shutdown mechanism from the last lesson is abrupt. The destructor signals threads to stop, but it doesn't wait for tasks already in the queue to be completed. In the next lesson, we will implement a graceful shutdown that allows the pool to finish all pending work before the threads join, ensuring no work is lost.