Appearance
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:
- Unlocks the mutex
- Sleeps the thread
- Re-locks the mutex when woken
- Checks the predicate
- If false: Goes back to sleep (handles spurious wakeups)
- 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:
- Accept new tasks and assign them to the worker threads
- Transferring the inputs and outputs of the tasks to the worker threads
- 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
}
};