Skip to content

Condition Variables & Unique Lock

Condition variables allow threads to wait for specific conditions to become true, enabling sophisticated thread synchronization beyond simple mutexes.

The Problem: Busy Waiting

Consider this naive approach to wait for a condition:

cpp
std::mutex mtx;
bool ready = false;

void wait_for_ready() {
    while (true) {
        mtx.lock();
        if (ready) {
            mtx.unlock();
            break;
        }
        mtx.unlock();
        // Busy waiting - wastes CPU!
    }
}

Problems:

  • CPU waste: Thread constantly checks the condition
  • Poor performance: Consumes 100% CPU while waiting
  • Race conditions: Between checking and unlocking

The Solution: std::condition_variable

Condition variables allow threads to sleep until a condition becomes true. But to use them, we need to first learn about std::unique_lock.

std::unique_lock

Much like std::lock_guard, std::unique_lock is a RAII wrapper for std::mutex. It automatically locks the mutex on construction and unlocks it on destruction.

However, one major difference is that std::unique_lock can be unlocked and locked multiple times.

cpp
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
lock.unlock();  // Can unlock before destruction
lock.lock();    // Can lock again!

std::condition_variable uses unique_lock to unlock the mutex before sleeping and lock it again when waking up. It optionally takes a function that returns a boolean to check if a condition is met. Here is a simple example, which will be explained in detail later.

Basic Usage

cpp
#include <condition_variable>
#include <mutex>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void wait_for_ready() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, { return ready; });
    // Thread sleeps here until ready becomes true
}

void signal_ready() {
    {
        std::unique_lock<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();  // Wake up one waiting thread
}

Condition Variable Methods

cv.wait(lock, predicate)

cpp
cv.wait(lock, { return ready; });

What happens:

  1. Unlocks the mutex
  2. Sleeps the thread
  3. Re-locks the mutex when woken
  4. Checks the predicate
  5. If false: Goes back to sleep (handles spurious wakeups)
  6. If true: Continues execution

cv.notify_one()

Wakes up one waiting thread. This is useful when you have multiple threads waiting for a condition and you want to wake up one of them to check if they can execute their critical section.

cv.notify_all()

Wakes up all waiting threads. This is useful when you have multiple threads waiting for a condition and you want to wake up all of them to check if they can execute their critical section.

Spurious Wakeups

Spurious wakeups are when a thread wakes up even though no notification was sent.

Why They Happen

  • OS scheduling: Operating system may wake threads for various reasons
  • Hardware interrupts: System events can cause wakeups
  • Performance optimization: Some systems wake multiple threads for efficiency

Since std::condition_variable suffers from spurious wakeups, we should always use a predicate with cv.wait().

cpp
// WRONG - can miss notifications
cv.wait(lock);

// CORRECT - handles spurious wakeups
cv.wait(lock, { return ready; });

Common Patterns

1. Producer-Consumer (Simplified)

cpp
std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
int data = 0;

void producer() {
    {
        std::lock_guard<std::mutex> lock(mtx); // Don't need a unique_lock here because we're not waiting on a condition!
        data = 42;
        data_ready = true;
    }
    cv.notify_one();
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, { return data_ready; });
    // Process data
    data_ready = false;
}

2. Barrier Synchronization

cpp
std::mutex mtx;
std::condition_variable cv;
int threads_waiting = 0;
int total_threads = 3;

void barrier_wait() {
    std::unique_lock<std::mutex> lock(mtx);
    threads_waiting++;

    if (threads_waiting == total_threads) {
        cv.notify_all();  // Wake everyone
    } else {
        cv.wait(lock, { return threads_waiting == total_threads; });
    }
}

3. Timeout Waiting

cpp
std::unique_lock<std::mutex> lock(mtx);
auto timeout = std::chrono::seconds(5);

if (cv.wait_for(lock, timeout, { return ready; })) {
    // Condition became true within timeout
} else {
    // Timeout occurred
}

Performance Considerations

1. Lock Duration

  • Keep locks short: Minimize time holding the mutex
  • Notify outside lock: Avoid waking threads while holding lock

2. Wakeup Overhead

  • notify_one(): More efficient than notify_all()
  • Predicate checking: Minimal overhead compared to busy waiting

Your Task: Thread Pool Implementation

A thread pool is a design pattern that manages a collection of worker threads that are waiting for tasks to execute. Instead of creating a new thread for each task, the pool reuses existing threads, to reduce the overhead of thread creation and destruction.

The thread pool has three responsibilities:

  1. Accept new tasks and assign them to the worker threads
  2. Transferring the inputs and outputs of the tasks to the worker threads
  3. Once the task is completed, return the worker thread to the pool and wait for the next task

Your task is to implement a thread-safe task scheduler using std::condition_variable and std::unique_lock.

1. Task Queue

  • Purpose: Stores pending tasks waiting to be executed
  • Implementation: std::queue<std::function<void()>> or similar
  • Thread Safety: Protected by mutex and condition variable

2. Worker Threads

  • Purpose: Execute tasks from the queue
  • Lifecycle:
    • Start: Join the pool and begin processing
    • Work: Continuously fetch and execute tasks
    • Stop: Exit when shutdown is requested

3. Thread Pool Manager

  • Purpose: Coordinates between task submission and worker threads
  • Responsibilities:
    • Accept new tasks
    • Distribute tasks to workers
    • Manage shutdown process
    • Handle thread lifecycle

Implement a thread-safe task scheduler using std::condition_variable and std::unique_lock. Workers should wait for tasks to become available, and the scheduler should signal when new tasks are added.

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

class TaskScheduler {
private:
    std::queue<std::function<void()>> tasks;
    std::mutex mtx;
    std::condition_variable task_available;
    bool shutdown = false;

public:
    TaskScheduler() = default;

    // TODO: Implement thread-safe add_task method
    // Should add a task to the queue and notify waiting workers
    void add_task(std::function<void()> task) {
        // USER CODE HERE
    }

    // TODO: Implement thread-safe get_task method
    // Should wait for a task to become available, then return and remove it
    // Return true if task was retrieved, false if shutdown
    std::optional<std::function<void()>> get_task() {
        // USER CODE HERE
    }

    // TODO: Implement thread-safe shutdown method
    // Should signal all waiting workers to stop
    void shutdown_scheduler() {
        // USER CODE HERE
    }

    // TODO: Implement thread-safe has_tasks method
    // Should return true if there are tasks in the queue
    bool has_tasks() {
        // USER CODE HERE
    }
};