Hello! Welcome back to the final module of our course.
In our last session, we added a significant piece of intelligence to our thread pool: dynamic sizing. We created a manager thread that can monitor the workload and adjust the number of worker threads, using std::jthread and interruptible waits to ensure threads can be shut down gracefully. However, in the process, our worker loop's error handling was simplified to a placeholder: catch (...) {}. This prevents a single failing task from crashing a worker, but it silently swallows the error, leaving the client waiting indefinitely for a result that will never come. This is a critical flaw in any production system.
Today, we will fix that. Our learning outcome is to implement robust exception propagation from tasks back to the submitting thread. This means that if a function executed by a worker thread throws an exception, the std::future returned to the client will re-throw that same exception when get() is called. This is the behavior developers expect and is essential for building reliable, debuggable systems.
The Problem: Exceptions Don't Cross Thread Boundaries
When you call a function directly, an exception propagates up the call stack until it's caught. With threads, this chain is broken. Each thread has its own independent call stack. An exception thrown in a worker thread will unwind that thread's stack, and if it's not caught, it will call std::terminate, crashing your entire application.
Let's see a clear demonstration of this problem and the desired solution.
async task-based parallelism in C++11 - Eli Bendersky's website
The article 'async task-based parallelism in C++11' by Eli Bendersky perfectly illustrates the issue. It shows how a naive std::thread-based approach fails and how std::async solves the problem elegantly.
Please read the section titled 'Propagating exceptions between threads'. Notice how throwing from a std::thread leads to std::terminate, even with a try-catch block in the calling thread. Then, see how std::async and std::future make the exception correctly propagate back to the caller.
As the article shows, the goal is to make our ThreadPool::submit behave like std::async. The std::future object must become a conduit not just for the return value, but for exceptions as well.
The Mechanism: promise, exception_ptr, and packaged_task
The standard library provides the tools to build this conduit. The key components are:
std::promise::set_exception(): We've usedpromise::set_value()to fulfill a promise with a result. The counterpart,set_exception(), allows us to fulfill it with an error.std::exception_ptr: You can't pass raw exception objects between threads; you'd run into object slicing and lifetime issues. The C++ standard provides a special, type-erased, sharable pointer calledstd::exception_ptr. You capture the current exception withstd::current_exception()and store the resultingexception_ptrin the promise.std::rethrow_exception(): On the client side, whenfuture::get()is called, the future's internal logic can check if it holds anexception_ptr. If so, it callsstd::rethrow_exception(), which re-throws the original exception, preserving its type and data.
Let's see how these pieces fit together. We could modify the task lambda in our submit function to include a try...catch block:
// Inside ThreadPool::submit...
auto task_wrapper = [/* captured function and args */, promise]() {
try {
// ... invoke the user's function ...
// ... set value on promise ...
} catch (...) {
// If an exception occurs, capture it and store it in the promise.
promise->set_exception(std::current_exception());
}
};
tasks.push(std::move(task_wrapper));
This works, but there's an even cleaner way. The standard library provides an abstraction that encapsulates this exact pattern: std::packaged_task.

