Concurrency
Multithreading
Condition Variable
Mutex
Synchronization

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

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

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

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions