Skip to main content

Welcome to a Grasp lesson

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

Lesson 41

Building a Safe Worker Thread Loop

Recommended resources

Hello! Welcome back to our course on building a thread pool in C.

In our last lesson, we designed the complete architectural blueprint for our thread pool. We defined the threadpool_t manager struct and the thread_task_t struct, which together provide the skeleton for our implementation. We now have a clear plan for the data structures that will hold the tasks, threads, and synchronization primitives.

Today, we will build the engine of our thread pool: the worker thread's execution loop. This is the code that each thread in the pool will run continuously, allowing it to pick up and execute work. By the end of this lesson, you will be able to implement this loop, ensuring that threads can safely fetch tasks from the queue and, most importantly, wait efficiently when no tasks are available.

The Life of a Worker Thread

A worker thread's job is to run in a perpetual loop, performing these steps:

  1. Check the task queue for work.
  2. If a task exists, take it from the queue.
  3. Execute the task.
  4. Repeat.

The most interesting challenge lies in step 1. What should a thread do if the queue is empty?


The Problem of Idle Threads: Busy-Waiting

A naive implementation might look like this:

// PSEUDOCODE - DO NOT USE
while (1) {
    pthread_mutex_lock(&queue_mutex);
    if (queue is not empty) {
        task = dequeue_task();
        pthread_mutex_unlock(&queue_mutex);
        execute(task);
    } else {
        pthread_mutex_unlock(&queue_mutex);
        // What to do here? Loop again?
    }
}

If a thread finds the queue empty, it unlocks the mutex and immediately loops back to lock it and check again. This is called busy-waiting or spinning.

How to write a multithreaded webserver using condition variables (Part 3)

To understand why busy-waiting is a problem, let's watch a short clip from Jacob Sorber. He explains the negative impact this has on CPU utilization.

Watch from 0:45 to 1:11. The key takeaway is that constantly checking the queue, even when it's empty, wastes CPU cycles for no reason.

As the video explains, busy-waiting can max out your CPU cores, even when your application is doing no useful work. This is highly inefficient and detrimental to overall system performance. A common but flawed "fix" is to add a sleep() call in the else block, but this introduces latency and creates a clumsy trade-off between responsiveness and CPU usage.


The Efficient Solution: Condition Variables

The correct way to handle an empty queue is to have the worker thread go to sleep until new work is available. This is precisely the job of condition variables (pthread_cond_t), which we included in our threadpool_t design.

A condition variable allows threads to wait for a specific condition to become true (e.g., "the task queue is no longer empty") without consuming CPU. Another thread (the producer) can then "signal" the condition variable to wake up one of the sleeping threads when it adds a new task.

This diagram illustrates the fundamental interaction. Our worker thread is the "Processing Thread," and the thread adding tasks is the "Signaling Thread."

Pthread Condition Variable Sequence Diagram
This sequence diagram shows a thread waiting on a condition variable (`pthread_cond_wait`) and being woken up by another thread's signal (`pthread_cond_signal`). Notice how the mutex is locked before waiting and is atomically released during the wait, then re-locked upon waking.

Implementing the Worker's Execution Loop

The core logic of the worker function revolves around pthread_cond_wait. This function must be called while a mutex is locked. When called, it atomically performs three steps:

  1. Unlocks the mutex.
  2. Puts the calling thread to sleep.
  3. When woken by a signal, it waits to re-acquire the mutex before returning.

This atomic release-and-wait is crucial. If the mutex were not released, no other thread could lock it to add a task to the queue, leading to a deadlock.

Let's look at a complete, production-quality implementation.

Thread Pool in C | John's Blog

The blog post 'Thread Pool in C' provides a superb, well-commented implementation of the worker thread function. Let's analyze it to see how these concepts are put into practice.

