Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 81

Worker Thread Implementation

Recommended resources

Hello! Welcome to the next step in building our thread pool.

In the last lesson, we built a ThreadSafeQueue, a crucial component for any concurrent system. We focused on the synchronization primitives—std::mutex and std::condition_variable—that allow a "consumer" thread to wait efficiently for a "producer" thread to provide data. This producer/consumer pattern is the exact model our thread pool uses.

Today, we'll implement the "consumers"—the worker threads themselves. Our learning outcome is to implement the worker thread logic, including the main loop for fetching and executing tasks from the queue. This is where the work of the thread pool actually gets done. We will create the threads, give them their core logic, and ensure they can be shut down gracefully.

The Anatomy of a Worker Thread

A worker thread in a thread pool has a simple but critical set of responsibilities. It lives inside a loop, where it:

  1. Waits for a task to appear in the shared queue.
  2. Fetches the task from the queue.
  3. Executes the task.
  4. Repeats this process until the thread pool is instructed to shut down.

Let's look at a well-structured example of a thread_pool class that contains this logic.

Building a Thread Pool with C++ and STL

The article 'Building a Thread Pool with C++ and STL' by 'nixiz' provides a concise and effective implementation that we will use as our guide. It integrates the task queue and synchronization primitives directly into the thread_pool class, which is a common and efficient design.

Read the code block that defines the thread_pool class. Pay close attention to its member variables (is_active, pool, cv, guard, pending_jobs) and the overall structure, especially the constructor and destructor.

As you saw, the class contains all the necessary components:

  • std::atomic_bool is_active: A flag to signal when the pool should shut down. We use std::atomic to prevent data races when the main thread modifies it while worker threads are reading it.
  • std::vector<std::thread> pool: Stores the worker threads themselves.
  • std::deque<std::packaged_task<void()>> pending_jobs: Our task queue. We're using std::packaged_task<void()> because, as we'll see in the next lesson, it's the key to handling return values via futures. For now, think of it as a wrapper for a function that can be executed.
  • std::mutex guard and std::condition_variable cv: The same synchronization primitives we used in our ThreadSafeQueue last lesson, now as members of the thread_pool.

Now, let's dive into the most important part: the function that the worker threads execute.


The Worker's Main Loop: The run() Method

Each thread in the pool will be executing the run() member function. This function contains the infinite loop that defines the thread's lifecycle.

Building a Thread Pool with C++ and STL

Let's analyze the run() method from the same article. This is the heart of our worker thread's logic.

Focus on the implementation of the run() method and the author's explanation. We'll break it down line-by-line.

The run() method is a masterclass in correct concurrent design. Let's dissect it:

void run() noexcept 
{
    while (is_active) // 1. Main loop
    {
        thread_local std::packaged_task<void()> job; // 2. Task storage
        {
            std::unique_lock lock(guard); // 3. Acquire lock

            // 4. Wait for a condition
            cv.wait(lock, [&]{ return !pending_jobs.empty() || !is_active; });

            // 5. Shutdown check
            if (!is_active && pending_jobs.empty()) {
                // A check to exit if shutdown is signaled AND the queue is empty.
                // The original resource had a simpler 'if (!is_active) break;',
                // this version is slightly more robust to ensure all tasks are drained on shutdown.
                break;
            }

            // 6. Fetch the job
            job = std::move(pending_jobs.front());
            pending_jobs.pop_front();
        } // 7. Release lock

        // 8. Execute the job
        job();
    }
}
  1. while (is_active): The loop continues as long as the pool is active.
  2. thread_local std::packaged_task<void()> job;: This creates a variable job that is local to each specific thread. Using thread_local can be a minor optimization in some cases, but a simple local variable (std::packaged_task<void()> job;) inside the loop would also work perfectly well.
  3. std::unique_lock lock(guard);: We acquire the lock to safely inspect and modify the shared task queue (pending_jobs).
  4. cv.wait(lock, ...): This is the most critical line. The thread will sleep here if the queue is empty.
    • The Predicate: The lambda [&]{ return !pending_jobs.empty() || !is_active; } is the condition for waking up. The thread will only proceed if there's a job in the queue OR if the pool has been deactivated. This second part is vital; without !is_active, threads would sleep forever on an empty queue during shutdown.
    • Recalling OSTEP: As we know from OSTEP and our last lesson, this pattern of re-checking a condition in a loop (while) is essential due to "Mesa semantics" and to protect against spurious wakeups. The cv.wait with a predicate handles this loop for us.
  5. Shutdown Check: After waking up, we explicitly check if the shutdown was signaled (!is_active). If it was, and the queue is empty, the thread's work is done, and it breaks the loop.
  6. Fetch the Job: We move the task from the queue into our local job variable. This is an efficient way to transfer ownership.
  7. Release Lock: The unique_lock goes out of scope here, releasing the mutex. This is a crucial step.
  8. job();: We execute the task after releasing the lock. This minimizes the time the lock is held, maximizing concurrency. If we held the lock during execution, only one thread in the entire pool could ever be running a task at a time, defeating the purpose of the pool!

