Concurrency
Multithreading
Condition Variable
Mutex
Synchronization

When is a condition variable needed, isn't a mutex enough?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

A mutex alone protects shared data from concurrent access, but it cannot make a thread wait efficiently until a certain condition becomes true. A condition variable lets a thread sleep until another thread signals that the condition has changed — without busy-waiting (spinning in a loop). Without condition variables, a thread would have to repeatedly lock the mutex, check the condition, unlock, and loop, wasting CPU cycles. Condition variables are essential for producer-consumer queues, thread pools, barriers, and any pattern where one thread must wait for another thread's action.

Mutex Only: Busy-Waiting Problem

A mutex protects shared state but cannot signal state changes:

cpp
1#include <mutex>
2#include <thread>
3#include <queue>
4
5std::mutex mtx;
6std::queue<int> queue;
7
8// Consumer with busy-waiting — wastes CPU
9void consumer() {
10    while (true) {
11        mtx.lock();
12        if (!queue.empty()) {
13            int item = queue.front();
14            queue.pop();
15            mtx.unlock();
16            process(item);
17        } else {
18            mtx.unlock();
19            // Spin — repeatedly checks, burning CPU cycles
20            std::this_thread::sleep_for(std::chrono::milliseconds(1));
21        }
22    }
23}

The consumer constantly acquires and releases the lock even when there is nothing to process.

Condition Variable: Efficient Waiting

A condition variable lets the consumer sleep until the producer signals new data:

cpp
1#include <mutex>
2#include <condition_variable>
3#include <thread>
4#include <queue>
5
6std::mutex mtx;
7std::condition_variable cv;
8std::queue<int> queue;
9
10void producer() {
11    for (int i = 0; i < 10; i++) {
12        {
13            std::lock_guard<std::mutex> lock(mtx);
14            queue.push(i);
15        }
16        cv.notify_one();  // Wake one waiting consumer
17    }
18}
19
20void consumer() {
21    while (true) {
22        std::unique_lock<std::mutex> lock(mtx);
23
24        // Wait releases the lock, sleeps, then re-acquires when notified
25        cv.wait(lock, [&] { return !queue.empty(); });
26
27        int item = queue.front();
28        queue.pop();
29        lock.unlock();
30
31        process(item);
32    }
33}

Key difference: cv.wait() releases the mutex and puts the thread to sleep with zero CPU usage. When notify_one() is called, the thread wakes up, re-acquires the mutex, and checks the condition.

Why the Predicate Matters

The second argument to cv.wait(lock, predicate) protects against spurious wakeups — the OS may wake a thread even without a notification:

cpp
1// WRONG: no predicate — vulnerable to spurious wakeups
2cv.wait(lock);
3// Thread may wake up even though queue is still empty
4
5// CORRECT: predicate re-checks the condition
6cv.wait(lock, [&] { return !queue.empty(); });
7// Equivalent to:
8while (queue.empty()) {
9    cv.wait(lock);
10}

Always use a predicate with wait().

Producer-Consumer with Bounded Buffer

A complete example with a fixed-size buffer using two condition variables:

cpp
1#include <mutex>
2#include <condition_variable>
3#include <thread>
4#include <queue>
5#include <iostream>
6
7class BoundedQueue {
8    std::queue<int> buffer;
9    size_t capacity;
10    std::mutex mtx;
11    std::condition_variable not_full;   // Signals when space is available
12    std::condition_variable not_empty;  // Signals when items are available
13
14public:
15    BoundedQueue(size_t cap) : capacity(cap) {}
16
17    void produce(int item) {
18        std::unique_lock<std::mutex> lock(mtx);
19        not_full.wait(lock, [&] { return buffer.size() < capacity; });
20
21        buffer.push(item);
22        std::cout << "Produced: " << item << std::endl;
23
24        not_empty.notify_one();
25    }
26
27    int consume() {
28        std::unique_lock<std::mutex> lock(mtx);
29        not_empty.wait(lock, [&] { return !buffer.empty(); });
30
31        int item = buffer.front();
32        buffer.pop();
33        std::cout << "Consumed: " << item << std::endl;
34
35        not_full.notify_one();
36        return item;
37    }
38};

