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
114: Practice Exercise: Building a Thread-Safe Task Queue
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::queuefrom 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 stoppedflag to the class (initialized tofalse). - Implement a
stop()method that setsstopped = trueand callscv.notify_all()to wake up any sleeping workers. - Modify
wait_and_pop()to return astd::optional<Task>. If the queue is stopped and empty, it should returnstd::nulloptso the worker thread knows it's time to exit its loop. - Ensure the predicate in
cv.waitaccounts for thestoppedflag so the thread wakes up when the queue is stopped, even if no tasks are left.
There are no comments for now.