Async Tasks, Futures, Promises & Implementing a Thread Pool
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
Neither std::promise nor std::future stores the value directly. Instead, they both hold pointers to a heap-allocated Shared State control block. The promise represents the write end (set_value / set_exception), and the future represents the read end (get() / wait()). When the producer writes to the shared state, it marks it ready and notifies the condition variable, allowing the consumer's future.get() to unblock and extract the value.
The ISO C++ standard specifies that the destructor of a std::future that refers to the shared state of a task created by std::async(std::launch::async) must block until the task finishes execution. When std::async is called without assigning its return value to a named variable, the returned temporary std::future is destroyed at the end of the full-expression (the semicolon). This forces the calling thread to wait synchronously for the task to complete before advancing to the next statement.
std::packaged_task owns the shared state write handle and cannot be copied because doing so would create multiple writers to the same future. However, std::function<void()> requires its target callable to be CopyConstructible. To resolve this mismatch in a ThreadPool, we wrap the std::packaged_task in a std::shared_ptr<std::packaged_task<...>>, and capture that shared pointer by value inside a small lambda [task]() { (*task)(); }. Copying the shared pointer satisfies std::function while preserving unique ownership of the underlying task.
Interviewer's lens
Implementing a Thread Pool from scratch is one of the most frequently tested design problems in senior C++ multithreading interviews.
What separates candidates:
- Junior instinct: Confuses
std::asyncwith a thread pool, falls into the temporarystd::futuredestructor blocking trap (accidentally running parallel jobs sequentially), or forgets to join worker threads on destruction (crashing viastd::terminate()). - Senior mastery: Seamlessly stitches together modern templates (
std::invoke_result_t, variadic forwarding packs), synchronization primitives (std::mutex,std::condition_variable), and asynchronous handles (std::packaged_task,std::future) into a clean, crash-proof, RAII-governed task queue that cleanly propagates exceptions across thread boundaries.
Under the hood & memory layout
Think of creating an OS thread as hiring an employee and building them an entire office (1β8 MB virtual stack) for a 5-millisecond task: you quickly exhaust memory and choke the OS scheduler. A Thread Pool instead maintains a small, fixed team of seated workers who pull jobs from a shared queue.
Mechanically, the system coordinates in 3 distinct stages:
- The Worker Loop: N persistent worker threads pre-spawn on startup and loop indefinitely. When idle, they sleep on a
std::condition_variableat 0% CPU. When a job arrives, one worker wakes up, pops the task from a protected queue, and runs it. - The Asynchronous Shared State: A
std::futureandstd::promise(orstd::packaged_task) do not talk directly. They share a heap-allocated Shared State control block. This block contains atomic reference counts, a mutex, a condition variable, and an aligned storage buffer holding either the return valueTor a capturedstd::exception_ptr. - The RAII Shutdown Invariant: On destruction, the pool sets a stop flag, broadcasts
cv_.notify_all()so all sleeping workers wake up to notice the shutdown, and joins every worker thread before freeing resourcesβpreventingstd::terminate()crashes.
Occupies 8 bytes on 64-bit systems. It is a move-only handle containing a raw or smart pointer to the heap-allocated asynchronous Shared State block. Once get() is called, the handle relinquishes ownership and valid() == false.
Dynamically allocated on the heap (~64 to 96 bytes). Contains atomic reference counters (for future and promise), a mutex and condition variable for synchronization, a completion flag, and aligned storage for either the return value T or a captured std::exception_ptr.
On 64-bit systems: std::vector<std::thread> (24 bytes header + 8 Γ N bytes for thread handles), std::queue<std::function<void()>> (backed by std::deque, ~48 bytes), std::mutex (40 bytes), std::condition_variable (48 bytes), and atomic/bool stop_ (1 byte + padding), totaling ~176 bytes excluding heap buffers.
When enqueued, each task closure is wrapped in a std::packaged_task<ReturnType()>. Because std::function<void()> requires CopyConstructible types while packaged_task is move-only, wrapping it in a std::shared_ptr<std::packaged_task<...>> (16 bytes) enables seamless type-erasure inside the task queue.
Core concepts & syntax traps
High-Level Task Concurrency: std::async Policies (async vs deferred)
High-Level Task Concurrency: std::async Policies (async vs deferred)
C++ provides std::async as a high-level task launcher. However, its concurrency behavior depends entirely on its launch policy:
std::launch::async(Guaranteed Background Thread):- Spawns a new, separate asynchronous thread to run the task.
- Throws
std::system_errorif the OS runs out of thread resources.
std::launch::deferred(Lazy Synchronous Execution):- Spawns zero threads. The task sits idle until
future.get()or.wait()is called. - Runs synchronously on the calling thread. If
get()is never called, the task never runs at all!
- Spawns zero threads. The task sits idle until
- Default Policy (
async | deferredTrap):- Omitting the policy allows the runtime to choose arbitrarily. Under high load, it can silently defer your task onto the caller, destroying concurrent throughput.
Rule: Always pass std::launch::async explicitly if true background execution is required.
// 1. Guaranteed background thread:
auto f1 = std::async(std::launch::async, []() { return 42; });
// 2. Deferred (runs synchronously on caller thread only when get() is called!):
auto f2 = std::async(std::launch::deferred, []() { return 99; });
// 3. Unspecified launch policy (NON-DETERMINISTIC in production):
auto f3 = std::async([]() { return 100; }); // May run async or deferred!Never call std::async without an explicit launch policy; unspecified policies can silently defer execution onto the calling thread.
The Dangerous Temporary std::future Destructor Blocking Trap
The Dangerous Temporary std::future Destructor Blocking Trap
One of the most dangerous traps in the C++ standard library is the destructor of a std::future returned by std::async:
The Standard Invariant (Β§32.10.7)
If a std::future created by std::async(std::launch::async) is destroyed before the task finishes, its destructor blocks until the task completes!
The Semicolon Trap
// CRITICAL BUG: Runs 100% SEQUENTIALLY, not concurrently!
for (int i = 0; i < 10; ++i) {
std::async(std::launch::async, compute, i); // Temporary future dies at ';'
}
- Because the return value is not assigned to a named variable, it creates an unnamed temporary
std::future. - At the semicolon (
;), the temporary object destructs. - Its destructor blocks the main thread until
compute(i)completes before starting iterationi+1! - The 10 background tasks execute one-by-one in serial order.
The Fix: Always store futures in a std::vector<std::future<T>> so all tasks launch before any destructor blocks.
// WRONG: Serial execution due to temporary future destructor blocking:
for (int i = 0; i < 5; ++i) {
std::async(std::launch::async, [i]() { doWork(i); }); // BLOCKS at ';'
}
// RIGHT: Store futures to allow simultaneous parallel execution:
std::vector<std::future<void>> futures;
for (int i = 0; i < 5; ++i) {
futures.push_back(std::async(std::launch::async, [i]() { doWork(i); }));
}
for (auto& f : futures) f.get(); // All tasks ran concurrently!An unstored temporary std::future from std::async blocks in its destructor, accidentally serializing parallel code.
std::promise<T> and std::future<T>: Communicating Values and Exceptions
std::promise<T> and std::future<T>: Communicating Values and Exceptions
Think of std::promise and std::future as the two ends of a private delivery tube across threads:
std::promise<T>(The Writer / Producer):- Held by the worker thread.
- Publishes results via
.set_value(val)or reports failure via.set_exception(std::current_exception()).
std::future<T>(The Reader / Consumer):- Held by the caller thread.
- Calling
fut.get()suspends the caller until the worker delivers the package, then moves the value out.
Exception Marshaling Across Threads
In raw std::thread, an unhandled exception escaping a function calls std::terminate(), crashing the whole process. With a promise and future:
- The worker catches the exception and stores it in the shared state via
p.set_exception(...). - When the caller calls
fut.get(), the exception is automatically rethrown on the caller's thread, where it can be caught cleanly in a standardtry / catchblock!
std::promise<double> prom;
std::future<double> fut = prom.get_future();
std::jthread worker([&](std::promise<double> p) {
try {
double divisor = 0.0;
if (divisor == 0.0) throw std::runtime_error("Division by zero");
p.set_value(100.0 / divisor);
} catch (...) {
p.set_exception(std::current_exception()); // Capture and store exception
}
}, std::move(prom));
try {
double result = fut.get(); // Rethrows the stored std::runtime_error!
} catch (const std::runtime_error& e) {
std::println("Caught from worker thread: {}", e.what());
}std::promise and std::future bridge thread boundaries by safely marshaling both return values and unhandled exceptions.
std::packaged_task<Ret(Args...)>: Bridging Arbitrary Callables to Futures
std::packaged_task<Ret(Args...)>: Bridging Arbitrary Callables to Futures
std::packaged_task<Signature> wraps any callable target (function, lambda, or functor) and automatically forwards its return value and thrown exceptions into an associated std::future.
Why it is the essential building block of a Thread Pool:
- Zero Boilerplate: It eliminates manual
std::promisesetup. Executingtask()automatically handlesset_value()andset_exception(). - The Thread Pool Pipeline:
- The client calls
pool.enqueue(f, args...). - The pool wraps the callable into a
std::packaged_task<ReturnType()>. - The client receives
task.get_future(). - The task is wrapped in a type-erased
std::function<void()>closure and pushed into the work queue. - When any worker thread dequeues and executes
(*task)(), the return value lands directly in the client's future!
- The client calls
// Wrap a lambda in a packaged_task:
std::packaged_task<int(int, int)> task([](int a, int b) { return a * b; });
// Extract the future handle BEFORE executing the task:
std::future<int> fut = task.get_future();
// Execute the task (on any thread or inline):
std::jthread worker(std::move(task), 6, 7);
std::println("Result: {}", fut.get()); // 42std::packaged_task eliminates manual promise boilerplate by automatically routing return values and exceptions into a future.
Anatomy of a Production Thread Pool: Queue Design & RAII Shutdown
Anatomy of a Production Thread Pool: Queue Design & RAII Shutdown
A production-grade C++ Thread Pool requires 5 synchronized components:
- Worker Threads: A
std::vector<std::thread>sized tostd::thread::hardware_concurrency(). - Task Queue: A
std::queue<std::function<void()>>storing type-erased task closures. - Queue Synchronization: A
std::mutexandstd::condition_variableto protect the queue and put idle workers to sleep at 0% CPU. - Generic Variadic
enqueue: Usesstd::invoke_result_t<F, Args...>to deduce return types, returning astd::future<ReturnType>to the caller. - The RAII Shutdown Invariant:
- In
~ThreadPool(), setstop_ = trueunder the lock. - Broadcast
cv_.notify_all()so all sleeping workers wake up to notice the shutdown. - Join every single worker thread in sequence. If any thread is left unjoined, its destructor calls
std::terminate(), crashing the entire application.
- In
template <typename F, typename... Args>
auto enqueue(F&& f, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>> {
using ReturnType = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<ReturnType()>>(
[func = std::forward<F>(f), ...capturedArgs = std::forward<Args>(args)]() mutable {
return std::invoke(std::move(func), std::move(capturedArgs)...);
}
);
std::future<ReturnType> fut = task->get_future();
{
std::unique_lock<std::mutex> lock(mtx_);
if (stop_) throw std::runtime_error("enqueue on stopped pool");
tasks_.emplace([task]() { (*task)(); });
}
cv_.notify_one();
return fut;
}A production ThreadPool must wrap move-only packaged_tasks in std::shared_ptr to store them inside std::function closures.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.