Spawning and Joining: The Pool's Lifecycle

Now that we have the worker's logic, let's see how the thread_pool class manages the threads' lifecycles in its constructor and destructor.

Constructor: Bringing Threads to Life

explicit thread_pool(int num_threads = std::thread::hardware_concurrency()) 
{
    for (int i = 0; i < num_threads; i++) {
        pool.emplace_back(&thread_pool::run, this);
    }
}
  • A loop runs num_threads times. std::thread::hardware_concurrency() is a handy utility that returns an estimate of the number of concurrent threads supported by the hardware.
  • pool.emplace_back(&thread_pool::run, this); does two things:
    1. It constructs a new std::thread object directly inside the pool vector.
    2. It tells the new thread to execute the run method (&thread_pool::run) on the current instance of our class (this). This is how a member function is passed as the thread's entry point.

As soon as std::thread is constructed, the new OS thread begins executing the run() method concurrently.

Destructor: Ensuring a Graceful Shutdown

When the thread_pool object is destroyed, we can't just let the OS terminate the threads. That could leave tasks half-finished and resources in a corrupt state. We must perform a graceful shutdown.

~thread_pool() {
    is_active = false;      // 1. Signal shutdown
    cv.notify_all();        // 2. Wake up all threads
    for (auto& th : pool) { // 3. Join all threads
        th.join();
    }
}
  1. is_active = false;: We set our atomic flag. This signals to any running or future loop iterations in run() that it's time to stop.
  2. cv.notify_all();: This is critical. Some threads might be sleeping in cv.wait(), waiting for tasks. This call wakes up all of them. Once woken, they will re-evaluate their wait predicate (!pending_jobs.empty() || !is_active), see that is_active is now false, and proceed to exit their loops.
  3. th.join();: The join() call blocks the main thread (the one executing the destructor) until the corresponding worker thread has completely finished its execution (i.e., its run() function has returned). By joining every thread in the pool, we guarantee that all workers have exited cleanly before the thread_pool's member variables (like the queue) are destroyed.

Under the Hood: The wait and signal Mechanism

Since you're keen on OS internals, let's visualize how std::condition_variable maps to the underlying POSIX thread primitives you might have seen in your C studies. The wait/signal dance is a well-defined choreography.

Pthread Condition Variable Usage & Timeline
This timeline illustrates the interaction between a waiting and a signaling thread using the underlying POSIX API. The waiting thread (top) locks a mutex, then calls `pthread_cond_wait`, which atomically unlocks the mutex and puts the thread to sleep. The signaling thread (bottom) locks the same mutex, calls `pthread_cond_signal`, and unlocks. The signal wakes the waiting thread, which then re-acquires the lock before continuing.

This diagram perfectly shows the atomic "unlock-and-sleep" of wait and the "lock-signal-unlock" pattern of the producer. The internal implementation of std::condition_variable on Linux relies on these pthread functions, which in turn use futex system calls to communicate with the kernel scheduler to efficiently block and wake threads.

Test your understanding!

In the destructor, why is it essential to call cv.notify_all() instead of just calling cv.notify_one() in a loop?

Show answer

If we only called cv.notify_one(), we would wake up just one sleeping thread. That thread would see is_active is false, exit its loop, and terminate. The other N-1 threads would remain asleep, blocked on the condition variable. The main thread would then call join() on one of these sleeping threads and block forever, resulting in a deadlock. cv.notify_all() ensures that all threads currently blocked in cv.wait() are woken up simultaneously so they can all observe the is_active == false condition and terminate gracefully.


Conclusion

Today we've assembled the engine of our thread pool. We implemented the worker threads' main loop, giving them the logic to efficiently wait for, fetch, and execute tasks. We also built the critical startup and shutdown machinery in the thread_pool's constructor and destructor, ensuring threads are managed correctly throughout the pool's lifecycle.

Key Takeaways:

  • Worker threads are typically implemented as an infinite loop that is broken by a shutdown signal.
  • The std::condition_variable::wait method is the key to efficient blocking, and its predicate is crucial for handling both task availability and shutdown signals (!queue.empty() || !is_active).
  • Graceful shutdown is a three-step process: signal (atomic_flag = false), wake (cv.notify_all()), and wait (thread.join()).
  • Tasks should always be executed after releasing the lock on the shared queue to maximize concurrency and prevent deadlocks.

Next Lesson Preview:

Our thread pool can now execute tasks of type std::packaged_task<void()>, but we have no way to get a return value back from a task. How can the main thread submit a function that calculates a value and get that value back later? In our next lesson, we will implement the public-facing post() or submit() function for our thread pool and explore how to use std::packaged_task in combination with std::future to create a powerful mechanism for asynchronous task execution with return values.