Thread Pool

Abdullah KhairyAbdullah Khairy
6 min read

This is an explanation of C++ thread pools — what they're used for, why they're needed, and how they work, in a way that teaches the topic step-by-step:


🔧 What Is a Thread Pool?

A thread pool is a group of worker threads (threads that always running and, waiting for tasks) that are created once and then reused to execute multiple tasks.

Think of it like a team of barber shop workers (threads) waiting in a room (the pool). When a new Customer (task) arrives, you assign it to one of the available workers. When the job is done, the worker goes back to the rest room, ready for the next Customer.


🤔 Why Do We Need a Thread Pool?

1. Avoid the Cost of Creating Threads Repeatedly

Creating and destroying threads is expensive (in time and system resources). If your program spawns a new thread for every small task, performance will suffer.

A thread pool solves this by:

  • Creating a fixed number of threads once.

  • Reusing them to do multiple jobs.


2. Efficient Task Management

When many short-lived tasks need to run (e.g., image processing, network handling, or parallel computations), thread pools let you:

  • Manage concurrency easily.

  • Prevent too many threads from overwhelming the system.


3. Better Control of System Resources

If you allow unlimited threads:

  • You may overload the CPU or memory.

  • Your app can crash or slow down.

A thread pool limits the number of threads (e.g., based on CPU cores), giving predictable performance.


🔨 How Does a Thread Pool Work?

Here's the basic idea:

  1. Initialize the Pool

    • Create a fixed number of threads (e.g., 4 or 8).

    • Each thread waits for a task in a queue.

  2. Add Tasks to the Queue usually named enqueue() or submit()

    • When your app has a job (e.g., process a file, handle a request), it puts the job into the task queue.
  3. Worker Threads Pull from the Queue

    • Each thread waits for tasks.

    • When a task appears, a thread grabs it, does the work, then waits for the next one.

  4. Shutdown (Optional)

    • Once all work is done, you can signal threads to stop and clean up.

🧠 Real-World Example Use Cases

  • Web servers handling many requests (each request is a task).

  • Multimedia apps processing frames or audio.

  • Games performing AI updates or physics simulations in parallel.

  • Data processing pipelines (e.g., encoding, filtering).


</> Code Example

  • to the point code thread return void
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>

class ThreadPool {
public:
    // Constructor: initializes the pool with a given number of threads
    ThreadPool(size_t numThreads) : stop(false) {
        for (size_t i = 0; i < numThreads; ++i) {
            // Each worker runs this lambda in its own thread
            workers.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;

                    // Scoped locking to safely access shared queue
                    {
                        std::unique_lock<std::mutex> lock(queueMutex);

                        // Wait until a task is available or pool is stopping
                        condition.wait(lock, [this]() {
                            return stop || !tasks.empty();
                        });

                        // If we're stopping and no tasks remain, exit thread
                        if (stop && tasks.empty())
                            return;

                        // Get the next task from the queue
                        task = std::move(tasks.front());
                        tasks.pop();
                    }

                    // Execute the task
                    task();
                }
            });
        }
    }

    // Add a new task to the pool
    void enqueue(std::function<void()> task) {
        {
            std::unique_lock<std::mutex> lock(queueMutex);

            if (stop)
                throw std::runtime_error("Enqueue on stopped ThreadPool");

            tasks.push(std::move(task));
        }

        // Notify one worker that a task is ready
        condition.notify_one();
    }

    // Destructor: stops the pool and joins all threads
    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            stop = true;
        }

        // Wake up all threads so they can exit
        condition.notify_all();

        // Join all threads (wait for them to finish)
        for (std::thread &worker : workers)
            worker.join();
    }

private:
    std::vector<std::thread> workers;                  // Thread pool
    std::queue<std::function<void()>> tasks;           // Task queue
    std::mutex queueMutex;                             // Protects task queue
    std::condition_variable condition;                 // Signals available work
    std::atomic<bool> stop;                            // Stop flag
};
  • advanced code
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>
#include <future>
#include <atomic>

class ThreadPool {
private:
    std::vector<std::thread> workers; // The worker threads
    std::queue<std::function<void()>> tasks; // Task queue (FIFO)

    std::mutex queue_mutex; // Protects access to the queue
    std::condition_variable cv; // Notifies worker threads of new tasks
    std::atomic<bool> stop{false}; // Flag to indicate shutdown

public:
    // Constructor: create worker threads
    ThreadPool(size_t num_threads) {
        for (size_t i = 0; i < num_threads; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;

                    // Acquire task from the queue
                    {
                        std::unique_lock<std::mutex> lock(queue_mutex);

                        // Wait until either:
                        // - there's a task available
                        // - OR the pool is stopping
                        cv.wait(lock, [this] {
                            return !tasks.empty() || stop;
                        });

                        // Exit if pool is stopping AND no tasks left
                        if (stop && tasks.empty())
                            return;

                        // Take the next task from the queue
                        task = std::move(tasks.front());
                        tasks.pop();
                    }

                    // Execute the task (outside the lock)
                    task();
                }
            });
        }
    }

    // Adds a new task to the thread pool and returns a future result
    // 'enqueue' takes a callable 'f' and any number of arguments 'args...',
    // deduces the return type of invoking 'f(args...)',
    // and returns a std::future that will eventually hold the result.
    template <typename F, typename... Args>
    auto enqueue(F&& f, Args&&... args) -> std::future<decltype(f(args...))> {
        // Deduce the return type of the callable 'f' when invoked with arguments 'args...'
        // Example: If f is a function returning int, return_type becomes int
        using return_type = decltype(f(args...));

        // Wrap the task and arguments into a packaged_task
        // Create a packaged_task to wrap the callable and its arguments:
        // 1. std::bind + perfect forwarding preserves argument value categories (lvalue/rvalue)
        // 2. Converts f(args...) to a nullary function (no-argument function)
        // 3. Wrapped in shared_ptr for lifetime management across threads
        auto task = std::make_shared<std::packaged_task<return_type()>>(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );
        // Obtain a future linked to the packaged_task's result:
        // - The future/promise pair shares a state
        // - Task stores result/exception in shared state when executed
        // - res.get() will block until result is available
        std::future<return_type> res = task->get_future(); // Get the associated future

        {
            std::unique_lock<std::mutex> lock(queue_mutex);

            // If pool is stopped, throw an exception
            if (stop)
                throw std::runtime_error("Enqueue on stopped ThreadPool");

            // Add the task to the queue (as a void() function)
            tasks.emplace([task]() { (*task)(); });
        }

        // Notify one worker that there's a new task
        cv.notify_one();
        return res; // Return the future so the caller can get the result later
    }

    // Destructor: clean up the threads and shutdown the pool
    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true; // Signal to stop
        }

        cv.notify_all(); // Wake up all threads

        // Join all worker threads
        for (std::thread& worker : workers) {
            if (worker.joinable())
                worker.join();
        }
    }
};

// Example usage
int main() {
    ThreadPool pool(4); // Create a pool with 4 worker threads
    std::vector<std::future<int>> results;

    // Submit 8 tasks to the pool
    for (int i = 0; i < 8; ++i) {
        results.emplace_back(pool.enqueue([i] {
            std::cout << "Task " << i << " executed by thread "
                      << std::this_thread::get_id() << std::endl;

            std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Simulate work
            return i * i; // Return square of i
        }));
    }

    // Retrieve results from futures (this blocks until each task is done)
    for (auto&& result : results) {
        std::cout << "Result: " << result.get() << std::endl;
    }

    return 0;
}
10
Subscribe to my newsletter

Read articles from Abdullah Khairy directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdullah Khairy
Abdullah Khairy