Two condition variables are needed because producers and consumers wait for different conditions.

Python Example

python
1import threading
2import queue
3import time
4
5buffer = queue.Queue(maxsize=5)
6condition = threading.Condition()
7
8def producer():
9    for i in range(10):
10        with condition:
11            while buffer.full():
12                condition.wait()  # Wait until space available
13            buffer.put(i)
14            print(f"Produced: {i}")
15            condition.notify_all()  # Wake consumers
16        time.sleep(0.1)
17
18def consumer(name):
19    while True:
20        with condition:
21            while buffer.empty():
22                condition.wait()  # Wait until item available
23            item = buffer.get()
24            print(f"{name} consumed: {item}")
25            condition.notify_all()  # Wake producers
26
27t1 = threading.Thread(target=producer)
28t2 = threading.Thread(target=consumer, args=("C1",))
29t3 = threading.Thread(target=consumer, args=("C2",))
30
31t2.daemon = t3.daemon = True
32t1.start(); t2.start(); t3.start()
33t1.join()

Java Example

java
1import java.util.LinkedList;
2import java.util.Queue;
3
4public class ProducerConsumer {
5    private final Queue<Integer> buffer = new LinkedList<>();
6    private final int capacity = 5;
7
8    public synchronized void produce(int item) throws InterruptedException {
9        while (buffer.size() == capacity) {
10            wait();  // Release lock and wait
11        }
12        buffer.add(item);
13        System.out.println("Produced: " + item);
14        notifyAll();  // Wake waiting consumers
15    }
16
17    public synchronized int consume() throws InterruptedException {
18        while (buffer.isEmpty()) {
19            wait();  // Release lock and wait
20        }
21        int item = buffer.poll();
22        System.out.println("Consumed: " + item);
23        notifyAll();  // Wake waiting producers
24        return item;
25    }
26}

In Java, every object has an intrinsic condition variable accessed through wait() and notifyAll() inside synchronized blocks.

When You Need Each

ScenarioMutex AloneCondition Variable + Mutex
Protect shared counterSufficientNot needed
Producer-consumer queueBusy-wait requiredEfficient wait
Thread pool task dispatchBusy-wait requiredEfficient wait
Barrier synchronizationCannot implementRequired
One-time initializationstd::once_flagNot needed

Common Pitfalls

  • Forgetting the predicate in cv.wait(): Without a predicate, the thread may proceed on a spurious wakeup when the condition is not actually met. This causes consuming from an empty queue or writing to a full buffer. Always pass a lambda predicate: cv.wait(lock, [&]{ return condition; }).
  • Calling notify before wait: If the producer calls notify_one() before the consumer calls wait(), the signal is lost. The consumer then waits forever (deadlock). Design your logic so the condition check in the predicate handles this — if the condition is already true, wait returns immediately.
  • Using notify_one when multiple threads are waiting: notify_one wakes only one thread. If multiple consumers are waiting and one notification should wake all of them (e.g., shutdown signal), use notify_all().
  • Holding the mutex while doing expensive work: Lock the mutex only to check the condition and access shared data. Release it before doing CPU-intensive or I/O work. Holding the lock during processing blocks all other threads from making progress.
  • Using a condition variable without a mutex: Condition variables must always be used with a mutex that protects the shared state being checked. Signaling or waiting without the mutex causes data races and undefined behavior.

Summary

  • A mutex protects shared data; a condition variable enables efficient waiting for state changes
  • Without condition variables, threads must busy-wait (spin), wasting CPU cycles
  • Always use a predicate with cv.wait() to handle spurious wakeups
  • Use notify_one() to wake a single waiter, notify_all() to wake all waiters
  • The producer-consumer pattern is the canonical use case — one thread produces data and signals, the other waits and consumes

Course illustration
Course illustration

All Rights Reserved.