C++
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented C++
-
Section 4: Memory Management
-
Section 5: Templates and Generic Programming
-
Section 6: The Standard Template Library
-
Section 7: Modern C++ Features
-
Section 8: Error Handling
-
Section 9: Multithreading
-
Section 10: Operator Overloading and Type Conversion
-
Section 11: Advanced Topics
-
Section 12: Tooling and Build Systems
-
Section 13: Design Patterns in C++
-
Section 14: Interfacing with C and Systems Programming
-
Section 15: Networking and IPC Basics
-
Section 16: Graphics and Game Programming Basics
-
Section 17: The Boost Libraries
-
Section 18: Data Structures and Algorithms in C++
-
Section 19: Practical Projects
-
Section 20: More Concurrency Patterns
-
Section 21: More OOP and Design Practice
-
Section 22: File I/O and Streams
-
Section 23: More Standard Library
-
Section 24: Practice Exercises
-
Section 25: Interview and Algorithm Practice
-
Section 26: Compiler and Language Internals
-
Section 27: GUI and Application Frameworks Overview
-
Section 28: Testing and Quality Practices
-
Section 29: Numerics Library
-
Section 30: Concepts Library (C++20)
-
Section 31: Ranges Library (C++20) In Depth
-
Section 32: More Utility Library
112: Thread Pools
Imagine you're running a busy coffee shop. Every time a customer walks in, you don't go out to the street, hire a brand-new barista on the spot, teach them where the espresso machine is, and then fire them the second the latte is handed over. That would be an absolute nightmare. The time spent hiring and onboarding would take longer than actually making the coffee.
Instead, you hire a fixed team of, say, four baristas. They stay behind the counter all day. When a customer arrives, their order goes onto a ticket rail (a queue). The first available barista grabs the next ticket, makes the drink, and then looks back at the rail for the next task. If there are no orders, they just hang out and wait.
In C++, this is exactly what a Thread Pool does. Spinning up a std::thread isn't free; it requires a syscall to the OS to allocate a stack and manage kernel resources. If you're doing thousands of tiny tasks—like processing pixels in an image or handling incoming network packets—creating a thread for every single one will actually slow your program down due to "thread thrashing."
The High Cost of Constant Hiring
I've seen a lot of developers try to solve concurrency by just throwing threads at the problem in a for loop. While that works for two or three heavy tasks, it falls apart at scale. A thread pool solves this by decoupling the task (the work to be done) from the worker (the thread doing it).
To build this, we need three main components:
- A vector of worker threads that live for the duration of the pool's life.
- A queue of tasks (usually
std::function<void()>) waiting to be executed. - A synchronization mechanism (a mutex and a condition variable) so workers know when a new task has arrived without spinning their CPUs at 100% while idle.
Managing the Order Queue
The trickiest part is making sure the workers don't fight over the queue. We use a std::mutex to protect the queue and a std::condition_variable to put threads to sleep when the queue is empty. I prefer using std::future and std::packaged_task here, because in the real world, you rarely want a task to just "run"—you usually want a result back from it.
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
bool stop = false;
public:
ThreadPool(size_t threads) {
for(size_t i = 0; i < threads; ++i) {
workers.emplace_back([this] {
for(;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock, [this]{
return this->stop || !this->tasks.empty();
});
if(this->stop && this->tasks.empty()) return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
});
}
}
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::invoke_result<F, Args...>::type>
{
using return_type = typename std::invoke_result<F, Args...>::type;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace([task](){ (*task)(); });
}
condition.notify_one();
return res;
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker: workers) worker.join();
}
};
Gracefully Closing Shop
Notice the destructor. You can't just let the pool vanish; you have to tell the workers that the shop is closing (stop = true) and wake them all up (notify_all()). If you don't join the threads, your program will crash with a std::terminate because the threads are still running when the pool object is destroyed. It's the C++ equivalent of locking the doors and making sure everyone has clocked out before you turn off the lights.
One last tip: don't just set your pool size to 100 because you think "more is better." Usually, the sweet spot is the number of hardware cores available on the machine (std::thread::hardware_concurrency()). Adding more threads than cores just forces the OS to context-switch, which brings us right back to the performance overhead we were trying to avoid.
📋 Practical Task
Building a Parallel Heavy-Compute Simulator
To put this into practice, you're going to build a simulation of a batch-processing system. Your goal is to process a large set of "data packets" where each packet requires a simulated heavy calculation (use std::this_thread::sleep_for to mimic a 10-50ms compute task).
Requirements:
- Implement the
ThreadPoolclass as discussed in the lesson. - Create a vector of 100 "work items" (e.g., integers from 1 to 100).
- Enqueue these items into the pool. Each task should take the integer, "process" it (sleep), and return the square of that integer.
- Store the
std::futureobjects returned byenqueuein a vector. - Iterate through your futures to collect and print the results, ensuring that the main thread waits for all tasks to complete.
- Compare the total execution time using a single-threaded loop versus your
ThreadPool(usingstd::chrono).
There are no comments for now.