Skip to Content
Course content

114: Practice Exercise: Building a Thread-Safe Task Queue

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Alright, let's get our hands dirty. We've talked about the theory of mutexes and condition variables, but they don't really "click" until you try to build something that actually needs them. Today, we're building a thread-safe task queue. Imagine we're writing a background logger or a job system for a game engine; we want to be able to throw a function into a queue from any thread and have a dedicated worker thread chew through them one by one.

The "Naive" Synchronized Queue

My first instinct is usually to just wrap a std::queue in a class and slap a std::mutex around every method. It seems straightforward. Let's see what happens when I try to implement a basic pop method that returns a task.

#include <queue>
#include <mutex>
#include <functional>
#include <optional>

using Task = std::function<void()>;

class TaskQueue {
    std::queue<Task> tasks;
    std::mutex mtx;
public:
    void push(Task task) {
        std::lock_guard<std::mutex> lock(mtx);
        tasks.push(std::move(task));
    }

    std::optional<Task> pop() {
        std::lock_guard<std::mutex> lock(mtx);
        if (tasks.empty()) return std::nullopt;
        
        Task task = std::move(tasks.front());
        tasks.pop();
        return task;
    }
};

This looks correct, right? It's thread-safe. But here is where I hit a wall. If I create a worker thread that looks like this: while(running) { auto task = queue.pop(); if(task) (*task)(); }, I'm in trouble. I just checked my system monitor, and that worker thread is pinning a CPU core to 100%. Why? Because it's "busy-waiting." It's asking the queue "Got anything? No? How about now? Now?" millions of times per second.

Stopping the CPU Burn

I need the worker thread to actually sleep until there is work to do. This is exactly what a std::condition_variable is for. It allows a thread to give up its time slice and stay dormant until another thread wakes it up. I'll add one to the class and change the pop logic. I'll call it wait_and_pop to make it clear that this is a blocking call.

#include <condition_variable>

class TaskQueue {
    std::queue<Task> tasks;
    std::mutex mtx;
    std::condition_variable cv;
public:
    void push(Task task) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            tasks.push(std::move(task));
        }
        cv.notify_one(); // Wake up one waiting worker
    }

    Task wait_and_pop() {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock); // Wait here until notified
        
        Task task = std::move(tasks.front());
        tasks.pop();
        return task;
    }
};

Now the CPU usage drops to 0% when the queue is empty. Victory, right? Not quite. If I run this for a while, I might occasionally hit a crash or a weird state. I'm running into "spurious wakeups." It's a quirk of how operating systems handle threads; sometimes a thread wakes up from a condition variable even though nobody called notify.

The Predicate Fix

If the thread wakes up spuriously and I immediately call tasks.front() on an empty queue, the program will crash. I can't just trust that the wake-up call means there is data. I need to verify the condition again. I could put the cv.wait inside a while loop, but C++ gives us a cleaner way: the predicate overload of wait().

I'll change the wait line to this:

cv.wait(lock, [this]{ return !tasks.empty(); });

This tells the thread: "Wake up if notified, but if you wake up and the queue is still empty, go right back to sleep." It's a compact way of writing a while loop around the wait. Now the logic is robust. We have a queue that is safe to access from multiple threads, doesn't waste CPU cycles, and handles the weirdness of OS thread scheduling.

  • The Mutex: Protects the std::queue from data races.
  • The Condition Variable: Prevents busy-waiting.
  • The Predicate: Guards against spurious wakeups.



📋 Practical Task

Exercise: Implementing a Graceful Shutdown for the Task Queue

Right now, our wait_and_pop method blocks forever. If your main program wants to shut down, the worker thread will stay stuck in cv.wait(), and your application will hang indefinitely.

Your task is to modify the TaskQueue class to support a graceful shutdown. Implement the following:

  • Add a bool stopped flag to the class (initialized to false).
  • Implement a stop() method that sets stopped = true and calls cv.notify_all() to wake up any sleeping workers.
  • Modify wait_and_pop() to return a std::optional<Task>. If the queue is stopped and empty, it should return std::nullopt so the worker thread knows it's time to exit its loop.
  • Ensure the predicate in cv.wait accounts for the stopped flag so the thread wakes up when the queue is stopped, even if no tasks are left.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.