A std::packaged_task wraps a callable target. When you invoke the packaged_task object, it calls the target function and stores the return value or any thrown exception in a shared state that can be accessed via a std::future.
This is exactly what we need! It simplifies our code and delegates the complex exception-handling logic to the standard library.
Refactoring with std::packaged_task
The following video provides an excellent walkthrough of refactoring from a manual promise/future implementation to one using standard library components, culminating in std::packaged_task.
C++ Multithreading [std::future]
The video 'C++ Multithreading [std::future]' from the ChiliTomatoNoodle channel shows a practical refactoring process that we can learn from. We'll focus on the parts that introduce std::promise and std::packaged_task.
Watch from 10:16 to 16:43. The first part (10:16 - 13:29) highlights a key 'under-the-hood' issue: std::promise is not copyable, which conflicts with std::function's requirements. Note the C++23 solution, std::move_only_function. The second part (13:29 - 16:43) shows how std::packaged_task solves this problem elegantly by handling the promise creation and exception/value setting internally, dramatically simplifying the task submission logic.
Inspired by that refactoring, we can now update our ThreadPool::submit method. Instead of creating a promise and a complex lambda manually, we'll wrap the user's function in a std::packaged_task.
The task we push to our queue will no longer be the user's work directly. It will be a simple lambda that, when executed, calls the packaged_task.
// In ThreadPool.h
#include <future>
#include <functional>
#include <vector>
// ... other includes
class ThreadPool {
// ...
private:
// Our task queue now holds type-erased functions. std::function is a common choice.
ThreadSafeQueue<std::function<void()>> tasks;
// ...
};
// In ThreadPool.h or ThreadPool.cpp
template<typename Func, typename... Args>
auto ThreadPool::submit(Func&& func, Args&&... args) -> std::future<std::invoke_result_t<Func, Args...>> {
using ReturnType = std::invoke_result_t<Func, Args...>;
// 1. Create a packaged_task that wraps the user's function and its arguments.
// We use std::bind to bundle the function and its arguments into a single callable.
// A shared_ptr is used because the task will be moved into the lambda below,
// so we need shared ownership.
auto task_ptr = std::make_shared<std::packaged_task<ReturnType()>>(
std::bind(std::forward<Func>(func), std::forward<Args>(args)...)
);
// 2. Get the future from the packaged_task. This is what we'll return to the client.
std::future<ReturnType> future = task_ptr->get_future();
// 3. Push a simple, type-erased lambda into the queue.
// This lambda, when executed by a worker, will invoke the packaged_task.
tasks.push([task_ptr]() {
(*task_ptr)();
});
// An optional step: notify one waiting worker thread if you have a condition variable.
// tasks_cv.notify_one();
return future;
}
Our worker loop now becomes incredibly simple. It doesn't need to know anything about promises or exceptions. It just executes the std::function<void()> it pops from the queue.
// Inside the worker loop
void worker_loop(std::stop_token stoken) {
while (!stoken.stop_requested()) {
std::function<void()> task;
if (tasks.wait_and_pop(task, stoken)) {
// Simply execute the task. The packaged_task inside handles everything.
task();
}
}
}
With this change, our thread pool now correctly propagates exceptions.
Under the Hood: How exception_ptr Works
Since you're interested in how things work from first principles, let's dive deeper. What is an exception_ptr? How does std::current_exception() capture an exception?
This machinery is built on top of the C++ runtime's low-level exception handling ABI (Application Binary Interface). On Linux, this is typically the Itanium C++ ABI.
How do exceptions work in C++ on Linux?
To understand the mechanism, we need to look at what happens during a throw. The article 'How do exceptions work in C++ on Linux?' from PVS-Studio blog provides a fantastic breakdown of the Itanium C++ ABI.
Read the section 'Itanium C++ ABI' (you can skip the 'foo()' assembly analysis and focus on 'bar()'). Pay close attention to the functions __cxa_allocate_exception and __cxa_throw. Note that the exception object is allocated on the heap and is accompanied by a header (__cxa_exception) containing metadata like type information.
Here's the key takeaway from the article:
- When you
throwan object, the compiler generates a call to__cxa_allocate_exception. This function allocates memory on the heap for the exception object and a special header (__cxa_exception). - This header contains crucial metadata: a pointer to the object's
std::type_info, a destructor, reference counters, and more. - The compiler then constructs the exception object in this allocated memory.
- Finally,
__cxa_throwis called, passing it a pointer to this allocated block. This pointer begins the stack unwinding process.
An std::exception_ptr is essentially a smart, reference-counted pointer to this runtime-managed exception object on the heap.
std::current_exception()grabs a handle and increments the reference count on the__cxa_exceptionblock. This ensures the exception object outlives thecatchblock it was captured in.promise.set_exception(ptr)stores this smart pointer.future.get()finds this stored pointer and callsstd::rethrow_exception(ptr), which uses the metadata in the__cxa_exceptionheader (like thetype_info) to re-create and re-throw the original exception with its correct type.
This mechanism allows an exception to be "paused", passed across a thread boundary, and "resumed" in a different context, which is exactly what our thread pool requires.
Updating Unit Tests
The final step is to verify our implementation. We need to add a test case to our Google Test suite that proves exceptions are propagated correctly.
// In your thread pool test file...
#include <stdexcept>
TEST(ThreadPoolException, PropagatesException) {
ThreadPool pool(2); // Create a pool with 2 threads
// 1. Define a function that is guaranteed to throw
auto task_that_throws = []() {
throw std::runtime_error("This is a test exception");
};
// 2. Submit the task and get its future
std::future<void> future = pool.submit(task_that_throws);
// 3. Use ASSERT_THROW to verify that future.get() re-throws the correct exception type
try {
future.get();
FAIL() << "Expected std::runtime_error, but no exception was thrown.";
} catch (const std::runtime_error& e) {
// 4. (Optional but good) Check the exception message
ASSERT_STREQ("This is a test exception", e.what());
} catch (...) {
FAIL() << "Expected std::runtime_error, but a different exception was thrown.";
}
}
This test provides concrete evidence that our feature is working as intended.
Test your understanding!
Consider the submit function we just wrote using std::packaged_task. We used a std::shared_ptr<std::packaged_task<...>>. Why is std::shared_ptr necessary here? What would happen if we tried to use std::unique_ptr instead?
Show answer
The std::shared_ptr is necessary because the packaged_task object has two "owners" for a brief period. The first owner is the submit function's local scope. The second owner is the lambda [task_ptr](){ (*task_ptr)(); } that we push into the queue.
If we used a std::unique_ptr, we would have to std::move it into the lambda. Once moved, the original task_ptr in the submit function would become null. While the submit function doesn't need it after the push, using std::shared_ptr is a more robust and common pattern for sharing state between the submission context and the eventual execution context, especially since the task lambda might be copied by std::function. It correctly models the shared lifetime of the task object.
Conclusion
Congratulations! You have now implemented one of the most critical features for a production-ready tasking system. By ensuring exceptions are propagated correctly, you've made the thread pool safer, more predictable, and easier to debug for its users.
Key Takeaways:
- Exceptions thrown in a worker thread must be caught and propagated back to the client thread via the
std::future. - The standard mechanism involves
try...catchin the task wrapper,std::current_exception()to capture the error into anstd::exception_ptr, andpromise::set_exception()to store it. std::packaged_taskis a powerful abstraction that automates this entire process, simplifying the code and reducing the chance of errors.- At a low level,
std::exception_ptrworks by managing a reference-counted handle to the heap-allocated exception object created by the C++ runtime's ABI (e.g.,__cxa_allocate_exception).
Next Lesson Preview:
Our thread pool is now highly functional, with dynamic sizing and robust error handling. But is it correct under heavy load? Concurrency bugs are notoriously difficult to find. In the next lesson, we will focus on implementing stress tests to identify race conditions, deadlocks, and performance bottlenecks, and we'll introduce tools to help us analyze the pool's behavior under pressure.