This is a lightweight, high-performance ThreadPool implementation built with C++20. Its core capabilities include safely submitting asynchronous tasks, returning futures, and enabling graceful shutdown through
std::jthreadandstop_token. It solves common problems in traditional thread pools, such as manualjoin, stop-state races, and uncontrolled task lifetimes. Keywords: C++20, ThreadPool, asynchronous programming.
Technical specifications at a glance
| Parameter | Description |
|---|---|
| Language | C++20 |
| Concurrency model | Task queue + worker thread pool |
| Stop mechanism | Cooperative interruption with std::stop_token |
| Thread management | Automatic join with std::jthread |
| Result retrieval | std::future |
| Core dependencies | ` |
,,,,` |
|
| Ideal use cases | High-concurrency task dispatching, background computation, asynchronous server execution |
| Star count | Not provided in the source input |
This thread pool implementation represents a minimal viable architecture for modern C++ concurrency.
This implementation does not aim for complex scheduling. Instead, it focuses on three goals: uniformly wrapping heterogeneous tasks, ensuring that task objects remain valid before asynchronous execution begins, and exiting safely when the thread pool is destroyed.
Compared with the common C++11-era pattern of std::thread + bool stop + notify_all(), the C++20 version is shorter and much less likely to introduce race conditions or undefined behavior during destruction.
Core implementation
#pragma once
#include
<condition_variable>
#include
<functional>
#include
<future>
#include
<memory>
#include
<mutex>
#include
<queue>
#include
<thread>
#include
<vector>
class ThreadPool {
public:
explicit ThreadPool(size_t threads) {
for (size_t i = 0; i < threads; ++i) {
workers.emplace_back([this](std::stop_token st) {
while (!st.stop_requested()) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
// Wait for a task to arrive, or for a stop request
bool ok = condition.wait(lock, st, [this] {
return !tasks.empty();
});
// Exit safely if stopping was requested and the queue is empty
if (!ok && tasks.empty()) return;
// Pop the task to reduce time spent in the critical section
task = std::move(tasks.front());
tasks.pop();
}
// Execute outside the lock to avoid blocking other workers
task();
}
});
}
}
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using return_type = std::invoke_result_t<F, Args...>;
// Use shared_ptr to manage the packaged_task lifetime across threads
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward
<F>(f), std::forward<Args>(args)...)
);
std::future
<return_type> result = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
// Erase the concrete type to void() so all tasks fit in one queue
tasks.emplace([task]() { (*task)(); });
}
condition.notify_one();
return result;
}
~ThreadPool() = default; // jthread automatically requests stop and joins
private:
std::mutex queue_mutex;
std::condition_variable_any condition;
std::queue<std::function<void()>> tasks;
std::vector<std::jthread> workers; // Must be declared last so it is destroyed first
};
This code implements four core capabilities: thread creation, task submission, result retrieval, and graceful shutdown.
std::function<void()> solves the problem of storing heterogeneous tasks in a single queue.
Many developers ask: since std::packaged_task already exists, why not put it directly into the queue? The reason is that it is a strongly typed template. If the return type differs, the type differs as well.
For example, a task that returns int and one that returns std::string correspond to two distinct packaged_task instantiations. A standard queue cannot directly store objects of different types, so you need a uniform wrapper layer.
Type erasure is the key abstraction in a thread pool task queue.
using return_type = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward
<F>(f), std::forward<Args>(args)...)
);
// Erase the concrete return type through a lambda wrapper
std::function<void()> wrapper = [task]() {
(*task)(); // Execute the real task and write the result into the future
};
This wrapping logic flattens tasks with arbitrary return types into void(), allowing them to enter the same task queue uniformly.
The shared_ptr capture pattern ensures that asynchronous task objects never dangle before execution.
The real challenge in a thread pool is often not concurrency, but lifetime management. After enqueue returns, local variables are destroyed. If the task object lives only on the stack, a worker thread that executes it later will access an invalid object.
Here, std::make_shared creates the packaged_task, and the lambda captures that smart pointer by value, forming a safe ownership handoff. As long as the lambda remains in the queue, the task object cannot be released.
The lifetime handoff can be summarized in four steps.
// 1. Create a shared task object
auto task = std::make_shared<std::packaged_task<int()>>([] {
return 42; // Real business result
});
// 2. Capture the shared_ptr by value in the closure
auto wrapper = [task]() {
(*task)(); // After execution, the future can retrieve the result
};
This mechanism is essentially a combination of reference counting, closure ownership, and deferred queue execution, ensuring that no dangling references exist between asynchronous submission and delayed consumption.
std::jthread and stop_token make the thread pool destruction path safer.
Traditional thread pools often fail during destruction: they destroy synchronization primitives first and only then try to stop threads, or they forget to join, eventually causing crashes. std::jthread changes that path.
When workers is destroyed, each jthread automatically requests stop and waits for the thread to finish. That means the destructor can remain defaulted, but only if the member declaration order is correct: the thread container must be declared last so that it is destroyed first.
Cooperative cancellation replaces fragile manual stop flags.
bool ok = condition.wait(lock, st, [this] {
return !tasks.empty(); // Wake up when a task is available
});
if (!ok && tasks.empty()) {
return; // Exit safely after receiving a stop request
}
This waiting logic merges task arrival and thread stopping into a single blocking point, avoiding missed notifications that often occur when maintaining a separate stop flag.
This thread pool is simple enough to use as project-level infrastructure.
Users only need to construct a fixed-size thread pool and submit any callable object. If a task has a return value, future.get() retrieves the execution result synchronously.
Minimal usage example
#include
<iostream>
int main() {
ThreadPool pool(4);
auto future = pool.enqueue([](int x) {
return x * x; // Compute the square
}, 10);
std::cout << "Result: " << future.get() << std::endl; // Block until the result is ready
return 0;
}
This example shows how the thread pool submits a task with a return value and collects the result through a future on the caller side.
The boundaries of this implementation should also be clearly understood.
It already has the shape of a production-ready foundation, but it remains intentionally minimal. It does not include advanced features such as task priorities, rate limiting, queue length control, batch stealing, or work stealing.
If your use case involves general background task dispatching, CPU-bound computation wrappers, or asynchronous server execution, this version is already practical. If you are targeting extreme-throughput scenarios, you should continue by exploring lock-free queues, NUMA affinity, and more advanced scheduling strategies.
FAQ
Q1: Why not store std::packaged_task directly in the thread pool?
A: Because the type of std::packaged_task<R()> depends on the return type R. Different tasks produce different return types, so they cannot be placed directly into the same std::queue. Wrapping them with std::function<void()> enables type erasure.
Q2: Why must the task be wrapped in shared_ptr?
A: Tasks execute asynchronously, and local objects are destroyed after enqueue returns. When a lambda captures the shared_ptr by value, it guarantees that the task stays alive until actual execution, avoiding dangling references.
Q3: Why must workers be declared last?
A: Class members are destroyed in reverse declaration order. If workers is declared last, it is destroyed first. Threads receive the stop request and complete join before the mutex, condition variable, and queue are destroyed, which makes the destruction order safe.
Core summary: This article reconstructs and explains a high-performance thread pool implementation based on C++20. It focuses on std::jthread RAII cleanup, cooperative stopping with stop_token, type erasure with std::function<void()>, and task lifecycle management through shared_ptr + packaged_task. It is well suited for developers building modern C++ asynchronous infrastructure.