Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 9

Thread Synchronization with Condition Variables

Recommended resources

Hello! Welcome to your next lesson on C threads.

In our last session, we established the "what" and "why" of condition variables. We learned that they solve the problem of inefficient busy-waiting, and we covered the essential first step: how to initialize and destroy a pthread_cond_t variable and its mandatory partner, the pthread_mutex_t.

Today, we move from setup to action. Our goal is to coordinate threads by using pthread_cond_wait, pthread_cond_signal, and pthread_cond_broadcast. These three functions are the heart of the condition variable mechanism, allowing threads to communicate and synchronize their work in a powerful and efficient way.

The Problem with Inefficient Waiting

Let's quickly revisit why we need this. Imagine a consumer thread waiting for data. A naive approach might look like this:

// THIS CODE IS WRONG AND WILL DEADLOCK
pthread_mutex_lock(&mutex);
while (items_in_buffer == 0) {
    // I'm holding the lock, so the producer can't add items!
    // I'll wait, but I'll never wake up.
    sleep(1); 
}
// consume item
pthread_mutex_unlock(&mutex);

This is a classic deadlock. The waiting thread holds the lock, preventing any other thread from changing the state it's waiting for. This demonstrates that we need a way to wait and atomically release the lock so other threads can do their work.

This is precisely the problem pthread_cond_wait solves.


The Core Mechanism: wait and signal

The fundamental pattern of using condition variables involves two distinct actions: one thread waiting for a condition to become true, and another thread signaling that the condition has changed.

pthread_cond_wait: The Atomic Wait

When a thread finds that the condition it needs isn't met (e.g., the buffer is empty), it calls pthread_cond_wait(). This function performs a critical, atomic three-step process:

  1. It unlocks the mutex you provide.
  2. It puts the calling thread to sleep (blocks) until it is signaled.
  3. Upon waking up, it waits until it can re-acquire the mutex before returning.

The atomicity is key. It ensures there's no gap between checking the condition and going to sleep where a signal could be missed (a "lost wakeup").

pthread_cond_signal: The Wake-up Call

When another thread changes the state of the shared data (e.g., adds an item to the buffer), it calls pthread_cond_signal(). This function wakes up at least one of the threads currently waiting on that specific condition variable. If no threads are waiting, the signal has no effect.

Let's watch a video that brings this to life with a practical example.

Condition variables in C

The video 'Condition variables in C' from CodeVault uses a great gas station analogy. It first demonstrates the deadlock problem of busy-waiting with a lock and then introduces pthread_cond_wait and pthread_cond_signal as the elegant solution.

Please watch the following segments: 7:00 - 9:30: Understand the deadlock that occurs from busy-waiting inside a locked critical section. 11:01 - 12:42: See how pthread_cond_wait and pthread_cond_signal are introduced to solve this. 15:08 - 16:47: Pay close attention to this part. It explains the crucial atomic unlock-wait-lock mechanism of pthread_cond_wait in detail.

The interaction described in the video is fundamental. The following diagram visualizes this exact sequence for two threads.

pthread_cond_wait and pthread_cond_signal Sequence Diagram
This diagram shows Thread 2 locking a mutex (1), then calling `pthread_cond_wait` (2). The `wait` function atomically unlocks the mutex (3) and puts the thread to sleep (4). Later, Thread 1 signals the condition (5), which wakes up Thread 2 (6). Thread 2 then re-acquires the lock (7) before it can continue.

Let's ground this with the formal definitions and a simple, clean code example.

Condition Variables

The book 'Operating Systems: Three Easy Pieces' (OSTEP) provides a clear, academic explanation. We'll look at its definition and a simple parent-child synchronization example.

Read the section '30.1 Definition and Routines' (pages 3-4). Focus on the explanation of the wait() and signal() routines and analyze the code in 'Figure 30.3: Parent Waiting For Child'. This example is a perfect, minimal illustration of the concept.

A crucial rule to follow, as mentioned in the OSTEP text and the Oracle documentation, is to always hold the associated mutex when you call pthread_cond_signal.

Using Condition Variables

The Oracle documentation reinforces this point and explains the 'lost wake-up' problem that can occur if you don't.

Read the sections 'Unblock a Specific Thread' and 'The Lost Wake-Up Problem'. This will solidify why holding the lock during a signal is critical for correctness.


pthread_cond_broadcast: Waking Everyone Up

pthread_cond_signal is efficient, waking just one thread. But what if a change in state could satisfy multiple waiting threads?

