Graceful Thread Pool Shutdown
Why Shutdown Is Hard
Building a thread pool is straightforward. Stopping it correctly is where the subtle bugs live. You need to answer several questions simultaneously:
- Should pending tasks in the queue execute before workers exit, or be discarded?
- What about tasks that are mid-execution when shutdown is requested?
- How do workers learn that it is time to stop?
- What if a task throws during shutdown?
- Is the destructor safe if
shutdown()was never called?
Getting any of these wrong leads to deadlocks (workers waiting on a condition variable that is never notified), resource leaks (threads never joined), or undefined behavior (destroying a mutex while a thread is blocked on it).
Signaling Strategies
There are two common approaches for telling workers to stop.
Approach 1: Atomic Stop Flag
The pool maintains an std::atomic<bool> stop_ flag. When the controller calls
shutdown(), it sets the flag and notifies all waiting threads.
void shutdown() {
{
std::lock_guard<std::mutex> lock(mtx_);
stop_.store(true);
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
// Worker loop
void worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] {
return !queue_.empty() || stop_.load();
});
if (stop_.load() && queue_.empty()) return;
task = std::move(queue_.front());
queue_.pop();
}
task();
}
}
stop_ && queue_.empty() means
workers drain the queue before exiting. Change it to just stop_ for immediate exit.
Approach 2: Poison Pills
Instead of a flag, push a special “poison pill” task for each worker. When a worker dequeues a poison pill, it exits.
void shutdown() {
// Push one poison pill per worker.
for (std::size_t i = 0; i < workers_.size(); ++i) {
{
std::lock_guard<std::mutex> lock(mtx_);
queue_.push(nullptr); // nullptr = poison pill
}
cv_.notify_one();
}
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
// Worker loop
void worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return !queue_.empty(); });
task = std::move(queue_.front());
queue_.pop();
}
if (!task) return; // poison pill
task();
}
}
Poison pills guarantee that all tasks enqueued before the pills are executed first, providing natural draining. However, they require knowing the exact number of workers and cannot be “cancelled” once enqueued.
Comparison
| Aspect | Atomic Flag | Poison Pill |
|---|---|---|
| Draining behavior | Configurable | Always drains |
| Extra state | One atomic bool | N sentinel tasks |
| Cancellable | Yes (reset flag) | No |
| Complexity | Slightly more logic in wait | Simpler worker loop |
In practice, the atomic flag approach is more flexible and is the standard choice.
Draining the Queue
When you want to ensure all submitted tasks complete before the pool is destroyed, the shutdown sequence is:
- Stop accepting new tasks (reject or throw on new
submit()calls). - Set the stop flag.
- Notify all workers.
- Workers continue popping and executing until the queue is empty, then exit.
- Join all threads.
void shutdown() {
{
std::lock_guard<std::mutex> lock(mtx_);
stop_.store(true);
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
// At this point, the queue is empty and all workers have exited.
}
The critical line in the worker loop is:
if (stop_.load() && queue_.empty()) return;
The worker checks stop_ and queue_.empty(). If the queue is not
empty, it pops another task even though stop has been requested.
submit() after stop_ is set.
Handling In-Flight Tasks
A task that is currently executing on a worker thread cannot be interrupted by the pool. C++ has no
built-in thread cancellation mechanism (unlike Java’s Thread.interrupt()). You have
two options:
Option A: Let Them Finish
This is the default and safest approach. join() blocks until every in-flight task completes
naturally. The total shutdown time is bounded by the longest-running task.
Option B: Cooperative Cancellation
Pass a cancellation token to tasks so they can check periodically:
class CancellationToken {
public:
void cancel() {
cancelled_.store(true, std::memory_order_release);
}
bool is_cancelled() const {
return cancelled_.load(std::memory_order_acquire);
}
private:
std::atomic<bool> cancelled_{false};
};
// Usage inside a task
void long_running_task(CancellationToken& token) {
for (int i = 0; i < 1000000; ++i) {
if (token.is_cancelled()) return;
// ... do work ...
}
}
// On shutdown
void shutdown() {
cancel_token_.cancel(); // signal all tasks
{
std::lock_guard<std::mutex> lock(mtx_);
stop_.store(true);
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
C++20: std::stop_token
C++20 introduced std::stop_token and std::stop_source as a standardized
cooperative cancellation mechanism. If you are on C++20 or later, prefer these over rolling your own:
#include <stop_token>
void task_with_stop(std::stop_token stoken) {
while (!stoken.stop_requested()) {
// ... do work ...
}
}
// The pool holds a std::stop_source and passes
// stop_source_.get_token() to each task.
Exception Safety During Shutdown
What happens if a task throws while the pool is shutting down? The exception is captured by
std::packaged_task and stored in the future. The worker thread itself is not terminated. It
simply moves on to the next task (or exits if the queue is empty and stop_ is set).
However, if you are using raw std::function<void()> without a
packaged_task wrapper, an uncaught exception will call std::terminate(). Always
wrap task execution in a try-catch at the worker level:
void worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] {
return !queue_.empty() || stop_.load();
});
if (stop_.load() && queue_.empty()) return;
task = std::move(queue_.front());
queue_.pop();
}
try {
task();
} catch (const std::exception& e) {
// Log the error. Do NOT rethrow.
// The worker must survive to process
// remaining tasks.
std::cerr << "Task threw: " << e.what()
<< "\n";
} catch (...) {
std::cerr << "Task threw unknown exception\n";
}
}
}
Exceptions in the Destructor
The pool destructor calls shutdown(), which calls join() on each thread.
join() itself does not throw under normal conditions. The destructor should be
noexcept (the default for destructors in C++11 and later), so make sure
shutdown() does not throw either.
~ThreadPool() noexcept {
try {
if (!stop_.load()) shutdown();
} catch (...) {
// Swallow. Destructors must not throw.
}
}
Destructor Behavior
A well-designed pool destructor must handle two scenarios:
- User called
shutdown(). Workers are already joined. The destructor is a no-op. - User did not call
shutdown(). The destructor must shut down the pool itself. This means setting the stop flag, waking all workers, and joining them.
class ThreadPool {
// ...
~ThreadPool() noexcept {
if (!stop_.load(std::memory_order_acquire)) {
shutdown();
}
}
};
Without this guard, destroying the pool while workers reference its members (mutex, condition variable,
queue) is undefined behavior. The std::thread destructor calls std::terminate()
if the thread is still joinable.
Complete Shutdown-Safe Pool
Combining all the pieces, here is a pool that supports both graceful draining and immediate stop, with full exception safety:
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
#include <atomic>
#include <stdexcept>
#include <iostream>
class ThreadPool {
public:
enum class ShutdownPolicy { Drain, Immediate };
explicit ThreadPool(std::size_t n
= std::thread::hardware_concurrency())
: stop_(false), policy_(ShutdownPolicy::Drain)
{
if (n == 0) n = 1;
workers_.reserve(n);
for (std::size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] { run(); });
}
}
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
template <typename F, typename... Args>
auto submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>>
{
using R = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<
std::packaged_task<R()>>(
std::bind(std::forward<F>(f),
std::forward<Args>(args)...));
auto future = task->get_future();
{
std::lock_guard<std::mutex> lock(mtx_);
if (stop_.load()) {
throw std::runtime_error(
"submit on stopped pool");
}
tasks_.push([task] { (*task)(); });
}
cv_.notify_one();
return future;
}
void shutdown(ShutdownPolicy policy
= ShutdownPolicy::Drain) {
{
std::lock_guard<std::mutex> lock(mtx_);
policy_ = policy;
stop_.store(true);
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
~ThreadPool() noexcept {
try {
if (!stop_.load()) shutdown();
} catch (...) {}
}
private:
void run() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] {
return !tasks_.empty() || stop_.load();
});
if (stop_.load()) {
if (policy_ == ShutdownPolicy::Immediate
|| tasks_.empty()) {
return;
}
}
if (tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
try {
task();
} catch (const std::exception& e) {
std::cerr << "[pool] task error: "
<< e.what() << "\n";
} catch (...) {
std::cerr << "[pool] unknown task error\n";
}
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
std::atomic<bool> stop_;
ShutdownPolicy policy_;
};
Testing Shutdown Behavior
#include <cassert>
#include <chrono>
void test_graceful_drain() {
ThreadPool pool(2);
std::atomic<int> counter{0};
for (int i = 0; i < 100; ++i) {
pool.submit([&counter] {
++counter;
std::this_thread::sleep_for(
std::chrono::milliseconds(1));
});
}
pool.shutdown(ThreadPool::ShutdownPolicy::Drain);
assert(counter.load() == 100);
// All 100 tasks completed.
}
void test_immediate_stop() {
ThreadPool pool(2);
std::atomic<int> counter{0};
for (int i = 0; i < 1000; ++i) {
pool.submit([&counter] {
++counter;
std::this_thread::sleep_for(
std::chrono::milliseconds(10));
});
}
pool.shutdown(ThreadPool::ShutdownPolicy::Immediate);
// counter < 1000: some tasks were discarded.
std::cout << "Completed " << counter.load()
<< " of 1000 tasks\n";
}
void test_submit_after_shutdown() {
ThreadPool pool(2);
pool.shutdown();
bool threw = false;
try {
pool.submit([] {});
} catch (const std::runtime_error&) {
threw = true;
}
assert(threw);
}
void test_destructor_without_shutdown() {
std::atomic<int> counter{0};
{
ThreadPool pool(2);
for (int i = 0; i < 50; ++i) {
pool.submit([&counter] { ++counter; });
}
// pool goes out of scope without explicit shutdown
}
assert(counter.load() == 50);
}
int main() {
test_graceful_drain();
test_immediate_stop();
test_submit_after_shutdown();
test_destructor_without_shutdown();
std::cout << "All shutdown tests passed.\n";
return 0;
}
Common Shutdown Bugs
Bug: Notification Before Flag
// WRONG: workers may see the notify but not the flag
cv_.notify_all();
stop_.store(true);
// CORRECT: set flag under lock, then notify
{
std::lock_guard<std::mutex> lock(mtx_);
stop_.store(true);
}
cv_.notify_all();
If notify_all() fires before stop_ is set, workers wake up, see
stop_ == false and the queue empty, and go back to sleep. The subsequent
stop_ = true is never noticed because no further notification arrives.
Bug: Double Shutdown
If shutdown() is called twice, the second call attempts to join() threads that
are already joined, which is undefined behavior. Guard with a check:
void shutdown() {
bool expected = false;
if (!stop_.compare_exchange_strong(expected, true)) {
return; // already shut down
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
Bug: Destroying the Mutex While Blocked
If the destructor runs without joining threads, the std::mutex and
std::condition_variable are destroyed while workers are blocked on them. This is undefined
behavior and typically crashes. Always join all threads before the pool object is destroyed.
Summary
- Use an atomic flag and
notify_all()as the primary signaling mechanism. - Decide between drain (complete pending tasks) and immediate stop (discard pending tasks).
- In-flight tasks cannot be forcibly interrupted; use cooperative cancellation tokens.
- Wrap task execution in try-catch to isolate failures from the pool infrastructure.
- The destructor must join all threads. Guard against double shutdown.
- Set the flag before notifying, under the lock, to avoid lost wakeups.