Condition Variables: Synchronization for Complex Coordination
Signal Operation
Read aloud is unavailable in this browser.
Waking the Sleepers
If the wait operation is how threads go to sleep waiting for conditions, the signal operation is how they're awakened. But signaling is not as simple as it might appear. When you signal, you face several crucial decisions:
- Should you wake one thread or all threads?
- Should you signal before or after releasing the mutex?
- What happens if no one is waiting?
- How do you ensure the right thread wakes up?
These questions reveal the subtleties that make concurrent programming challenging. A wrong choice can lead to bugs ranging from performance degradation to system deadlock.
In this page, we'll thoroughly explore the signal operation, its sibling broadcast, and the decision-making framework for choosing between them. We'll examine the semantics, implementations, and the common patterns that make signals effective.
By the end of this page, you will understand: the precise semantics of signal and broadcast operations; when to use signal versus broadcast; the implications of signaling with or without the mutex held; how signals are implemented at the kernel level; and common signaling patterns and anti-patterns.
Signal vs. Broadcast: The Fundamental Choice
POSIX provides two ways to wake waiting threads:
int pthread_cond_signal(pthread_cond_t *cond); // Wake ONE waiter
int pthread_cond_broadcast(pthread_cond_t *cond); // Wake ALL waiters
The difference is simple to state but profound in implication:
Signal: Wakes at most one thread from the condition variable's wait queue. If no threads are waiting, the signal has no effect (it is not remembered).
Broadcast: Wakes all threads currently waiting on the condition variable. Again, if no threads are waiting, nothing happens.
Why two operations?
The existence of both operations reflects a fundamental tradeoff between correctness and performance.
| Aspect | Signal (wake one) | Broadcast (wake all) |
|---|---|---|
| Threads woken | At most 1 | All waiters |
| Performance | Higher (less context switching) | Lower (many wakeups, most blocked again) |
| Risk | May not wake the "right" thread | Thundering herd problem |
| Correctness | Requires careful analysis | Always correct (but may be slow) |
| Use case | Multiple waiters for same condition | Waiters for different conditions |
The correctness question:
Signal is an optimization. Broadcast is always correct (assuming correct wait loop patterns), but signal may be incorrect if used improperly. Consider:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Scenario: Two types of waiters on the SAME condition variable// This is a BAD design, but illustrates the problem pthread_cond_t cond;pthread_mutex_t mutex;int items = 0;int special_items = 0; // Waiter A: Waits for any itemvoid* waiter_any(void* arg) { pthread_mutex_lock(&mutex); while (items == 0) { pthread_cond_wait(&cond, &mutex); } items--; pthread_mutex_unlock(&mutex); return NULL;} // Waiter B: Waits for special items onlyvoid* waiter_special(void* arg) { pthread_mutex_lock(&mutex); while (special_items == 0) { // Different condition! pthread_cond_wait(&cond, &mutex); } special_items--; pthread_mutex_unlock(&mutex); return NULL;} // Producer adds a special itemvoid add_special_item(void) { pthread_mutex_lock(&mutex); special_items++; items++; pthread_cond_signal(&cond); // BUG: Might wake waiter_any! pthread_mutex_unlock(&mutex);} // The problem:// - waiter_any and waiter_special BOTH wait on 'cond'// - add_special_item calls signal()// - Signal might wake waiter_any instead of waiter_special// - waiter_any finds items > 0, consumes a regular item// - waiter_special stays asleep forever!Signal is only safe when ALL waiters on a condition variable wait for the SAME predicate. If different waiters wait for different conditions, signal may wake the wrong thread. In such cases, use separate condition variables or broadcast.
The Signal Semantics in Detail
Let's precisely define what signal does:
pthread_cond_signal:
- If the wait queue is empty, do nothing (signal is lost)
- If threads are waiting, mark at least one for wakeup
- Return immediately (don't wait for waiter to run)
Key properties:
- •Non-blocking: Signal returns immediately; it doesn't wait for the woken thread to execute
- •At-least-one: POSIX requires waking "at least one" waiter; implementations typically wake exactly one
- •No guarantee of order: POSIX doesn't mandate which waiter is woken (though FIFO is common)
- •No memory: If no one is waiting, the signal evaporates; it's not stored for later waiters
- •Safe without mutex: Technically valid to call without holding the mutex (but usually unwise)
12345678910111213141516171819202122232425262728
// Demonstrating signal behavior in different scenarios // Scenario 1: One waiter// ----------------------// Thread A: wait(&cond, &mutex) -- blocked// Thread B: signal(&cond) -- wakes A// Thread A: reacquires mutex, returns from wait // Scenario 2: Multiple waiters// ---------------------------// Thread A: wait(&cond, &mutex) -- blocked// Thread B: wait(&cond, &mutex) -- blocked// Thread C: signal(&cond) -- wakes ONE (A or B)// Only one thread proceeds; the other remains blocked // Scenario 3: No waiters// ----------------------// Thread A: signal(&cond) -- nothing happens// Thread B: wait(&cond, &mutex) -- blocks (signal was lost)// Thread B will wait forever unless signaled again // Scenario 4: Signal before predicate change (BAD)// -----------------------------------------------// Thread A: waiting for (count > 0)// Thread B: signal(&cond) -- A wakes// count++; -- too late!// Thread A: checks (count > 0), it's FALSE, waits again// This works due to while loop, but wastes a context switchMesa vs. Hoare semantics revisited:
The signal semantics we've described are Mesa semantics (signal-and-continue):
- Signaler keeps running after signal
- Woken thread competes for mutex like any other thread
- Condition may change before woken thread actually runs
Contrast with Hoare semantics (signal-and-wait):
- Signaler immediately transfers control to woken thread
- Woken thread runs immediately with condition guaranteed
- Signaler waits until signaled thread releases monitor
No modern system uses Hoare semantics in its pure form—the immediate context switch is too expensive. Mesa semantics dominate, which is why the while loop is mandatory.
The Broadcast Semantics
Broadcast wakes all waiting threads:
pthread_cond_broadcast:
- If the wait queue is empty, do nothing
- Mark ALL threads in the wait queue for wakeup
- Return immediately
The thundering herd problem:
Broadcast has a notorious performance issue. When N threads are woken:
12345678910111213141516171819202122232425262728293031
// The thundering herd problem illustrated // Setup: 100 threads waiting for workfor (int i = 0; i < 100; i++) { pthread_mutex_lock(&mutex); while (work_queue_empty()) { pthread_cond_wait(&work_available, &mutex); } item = dequeue_work(); pthread_mutex_unlock(&mutex); process(item);} // Producer adds ONE item and broadcastsvoid add_work(Item* item) { pthread_mutex_lock(&mutex); enqueue_work(item); pthread_cond_broadcast(&work_available); // Wakes ALL 100! pthread_mutex_unlock(&mutex);} // What happens:// 1. All 100 threads wake up// 2. They all try to acquire the mutex// 3. Thread 1 wins, dequeues the item, releases mutex// 4. Thread 2 acquires mutex, finds queue empty, waits again// 5. Thread 3 acquires mutex, finds queue empty, waits again// ... repeated 98 more times!// // Result: 1 item processed, 99 pointless context switches// This is VERY expensive on systems with many threadsIn the example above, adding one work item causes 100 threads to wake, 99 to immediately block again, with potentially hundreds of context switches. With thousands of threads, systems can become unresponsive. This is why signal (wake one) exists.
When broadcast is necessary:
Despite the thundering herd problem, broadcast is sometimes required:
- •Multiple resources available: Producer adds N items; should wake up to N consumers
- •Different predicates: Different waiters wait for different conditions (as shown earlier)
- •State change affects all: Shutdown notification should wake ALL waiting threads
- •Barrier synchronization: All threads must proceed when the last one arrives
- •Configuration change: A setting change that all waiters need to observe
1234567891011121314151617181920212223242526272829303132333435
// Examples where broadcast is correct and necessary // Example 1: Shutdown notificationvoid shutdown_server(void) { pthread_mutex_lock(&mutex); shutting_down = true; pthread_cond_broadcast(&cond); // Wake ALL threads to exit pthread_mutex_unlock(&mutex);} // Example 2: Resource pool with multiple availablevoid return_connections(Connection* conns, int n) { pthread_mutex_lock(&mutex); for (int i = 0; i < n; i++) { add_to_pool(conns[i]); available_count++; } pthread_cond_broadcast(&pool_cond); // Wake up to n waiters pthread_mutex_unlock(&mutex);} // Example 3: Barrier synchronizationvoid barrier_wait(Barrier* b) { pthread_mutex_lock(&b->mutex); b->count++; if (b->count == b->threshold) { // Last thread: wake everyone pthread_cond_broadcast(&b->cond); // Must be broadcast! } else { while (b->count < b->threshold) { pthread_cond_wait(&b->cond, &b->mutex); } } pthread_mutex_unlock(&b->mutex);}The Decision Framework: Signal or Broadcast?
Choosing between signal and broadcast requires careful analysis. Here's a decision framework:
Rule 1: When in doubt, use broadcast.
Broadcast is always correct (given proper while loops). It may be slower due to spurious wakeups, but it won't cause missed wakeups or deadlocks.
Rule 2: Use signal only when ALL of the following are true:
- •Uniform waiters: All threads waiting on this condition variable wait for the SAME predicate
- •One-at-a-time processing: Making the condition true enables exactly ONE waiter to proceed
- •Waiter interchangeability: It doesn't matter WHICH waiter is woken; any will do
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Case 1: Work queue with identical workers// Condition: work queue not empty// All workers wait for same predicate - USE SIGNAL void add_task(Task* t) { pthread_mutex_lock(&mutex); enqueue(t); pthread_cond_signal(&cond); // Signal is safe and efficient pthread_mutex_unlock(&mutex);} // Case 2: Readers-writers lock// Readers wait for: no writers// Writers wait for: no readers AND no writers// Different predicates - USE BROADCAST (or separate CVs) void writer_unlock(RWLock* lock) { pthread_mutex_lock(&lock->mutex); lock->writer = false; // Don't know if we should wake reader or writer pthread_cond_broadcast(&lock->cond); // Must broadcast pthread_mutex_unlock(&lock->mutex);} // Case 3: Bounded buffer producer// Condition: not full// Adding one slot enables one producer// All producers want same thing - USE SIGNAL void consume(void) { pthread_mutex_lock(&mutex); // ... consume item ... count--; pthread_cond_signal(¬_full); // Signal is safe pthread_mutex_unlock(&mutex);} // Case 4: Configuration change// All threads need to see new configvoid update_config(Config* new_config) { pthread_mutex_lock(&mutex); current_config = new_config; pthread_cond_broadcast(&config_cond); // Wake ALL pthread_mutex_unlock(&mutex);}| Scenario | Choice | Rationale |
|---|---|---|
| Single consumer, single producer | Signal | Only one waiter possible |
| Thread pool work queue | Signal | Uniform workers, one task per signal |
| Barrier synchronization | Broadcast | All threads must proceed together |
| Shutdown notification | Broadcast | All threads need to know |
| Readers-writers lock | Broadcast* | *Or use separate CVs |
| Resource pool returning N items | Broadcast | Up to N threads can proceed |
| Condition state change tracking | Broadcast | All observers need update |
Signaling With or Without the Mutex
A perennial question: should you hold the mutex when calling signal or broadcast?
POSIX allows both:
The specification permits calling signal/broadcast without holding the mutex. However, the implications are subtle.
Option A: Signal while holding mutex (recommended)
12345678910111213141516171819202122
// Pattern A: Signal while holding mutexvoid producer(Item item) { pthread_mutex_lock(&mutex); buffer[in] = item; in = (in + 1) % SIZE; count++; pthread_cond_signal(¬_empty); // Signal while locked pthread_mutex_unlock(&mutex);} // Pros:// - Simpler reasoning: modification and signal are atomic// - No race window between modify and signal// - Waiter wakes and blocks on mutex (predictable) // Cons:// - Woken thread immediately blocks on mutex// - Extra context switch in some cases// - Minor: holds mutex slightly longerOption B: Signal after releasing mutex
1234567891011121314151617181920212223
// Pattern B: Signal after releasing mutexvoid producer(Item item) { pthread_mutex_lock(&mutex); buffer[in] = item; in = (in + 1) % SIZE; count++; pthread_mutex_unlock(&mutex); pthread_cond_signal(¬_empty); // Signal after unlock} // Pros:// - Woken thread may acquire mutex immediately// - Potentially fewer context switches// - Mutex held for shorter time // Cons:// - Race window: another thread could run between// unlock and signal, changing state again// - More complex reasoning about program behavior// - Potential for subtle bugs in complex scenariosFor most applications, signaling while holding the mutex is simpler and safer. The performance difference is usually negligible. Only unlock-then-signal if profiling shows it matters AND you've carefully analyzed the race conditions.
The "hurry up and wait" problem:
When you signal while holding the mutex:
- The woken thread leaves the condition's wait queue
- It immediately tries to acquire the mutex
- It blocks because the signaler still holds the mutex
- The signaler releases the mutex
- The woken thread finally acquires it
This creates an extra context switch that unlock-then-signal avoids. However, modern kernels often optimize this with "wait morphing"—the woken thread is moved directly to the mutex's wait queue without fully waking it.
When unlock-then-signal can go wrong:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Subtle bug with unlock-then-signal in complex scenario // Thread A: Consumer with timeoutvoid consumer_with_timeout(void) { pthread_mutex_lock(&mutex); while (count == 0) { int status = pthread_cond_timedwait(&cond, &mutex, &deadline); if (status == ETIMEDOUT) { pthread_mutex_unlock(&mutex); return; // Timed out } } // Consume item Item item = buffer[out]; out = (out + 1) % SIZE; count--; pthread_mutex_unlock(&mutex); process(item);} // Thread B: Producer with unlock-then-signalvoid producer_unlock_first(Item item) { pthread_mutex_lock(&mutex); buffer[in] = item; in = (in + 1) % SIZE; count++; pthread_mutex_unlock(&mutex); // Unlock first // <<< RACE WINDOW >>> // Thread C could: // 1. Lock mutex // 2. Consume the item we just added // 3. Unlock mutex // Now count is 0 again! pthread_cond_signal(&cond); // Signal (for Thread A) // Thread A wakes, but count is 0! // A's while loop rechecks, goes back to wait // Not a bug (while loop saves us), but a wasted wakeup}Signal Implementation Details
Understanding how signal is implemented helps in reasoning about its behavior:
Linux (NPTL) Implementation:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Simplified Linux signal implementation (conceptual) int pthread_cond_signal(pthread_cond_t *cond) { // 1. Lock condition variable's internal spinlock spin_lock(&cond->__lock); // 2. Check if anyone is waiting if (cond->__nwaiters == 0) { spin_unlock(&cond->__lock); return 0; // No waiters, nothing to do } // 3. Increment wakeup sequence number cond->__wakeup_seq++; cond->__futex++; // This change will wake futex waiters // 4. Unlock and wake one waiter spin_unlock(&cond->__lock); // FUTEX_WAKE wakes one thread waiting on this address futex(&cond->__futex, FUTEX_WAKE, 1); return 0;} int pthread_cond_broadcast(pthread_cond_t *cond) { spin_lock(&cond->__lock); if (cond->__nwaiters == 0) { spin_unlock(&cond->__lock); return 0; } // Wake ALL by signaling nwaiters times cond->__wakeup_seq += cond->__nwaiters; cond->__futex++; spin_unlock(&cond->__lock); // FUTEX_WAKE with INT_MAX wakes all waiters futex(&cond->__futex, FUTEX_WAKE, INT_MAX); return 0;}The futex_wake operation:
The kernel maintains a hash table of futex wait queues. When FUTEX_WAKE is called:
- Hash the futex address to find the wait queue
- Remove up to N threads from the queue
- Mark those threads as runnable
- Schedule them (they may run immediately or be queued)
Wait morphing optimization:
Linux implements an optimization for the "hurry up and wait" problem. When a thread is woken from a condition variable and will immediately block on a mutex:
- Instead of fully waking the thread...
- Move it directly to the mutex's wait queue
- When the mutex is released, the thread wakes and acquires it
This avoids an unnecessary wake-block cycle and reduces context switches.
Windows, macOS, and other systems have different implementations. The key invariants (signal-one, broadcast-all) are maintained, but performance characteristics may differ. Always benchmark on your target platform if performance is critical.
Common Patterns and Anti-Patterns
Let's examine patterns that work well and patterns to avoid.
Pattern: Single-slot handoff
12345678910111213141516171819202122232425262728293031
// Pattern: Single producer, single consumer, single slot// Uses signal correctly pthread_mutex_t mutex;pthread_cond_t has_data;pthread_cond_t has_space;int slot_full = 0;Data slot; void producer(Data d) { pthread_mutex_lock(&mutex); while (slot_full) { pthread_cond_wait(&has_space, &mutex); } slot = d; slot_full = 1; pthread_cond_signal(&has_data); // Exactly one consumer waiting pthread_mutex_unlock(&mutex);} Data consumer(void) { pthread_mutex_lock(&mutex); while (!slot_full) { pthread_cond_wait(&has_data, &mutex); } Data d = slot; slot_full = 0; pthread_cond_signal(&has_space); // Exactly one producer waiting pthread_mutex_unlock(&mutex); return d;}Anti-pattern: Forgetting to signal
123456789101112
// ANTI-PATTERN: Forgetting to signal void buggy_producer(Data d) { pthread_mutex_lock(&mutex); enqueue(d); count++; // BUG: Forgot to signal! pthread_mutex_unlock(&mutex);} // Consumers will wait forever if they were already// waiting when this producer ranAnti-pattern: Signal without state change
1234567891011121314
// ANTI-PATTERN: Signaling without changing state void confusing_code(void) { pthread_mutex_lock(&mutex); // No state change here! pthread_cond_signal(&cond); // Pointless signal pthread_mutex_unlock(&mutex);} // This causes spurious wakeups with no benefit// Waiters wake, check condition, and wait again- •Forgetting to signal: State changes without signaling leave waiters stuck forever
- •Signal without state change: Causes pointless wakeups and CPU waste
- •Wrong condition variable: Signaling cond_A when waiters are on cond_B
- •Signal when broadcast needed: Different-predicate waiters get stuck
- •Broadcast when signal sufficient: Thundering herd wastes resources
- •Signal before state change: While-loop handles it, but wastes a wakeup
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.