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
179: Building a Thread-Safe Producer-Consumer Pipeline
I've spent a lot of time reviewing concurrent code, and there is one pattern that pops up constantly in junior-to-mid-level PRs: the "Lock-and-Poll" loop. It's a subtle mistake because the code actually works—it doesn't crash, and it produces the right output—but it's a performance disaster in a production environment.
The "Mutex is Enough" Trap
The misconception is that as long as you wrap your shared data structure (like a std::queue) in a std::mutex, your producer-consumer pipeline is thread-safe and efficient. The logic usually looks something like this:
// DON'T DO THIS
while (running) {
std::lock_guard lock(queueMutex);
if (!workQueue.empty()) {
auto task = workQueue.front();
workQueue.pop();
// Process task...
}
}
On the surface, this is "safe." No two threads are touching the queue at once. But look closer at what happens when the queue is empty. The consumer thread enters a tight loop, locking and unlocking the mutex thousands of times per second just to check if the queue is still empty. I call this "burning the CPU for nothing." You'll see one core pinned at 100% utilization, and you're creating massive contention on the bus, slowing down the producer who is actually trying to put work into that queue.
Sleeping Until There's Work to Do
To fix this, we need a way for the consumer to go to sleep and be woken up only when there is actually something to do. This is where std::condition_variable comes in. Instead of polling, the consumer "waits" on a signal.
Let's look at a real-world scenario: an image processing pipeline. We have one thread reading raw image buffers from a disk (the Producer) and another thread applying a grayscale filter (the Consumer).
#include <iostream>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <vector>
#include <string>
struct ImageBuffer {
int id;
std::vector<char> data;
};
class ImagePipeline {
std::queue<ImageBuffer> queue;
std::mutex mtx;
std::condition_variable cv;
bool finished = false;
public:
void produce(ImageBuffer img) {
{
std::lock_guard<std::mutex> lock(mtx);
queue.push(std::move(img));
}
cv.notify_one(); // Wake up one waiting consumer
}
void set_finished() {
{
std::lock_guard<std::mutex> lock(mtx);
finished = true;
}
cv.notify_all(); // Wake up everyone so they can exit
}
void consume() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// The predicate lambda prevents "spurious wakeups"
cv.wait(lock, [this] { return !queue.empty() || finished; });
if (queue.empty() && finished) {
return; // Pipeline drained and producer is done
}
ImageBuffer img = std::move(queue.front());
queue.pop();
lock.unlock(); // Unlock before processing to let producer keep working
std::cout < "Processing image " < img.id < "\n";
// Simulate heavy filter work
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
};
The Nuances of the Pipeline Shutdown
You'll notice I used a finished boolean and a cv.notify_all(). This is a critical detail. If the producer simply stops sending data, the consumer will stay asleep forever in the cv.wait() call, and your program will hang during shutdown.
I also intentionally called lock.unlock() before the "heavy filter work." A common mistake is holding the mutex while processing the data. If you do that, you've effectively turned your multi-threaded pipeline back into a single-threaded program because the producer can't add new items while the consumer is busy filtering. Always hold the lock for the absolute minimum time required to modify the shared container.
- The Predicate: The second argument to
cv.waitis vital. Operating systems sometimes wake up threads for no reason (spurious wakeups). The lambda ensures the thread re-checks the condition and goes back to sleep if the wakeup was a fluke. - unique_lock vs lock_guard: We use
std::unique_lockhere becausestd::condition_variable::waitneeds to be able to unlock and relock the mutex internally.lock_guardis too simple for that.
📋 Practical Task
Exercise: Building a Multi-Stage Log Processor Pipeline
Your task is to implement a thread-safe pipeline that processes simulated system logs. You need to build a system with one Producer and two different Consumers working in parallel.
Requirements:
- The Producer: Generate 20 strings representing log entries (e.g., "INFO: System started", "ERROR: Disk full", "DEBUG: Connection timed out"). Push these into a shared queue.
- The Consumers: Create two consumer threads.
- Consumer A should only print logs that start with "ERROR".
- Consumer B should only print logs that start with "INFO".
- Thread Safety: Use a
std::mutexandstd::condition_variableto ensure consumers are not busy-waiting. - Graceful Shutdown: Ensure that both consumer threads terminate cleanly after all 20 logs have been processed and the producer has signaled completion.
Success Criteria: Your program should output all ERROR logs via one thread and all INFO logs via another, with no race conditions and no CPU spiking while waiting for data.
There are no comments for now.