AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

17 Concurrency / Lesson 6 of 6

Concurrency patterns — pools, reductions and the deterministic-output rule

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

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.

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

  1. Prefer no shared mutable state; then no lock is needed.
  2. Hold one lock at a time; if two, std::scoped_lock or one fixed global order.
  3. Never call unknown code — a callback, a virtual function, a user-supplied comparator — while holding a lock.
  4. Keep critical sections to the update itself; never block inside one (no I/O, no join, no waiting on another thread).
  5. Every waiter has a way out: a stop flag with notify_all, a closed queue, a timeout.
  6. 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

MistakeWhat happens
Pool destructor that stops without drainingPending futures throw broken_promise
Running a task while holding the queue's lockOne worker at a time — a serial program with extra steps
Interleaved split (i, i + t, i + 2t, …) for a numeric loopCache lines shared between threads; slower than contiguous chunks
Reducing doubles through a shared atomicDifferent last bits on different runs
Printing results in completion orderOutput depends on the scheduler
A pool with more CPU-bound workers than coresNo speed-up, more context switches

Key takeaways

  • A thread pool is a queue, a condition variable, w workers and std::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::async for 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.

Mark as readSign in to keep your place.

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

  1. Prefer no shared mutable state; then no lock is needed.
  2. Hold one lock at a time; if two, std::scoped_lock or one fixed global order.
  3. Never call unknown code — a callback, a virtual function, a user-supplied comparator — while holding a lock.
  4. Keep critical sections to the update itself; never block inside one (no I/O, no join, no waiting on another thread).
  5. Every waiter has a way out: a stop flag with notify_all, a closed queue, a timeout.
  6. 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

MistakeWhat happens
Pool destructor that stops without drainingPending futures throw broken_promise
Running a task while holding the queue's lockOne worker at a time — a serial program with extra steps
Interleaved split (i, i + t, i + 2t, …) for a numeric loopCache lines shared between threads; slower than contiguous chunks
Reducing doubles through a shared atomicDifferent last bits on different runs
Printing results in completion orderOutput depends on the scheduler
A pool with more CPU-bound workers than coresNo speed-up, more context switches

Key takeaways

  • A thread pool is a queue, a condition variable, w workers and std::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::async for 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 4

Row 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 1

In this module: Concurrency

← Futures, promises and std::async — results that arrive later · Checkpoint — Concurrency →