Please read the section titled 'The Worker Function' and study the tpool_worker C code. The author provides an excellent breakdown of the function's logic. Pay close attention to these four key points: The outer while (1) loop that keeps the thread alive. The inner while (tm->work_first == NULL && !tm->stop) loop. Why is pthread_cond_wait inside a while loop and not just an if statement? The exact point where the mutex is unlocked (pthread_mutex_unlock) relative to where the task is executed (work->func(work->arg)). The logic for checking the stop flag to allow for a graceful shutdown.


Deconstructing the Worker Function

Let's consolidate the key patterns from that reading into the context of our own threadpool_t structure.

void* threadpool_worker(void* arg) {
    threadpool_t *pool = (threadpool_t*)arg;
    thread_task_t *task;

    while (1) {
        // 1. Lock the mutex to safely access shared data
        pthread_mutex_lock(&pool->lock);

        // 2. Wait in a loop while the queue is empty and the pool is active.
        //    This handles "spurious wakeups".
        while (pool->task_count == 0 && !pool->shutdown) {
            pthread_cond_wait(&pool->notify, &pool->lock);
        }

        // 3. If shutdown is signaled, exit the thread.
        //    The 'if' is checked after the wait loop.
        if (pool->shutdown) {
            pthread_mutex_unlock(&pool->lock);
            pthread_exit(NULL);
        }

        // 4. A task is available. Dequeue it.
        task = pool->head;
        pool->head = pool->head->next;
        if (pool->head == NULL) {
            pool->tail = NULL;
        }
        pool->task_count--;

        // 5. Unlock the mutex. The critical section is now over.
        pthread_mutex_unlock(&pool->lock);

        // 6. Execute the task. This happens outside the lock.
        (task->function)(task->argument);
        
        // 7. Clean up the task memory.
        free(task);
    }

    return NULL;
}

Two points from this implementation are absolutely critical for both correctness and performance:

  1. The Wait-in-a-Loop Pattern: We use while (condition) { pthread_cond_wait(...) } instead of if (condition). This is because a thread can occasionally wake up from pthread_cond_wait for reasons other than a direct signal (a "spurious wakeup"). By re-checking the condition in a while loop, we ensure the thread only proceeds when there is genuinely work to do.

  2. Minimizing Lock Hold Time: The mutex is unlocked before the task is executed. This is the key to concurrency. If the lock were held during task execution, only one thread could work at a time, rendering the thread pool useless and effectively making it a sequential processor.

Test your understanding!

In the threadpool_worker function above, what would be the performance consequence of moving pthread_mutex_unlock(&pool->lock); to after the (task->function)(task->argument); call?

Show answer

If the mutex were held during the task execution, the entire thread pool would be serialized. While one thread is executing a task, no other worker thread could acquire the lock to fetch the next task from the queue. The threads would execute tasks one by one, not in parallel. This would completely defeat the purpose of the thread pool, and performance would be no better (and likely worse, due to synchronization overhead) than a single-threaded implementation.


Conclusion

In this lesson, we have constructed the most complex and critical part of our thread pool: the worker's execution loop. We've replaced an inefficient busy-waiting approach with an elegant and efficient solution using condition variables.

Key Takeaways:

  • A worker thread's life is a perpetual loop of fetching and executing tasks.
  • Busy-waiting (spinning) while the task queue is empty is extremely wasteful of CPU resources.
  • pthread_cond_wait provides an efficient mechanism for threads to sleep until a condition is met.
  • To guard against spurious wakeups, pthread_cond_wait must always be called inside a while loop that re-checks the condition.
  • The critical section protected by the mutex must be as short as possible. Specifically, task execution must happen after the mutex has been unlocked to enable true parallelism.

Preview of the Next Lesson:
Our worker threads are now ready and waiting for work. In the next lesson, we will implement the "producer" side of the equation: the threadpool_add_task() function. This will be the public-facing function that allows a client to submit a new task to the pool, adding it to the queue and signaling one of our waiting worker threads to wake up and get to work.