C++ Thread Pool Implementation and Concurrency Patterns
How to write a thread pool in C++ with a queue, a condition variable and packaged tasks, plus split-and-reduce, message passing and rules that prevent deadlock.
- Course: C++ study plan
- Module: Concurrency
- Kind: Lesson
- Reading time: 15 min
- Runtime: Clang 18 · C++20
How do you implement a thread pool in C++?
A C++ thread pool starts a fixed number of worker threads once. Each worker loops, waiting on a std::condition_variable for the next task in a mutex-guarded queue and running it outside the lock. submit wraps the caller's function in a std::packaged_task and returns its std::future; the destructor sets a stop flag, calls notify_all, lets the workers drain the queue, and joins them.
Lesson
The earlier lessons gave the primitives; this one gives the shapes they are combined into, because production concurrency is a handful of patterns applied carefully rather than clever synchronisation. A thread pool turns "a thread per task" into "a queue and a fixed number of workers". Work splitting and reduction is the shape of every parallel loop. Immutable data and message passing remove most locks by removing the sharing. A short list of rules avoids deadlock in practice. The lesson ends with the interview answer to "threads or async?" and the checklist that keeps a judged concurrent program deterministic.
A minimal thread pool
Starting a thread per task is expensive and unbounded. A pool starts w workers once; each loops taking the next task from a queue guarded by a mutex and a condition variable; submit wraps the caller's function in a std::packaged_task, keeps the future for the caller, and pushes the task; the destructor flips a stop flag, wakes everyone and joins. The version below runs tasks that return long long; a generic submit template is the same code with std::invoke_result_t for the return type.
#include <condition_variable>
#include <deque>
#include <functional>
#include <future>
#include <mutex>
#include <thread>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(int workers) {
for (int i = 0; i < workers; ++i) threads_.emplace_back([this] { run(); });
}
~ThreadPool() {
{ std::lock_guard<std::mutex> lock(m_); stopping_ = true; }
cv_.notify_all();
for (auto& t : threads_) t.join();
}
std::future<long long> submit(std::function<long long()> f) {
std::packaged_task<long long()> task(std::move(f));
std::future<long long> result = task.get_future();
{ std::lock_guard<std::mutex> lock(m_); tasks_.push_back(std::move(task)); }
cv_.notify_one();
return result;
}
private:
void run() {
while (true) {
std::packaged_task<long long()> task;
{
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (tasks_.empty()) return; // stopping and drained
task = std::move(tasks_.front());
tasks_.pop_front();
}
task(); // outside the lock
}
}
std::vector<std::thread> threads_;
std::deque<std::packaged_task<long long()>> tasks_; // guarded by m_
std::mutex m_;
std::condition_variable cv_;
bool stopping_ = false;
};
Three details carry the design. The task runs outside the lock, so workers overlap. The destructor drains the queue before the workers exit — return only when tasks_ is empty — because a task thrown away would leave its future with a broken_promise. And the futures come back in submission order, so for (auto& f : results) f.get() yields results in the order the tasks were submitted, whatever order the workers finished them. Real pools add a generic submit, priorities and per-thread queues; the skeleton is what interviews ask for.
Work splitting and reduction
A parallel loop has two halves. Split: give each thread a contiguous chunk — [i * n / t, (i + 1) * n / t) — rather than every t-th element, because a contiguous chunk walks memory in order and stays in one cache line at a time. Static chunks are right when every element costs the same; when costs vary (primality tests, path searches) a dynamic split hands out the next index or the next block through next.fetch_add(1), so a fast thread takes more. Reduce: each thread accumulates into a local; after the join, the main thread combines the locals in index order. For integers, addition, counting, minimum and maximum are associative and commutative, so the result is the same whichever thread got which chunk. Floating-point addition is not, which is why a parallel double sum is reduced in a fixed order on the main thread and never through a shared atomic.
Immutable data and message passing
Every lock in this module guards mutation. Data that no thread writes needs no lock: a const std::vector<int>& read by eight threads is safe, and so is a lookup table built before the first thread starts. The design move is to make as much as possible immutable while threads run — build the input, freeze it, hand out slices — and to give each thread exclusive ownership of its output. When threads must talk, prefer passing messages through a queue to sharing a structure: a producer moves an item into a BoundedQueue, the consumer moves it out, and at no moment do two threads hold the same object. That is the discipline Go's slogan describes — share memory by communicating — and it is available in C++ with std::move and a queue.
Deadlock avoidance rules
- Prefer no shared mutable state; then no lock is needed.
- Hold one lock at a time; if two,
std::scoped_lockor one fixed global order. - Never call unknown code — a callback, a virtual function, a user-supplied comparator — while holding a lock.
- Keep critical sections to the update itself; never block inside one (no I/O, no
join, no waiting on another thread). - Every waiter has a way out: a stop flag with
notify_all, a closed queue, a timeout. - Join every thread in a known place; a thread nobody joins is a thread that can still be running when its data is destroyed.
Threads or async? The interview answer
std::thread (or std::jthread) is a resource: you own it, join it, and communicate through memory you arrange yourself. std::async with a future is a result: fire a function, collect its value or its exception, no join to write. Use threads for long-lived workers with a lifecycle — a pool, a consumer loop, a server — and std::async for a fixed set of independent computations whose answers you want back. In both cases the safe shape is the same: split, compute privately, reduce on one thread. The follow-up questions are about primitives, and the one-line answers are: a data race is two unordered accesses with a write and is undefined behaviour; a mutex protects a compound update, an atomic a single one; a condition variable waits for a state change without spinning; join synchronises, so after it the main thread sees everything the worker wrote.
The deterministic-output rule for judged programs
The judge runs your program once per case and compares stdout byte for byte, so anything that varies with scheduling fails the case. The checklist:
- Threads write into their own slots or return through futures; nothing prints until every thread has been joined.
- Print in index order, or in submission order for futures — never in completion order.
- Print aggregates that do not depend on interleaving: sums, counts, maxima, sorted merges.
- Never print
hardware_concurrency(),std::this_thread::get_id(), a duration, a queue's high-water mark or "which thread did it". - Reduce floating point on the main thread in a fixed order.
- Every worker must exit: close queues, set stop flags,
notify_all, join.
Pitfalls
| Mistake | What happens |
|---|---|
| Pool destructor that stops without draining | Pending futures throw broken_promise |
| Running a task while holding the queue's lock | One worker at a time — a serial program with extra steps |
Interleaved split (i, i + t, i + 2t, …) for a numeric loop | Cache lines shared between threads; slower than contiguous chunks |
Reducing doubles through a shared atomic | Different last bits on different runs |
| Printing results in completion order | Output depends on the scheduler |
| A pool with more CPU-bound workers than cores | No speed-up, more context switches |
Key takeaways
- A thread pool is a queue, a condition variable,
wworkers andstd::packaged_task; futures return in submission order. - Split into contiguous chunks (or hand out indices with
fetch_add), compute into locals, reduce on the main thread in a fixed order. - Immutable input needs no lock; give each thread its own output; pass messages instead of sharing structures.
- Deadlock is avoided by discipline: one lock at a time, fixed order, no unknown code under a lock, every waiter has an exit.
- Threads for long-lived workers with a lifecycle;
std::asyncfor independent results with an error path. - Judged output: join everything, print in index order, aggregates only, nothing that depends on scheduling.
Common questions
Should I use std::thread or std::async?
Use std::thread or std::jthread for long-lived workers with a lifecycle, such as a pool, a consumer loop or a server: you own them, join them and arrange how they communicate. Use std::async for a fixed set of independent computations whose results, or exceptions, you want back through futures.
How do you split work across threads in C++?
Give each thread a contiguous chunk, [i * n / t, (i + 1) * n / t), so it walks memory in order, and let it accumulate into a local. After joining, combine the locals on the main thread in index order. When element costs vary, hand out indices dynamically with next.fetch_add(1) instead.
Why is a parallel floating-point sum different on each run?
Floating-point addition is not associative, so adding the same partial sums in a different order can change the last bits of the result. Keep each thread's partial and add them on the main thread in a fixed order, never through a shared std::atomic<double>.
What does "share memory by communicating" mean?
It means passing data between threads as messages through a queue instead of sharing a mutable structure: a producer moves an item into the queue and a consumer moves it out, so no two threads ever hold the same object at once. In C++ that is std::move plus a thread-safe queue.
Exercises
A thread pool
Complete the lesson's ThreadPool. submit(std::function<long long()>) wraps the function in a std::packaged_task<long long()>, takes its future, pushes the task onto the queue under the mutex, notifies one worker and returns the future. run() — the body of every worker — loops: wait under a std::unique_lock until stopping or a task is available; return only when stopping and the queue is drained (a task thrown away would break its future's promise); otherwise move the front task out, unlock, and run it outside the lock so workers overlap. The constructor and destructor are given.
Read w (workers, 1–4) and n, then n integers k. Submit one task per k computing how many primes are ≤ k (count_primes is given), keep the futures in a vector, and print task <i>: <count> by calling get() in submission order — deterministic whatever order the workers finished. End with done <n>.
Input: w n, then n integers (each at most 100000). Output: n + 1 lines.
2 4
10 100 1 30
→
task 0: 4
task 1: 25
task 2: 0
task 3: 10
done 4Row sums over an immutable grid
Read t, R and C, then an R × C grid of integers into a std::vector<std::vector<long long>> that is never written once the threads start — pass it to them as const. Split the rows into t contiguous chunks. Each thread writes the sum of each of its rows into its own slots of a pre-sized rowSums vector, and adds its chunk's total into a std::atomic<long long> grand with one fetch_add after its loop. After joining, print row <r>: <sum> for every row, total <grand>, and widest row <r> for the row with the largest sum (the first on a tie), found on the main thread.
Input: t R C, then R lines of C integers. Output: R + 2 lines.
2 3 3
1 2 3
4 5 6
-1 -2 -3
→
row 0: 6
row 1: 15
row 2: -6
total 15
widest row 1In this module: Concurrency
- Threads — std::thread, std::jthread and the join rule
- Mutexes and locks — making shared writes safe
- Atomics — lock-free counters and compare-exchange
- Condition variables — waiting without spinning
- Futures, promises and std::async — results that arrive later
- Concurrency patterns — pools, reductions and the deterministic-output rule (this lesson)
- Checkpoint — Concurrency
← Futures, promises and std::async — results that arrive later · Checkpoint — Concurrency →