Consider the gas station analogy again. If the fuel truck adds 100 liters of fuel, and several cars are waiting, each needing 40 liters, two cars could now fill up. If we only signal, we wake up one car, which takes its 40 liters. The remaining 60 liters sit there unused while another perfectly capable car remains asleep.

This is where pthread_cond_broadcast comes in. It wakes up all threads currently waiting on the condition variable.

When to use signal vs. broadcast:

  • pthread_cond_signal: Use when you know that only one waiting thread can make progress. This is common when threads are waiting for exclusive access to a single resource item (e.g., one task from a queue). It's more efficient as it avoids waking threads unnecessarily.
  • pthread_cond_broadcast: Use when a change in state might allow multiple threads to proceed. This is also the correct choice when different threads are waiting on the same condition variable for different actual conditions (e.g., bytes_free > 10 vs. bytes_free > 100).

The following video clearly demonstrates a scenario where signal is insufficient and broadcast is required.

Signaling for condition variables (pthread_cond_signal vs pthread_cond_broadcast)

Let's return to the gas station. This video from CodeVault expands the example to include multiple cars, showing why pthread_cond_signal can be inefficient or incorrect and how pthread_cond_broadcast solves the problem.

Watch from 00:36 to 10:32. First, you'll see the problem with signal when there's enough fuel for more than one car. Then, you'll see how changing to broadcast fixes the logic, allowing multiple cars to refuel.

The downside of broadcast is the "thundering herd" problem: all waiting threads wake up, fight for the mutex, check the condition, and most likely go right back to sleep. This can be a performance cost. Therefore, you should prefer signal unless the logic of your program requires broadcast.

Test your understanding!

Consider a multithreaded logging system. Multiple "worker" threads generate log messages. A single "writer" thread is responsible for writing these messages from a shared queue to a file. The writer thread should sleep when the queue is empty.

When a worker thread adds a message to the queue, should it use pthread_cond_signal or pthread_cond_broadcast to wake up the writer thread? Why?

Show answer

It should use pthread_cond_signal.

There is only one consumer (the writer thread), so waking up more than one thread is pointless. pthread_cond_signal is more efficient because it targets just one waiting thread. Even if there were multiple writer threads, they would be processing one item at a time from the queue. Waking up all of them just to have them contend for the mutex and for a single log message would be inefficient. signal ensures one writer wakes up to process the new message, which is the desired behavior.


An Important Preview: Always Wait in a Loop

You may have noticed in all the correct examples (OSTEP, CodeVault, Oracle) that the call to pthread_cond_wait is wrapped in a while loop, not an if statement.

pthread_mutex_lock(&m);
while (condition_is_false) { // NOT if (condition_is_false)
    pthread_cond_wait(&c, &m);
}
// ... do work ...
pthread_mutex_unlock(&m);

This is not a stylistic choice; it is absolutely mandatory for correct code. There are two main reasons:

  1. Intercepted Wakeups: As we saw in the broadcast example, a thread can be woken up only to find that another thread "snuck in" and changed the condition back to false. The loop ensures the thread re-checks the condition before proceeding.
  2. Spurious Wakeups: For complex reasons related to OS and hardware implementation, a thread waiting on a condition variable can sometimes wake up even if no signal or broadcast was ever issued. This is rare but possible.

The while loop robustly handles both cases by forcing the thread to re-verify the condition is truly met after waking up. This "wait-in-a-loop" pattern is so fundamental that we will dedicate our entire next lesson to it.


Conclusion

Today you've learned the core mechanics of thread coordination with condition variables. We've moved from theory to practice, using the essential functions that orchestrate the complex dance of waiting and signaling.

Key Takeaways:

  • pthread_cond_wait(&cond, &mutex) atomically unlocks the mutex and puts the thread to sleep. It returns with the mutex locked again.
  • pthread_cond_signal(&cond) wakes up at least one thread waiting on the condition. It should be called while holding the associated mutex.
  • pthread_cond_broadcast(&cond) wakes up all threads waiting on the condition. It is necessary when a state change might satisfy multiple waiters, but can be less performant.
  • The choice between signal and broadcast depends on the logic of your program. Prefer signal unless you know multiple threads can and should proceed.

Preview of the Next Lesson:
We've touched upon the importance of the while loop. In the next lesson, we will dive deep into the "wait-in-a-loop" pattern. We'll formally explore why it's critical for handling spurious wakeups and race conditions that can occur between signaling and a thread re-acquiring its lock.