Skip to Content
Course content

179: Building a Thread-Safe Producer-Consumer Pipeline

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

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.wait is 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_lock here because std::condition_variable::wait needs to be able to unlock and relock the mutex internally. lock_guard is 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::mutex and std::condition_variable to 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.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.