Loading learning content...
In the previous page, we explored signal-and-wait semantics where the signaling thread immediately yields to the awakened thread. Now we examine the alternative approach that dominates modern implementations: signal-and-continue semantics.
The fundamental difference is philosophical: rather than guaranteeing that conditions hold when waiters resume, signal-and-continue treats the signal as a hint or notification that the condition might now be satisfiable. The awakened thread must verify this for itself.
This shift in responsibility—from the signaler to the waiter—has profound implications for correctness, performance, and how we structure conditioned waits in concurrent programs.
By the end of this page, you will deeply understand signal-and-continue semantics—where signaling merely schedules awakening rather than transferring execution. You will learn why while loops become mandatory, how spurious wakeups arise, the performance advantages that led to universal adoption, and the correctness patterns required when using systems like pthreads, Java, or Windows.
Signal-and-continue (also known as Mesa semantics, after the Xerox PARC Mesa programming language that introduced them) is a condition variable signaling policy where the signaling thread continues executing after signaling, and the awakened thread is merely moved to the ready queue rather than immediately given the lock.
Formal Behavior Specification
When thread T₁ (the signaler) executes signal(cv) on condition variable cv while thread T₂ is waiting on cv:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Signal-and-Continue implementation within a monitor// This is the model used by pthreads, Java, and modern systems monitor implementation { // Monitor lock private lock: Lock; // Queue for threads trying to enter the monitor private entryQueue: Queue<Thread>; // NOTE: No urgent queue needed! (simpler than signal-and-wait) // Condition variable wait operation procedure wait(cv: ConditionVariable) { // Add current thread to condition's wait queue cv.waitQueue.enqueue(currentThread); // Release monitor lock if (!entryQueue.isEmpty()) { wakeup(entryQueue.dequeue()); } else { lock.release(); } // Block until signaled block(); // CRITICAL DIFFERENCE: When we wake, we do NOT have the lock // We must reacquire it like any entering thread // Reacquire the lock while (!lock.tryAcquire()) { entryQueue.enqueue(currentThread); block(); } // Now we have the lock, but condition may be FALSE! // Caller MUST recheck the condition } // Signal operation - Signal-and-Continue semantics procedure signal(cv: ConditionVariable) { if (!cv.waitQueue.isEmpty()) { Thread waiter = cv.waitQueue.dequeue(); // Move waiter to entry queue (or ready queue) // They will compete for the lock when we release it entryQueue.enqueue(waiter); wakeup(waiter); // KEY DIFFERENCE: We continue executing here // We still hold the lock // We can modify state further before releasing } // If no waiters, signal has no effect } // Monitor procedure exit procedure exitMonitor() { // Simply release lock; next thread in entry queue gets it if (!entryQueue.isEmpty()) { wakeup(entryQueue.dequeue()); } else { lock.release(); } }}In signal-and-continue, there is a temporal gap between the signal and the waiter's resumption. During this gap, the signaler may modify state, other threads may enter and modify state, or multiple waiters may be awakened. The condition that was true at signal is NOT guaranteed to be true when the waiter executes.
The fundamental challenge with signal-and-continue semantics is that the condition that triggered the signal may become invalid before the awakened thread executes. This can happen in several ways:
Invalidation by the Signaler
The signaling thread continues executing after signaling. If it modifies state that the waiter depends on, the condition may become false:
1234567891011121314151617181920212223242526272829
// Example: Signaler invalidates condition before waiter runs monitor Problem { private available: boolean = false; condition becameAvailable; procedure produce() { available = true; signal(becameAvailable); // Wake up waiter // But we continue executing here... doSomeMoreWork(); // Bug: We might invalidate the condition available = false; // Waiter's condition is now invalid! // When waiter eventually runs, available == false } procedure consume() { if (!available) { // BUG: Using 'if' with signal-and-continue wait(becameAvailable); } // With signal-and-continue, available might be FALSE here! // The 'if' check doesn't protect us doConsume(); // May operate on invalid state }}Invalidation by Another Thread
Between the signal and the waiter's lock acquisition, a completely different thread may enter the monitor and consume the resource:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Example: Another thread invalidates condition // Timeline:// T1: Producer - calls produce()// T2: Consumer - waiting on becameAvailable// T3: Consumer - about to enter consume() // Time 0: T2 is waiting, T1 and T3 are ready // Time 1: T1 executes// - available = true// - signal(becameAvailable) -> T2 moved to entry queue// - T1 continues, eventually exits // Time 2: T3 happens to acquire lock before T2// (Scheduler gives T3 priority, or T3 was already in entry queue)// - T3 sees available == true// - T3 sets available = false// - T3 exits // Time 3: T2 finally acquires lock// - T2 was signaled (believes available should be true)// - But available == false (T3 consumed it)// - If T2 used 'if', it proceeds incorrectly// - If T2 used 'while', it rechecks and waits again monitor BoundedBuffer { private count: integer = 0; condition notEmpty; procedure insert(item: T) { // ... insert logic ... count++; signal(notEmpty); // Wake a consumer } procedure remove(): T { if (count == 0) { // BUG! wait(notEmpty); } // Another consumer might have taken the item // between signal and our lock acquisition count--; // BUG: count might already be 0! // ... }}Using an if statement to check conditions before wait() is a classic bug in signal-and-continue systems. The condition can become false between the signal and resumption. This is why pthreads documentation, Java tutorials, and every authoritative source emphasizes: ALWAYS use while loops, NEVER if statements.
Given that conditions can be invalidated, the solution is straightforward: always recheck the condition after waking. This is accomplished by wrapping the wait in a while loop rather than an if statement.
The Canonical Pattern
12345678910111213141516171819202122232425262728293031
// THE CORRECT PATTERN for signal-and-continue monitor CorrectExample { private condition: boolean = false; condition conditionVar; procedure waitForCondition() { // CORRECT: while loop rechecks condition after every wakeup while (!condition) { wait(conditionVar); // After waking, loop back and recheck! // If condition is still false, wait again } // When we exit the loop, condition is DEFINITELY true // (We hold the lock and just verified it) } procedure makeConditionTrue() { condition = true; signal(conditionVar); // We can continue doing things here... // The waiter will recheck anyway }} // Why while works:// 1. Initial check: if condition is true, skip wait entirely// 2. Wait: block until signaled// 3. Wake up: reacquire lock// 4. Recheck: if condition became false, wait again// 5. Only exit when condition verified true while holding lockThe while Loop Mental Model
Think of the while loop as establishing a precondition contract:
This is defensive programming in action—we trust no one, verify everything, and the code is correct regardless of how signals are ordered or how many threads compete.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Correct bounded buffer with signal-and-continue monitor BoundedBuffer<T> { private buffer: T[N]; private count: integer = 0; private in: integer = 0; private out: integer = 0; condition notEmpty; condition notFull; procedure insert(item: T) { // CORRECT: while loop for all waits while (count == N) { wait(notFull); // After waking: recheck! Another producer might have filled buffer } // GUARANTEED: count < N (verified while holding lock) buffer[in] = item; in = (in + 1) % N; count++; signal(notEmpty); // We continue here; consumer will recheck before consuming } procedure remove(): T { // CORRECT: while loop protects against invalidation while (count == 0) { wait(notEmpty); // After waking: recheck! Another consumer might have taken item } // GUARANTEED: count > 0 (verified while holding lock) T item = buffer[out]; out = (out + 1) % N; count--; signal(notFull); return item; }}The while loop provides an invariant: when control exits the loop, the condition is TRUE and the thread holds the lock. This invariant is established regardless of signal timing, other threads, or spurious wakeups. It is the foundation of all correct signal-and-continue code.
| Scenario | if (!cond) wait() | while (!cond) wait() |
|---|---|---|
| Signaler modifies after signal | BUG: Proceeds with stale state | CORRECT: Rechecks and waits again |
| Another thread takes resource | BUG: Fails to find expected state | CORRECT: Rechecks and waits again |
| Spurious wakeup | BUG: Proceeds when condition false | CORRECT: Rechecks and waits again |
| Multiple waiters, one resource | BUG: Multiple threads proceed | CORRECT: Others recheck and wait |
| Broadcast wakeup | BUG: All proceed simultaneously | CORRECT: Each rechecks; only valid ones proceed |
A spurious wakeup occurs when a thread waiting on a condition variable wakes up without being explicitly signaled. This may seem like a bug, but it's an intentional design choice with important performance implications.
Why Spurious Wakeups Exist
Spurious wakeups arise from the implementation of condition variables at the OS level:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Real-world spurious wakeup handling in pthreads #include <pthread.h>#include <stdbool.h> typedef struct { pthread_mutex_t mutex; pthread_cond_t condition; bool data_ready;} shared_state_t; void* consumer(void* arg) { shared_state_t* state = (shared_state_t*)arg; pthread_mutex_lock(&state->mutex); // MANDATORY: while loop handles spurious wakeups // POSIX spec explicitly states: // "Spurious wakeups from pthread_cond_wait() may occur" while (!state->data_ready) { // pthread_cond_wait atomically: // 1. Releases mutex // 2. Blocks on condition // 3. (eventually) Reacquires mutex before returning pthread_cond_wait(&state->condition, &state->mutex); // We might be here due to: // - An explicit signal (data_ready might be true) // - A spurious wakeup (data_ready is still false) // The while loop handles both correctly! } // GUARANTEED: data_ready == true && we hold the lock process_data(); pthread_mutex_unlock(&state->mutex); return NULL;} void* producer(void* arg) { shared_state_t* state = (shared_state_t*)arg; pthread_mutex_lock(&state->mutex); prepare_data(); state->data_ready = true; // Signal waiting thread(s) pthread_cond_signal(&state->condition); // We continue executing here (signal-and-continue) pthread_mutex_unlock(&state->mutex); return NULL;}The POSIX Specification
The POSIX standard for pthread_cond_wait explicitly states:
"When using condition variables there is always a Boolean predicate involving shared variables associated with each condition wait that is true if the thread should proceed. Spurious wakeups from the pthread_cond_timedwait() or pthread_cond_wait() functions may occur. Since the return from pthread_cond_timedwait() or pthread_cond_wait() does not imply anything about the value of this predicate, the predicate should be re-evaluated upon such return."
This is not a bug or limitation—it is the defined behavior. Code that does not use while loops is non-conformant with the POSIX specification.
Do not assume spurious wakeups are rare edge cases. On some systems and under certain conditions (high load, frequent interrupts), they occur regularly. Always code defensively with while loops, even if spurious wakeups seem unlikely in your testing environment.
Why Not Eliminate Spurious Wakeups?
Eliminating spurious wakeups would require:
The cost of these guarantees outweighs the benefit. Since while loops handle spurious wakeups anyway (and are required for other correctness reasons), allowing them simplifies implementation without affecting correctness.
Signal-and-continue semantics are not merely a "looser" alternative to signal-and-wait—they offer concrete performance advantages that explain their universal adoption in production systems.
Advantage 1: Fewer Context Switches
The most significant performance difference is the number of context switches:
| Operation | Signal-and-Wait | Signal-and-Continue |
|---|---|---|
| Single signal (1 waiter) | 2 switches (→waiter, →signaler) | 1 switch (→waiter when signaler exits) |
| N signals in sequence | 2N switches | N switches (batched when signaler exits) |
| Signal with no waiter | 0 switches | 0 switches |
| Signal then modify state | 2 switches + potential retry | 1 switch (state already modified) |
Each context switch involves:
On modern systems, a context switch costs 1,000 to 10,000+ CPU cycles. Halving the number of switches is a substantial gain for synchronization-heavy workloads.
Advantage 2: No Urgent Queue Overhead
Signal-and-wait requires an additional "urgent queue" data structure and priority management. Signal-and-continue needs only the standard entry queue, simplifying implementation and reducing memory overhead.
Advantage 3: Better Batching Opportunities
1234567891011121314151617181920212223242526272829303132333435
// Signal-and-continue enables efficient batching monitor TaskQueue { private tasks: Queue<Task>; condition tasksAvailable; // Producer adds multiple tasks atomically procedure addTasks(newTasks: List<Task>) { // Add all tasks while holding lock for task in newTasks { tasks.enqueue(task); signal(tasksAvailable); // Wake a worker for each // With signal-and-continue, we continue here // All signals are "queued up" } // When we release the lock, all awakened workers // compete fairly for tasks // With signal-and-wait, we would context switch // after EACH signal, serializing the additions // and drastically reducing throughput } procedure getTask(): Task { while (tasks.isEmpty()) { wait(tasksAvailable); } return tasks.dequeue(); }} // Example: Adding 100 tasks// Signal-and-wait: 200 context switches during addTasks()// Signal-and-continue: ~0 context switches during addTasks()// Workers wake when addTasks() completesAdvantage 4: Simpler Integration with OS Schedulers
Modern operating systems provide primitives (futexes, kernel condition variables) that naturally implement signal-and-continue. Implementing signal-and-wait on top of these requires additional complexity:
Signal-and-continue maps directly to OS primitives, resulting in simpler, more efficient implementations.
Every major threading library—pthreads (POSIX), Java synchronized/Object.wait(), C++ std::condition_variable, .NET Monitor, Go sync.Cond, Rust std::sync::Condvar—uses signal-and-continue semantics. The performance advantages are significant enough that no production system uses Hoare's original signal-and-wait.
Let us examine how signal-and-continue is implemented in major real-world systems.
POSIX Threads (pthreads)
The pthreads API provides pthread_cond_signal() with explicit signal-and-continue semantics:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// pthreads implementation of condition variables #include <pthread.h>#include <stdio.h> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_cond_t cond = PTHREAD_COND_INITIALIZER;int shared_resource = 0; void* waiter_thread(void* arg) { pthread_mutex_lock(&mutex); // Canon pattern: while loop + wait while (shared_resource == 0) { // pthread_cond_wait: // 1. Atomically unlocks mutex and waits on cond // 2. When signaled, reacquires mutex before returning // 3. May return spuriously (while loop handles this) pthread_cond_wait(&cond, &mutex); } printf("Got resource: %d\n", shared_resource); shared_resource = 0; // Consume pthread_mutex_unlock(&mutex); return NULL;} void* signaler_thread(void* arg) { pthread_mutex_lock(&mutex); shared_resource = 42; // Signal: move one waiter to ready queue // We continue holding the mutex pthread_cond_signal(&cond); // We can do more work here... printf("Signaled, continuing with more work\n"); do_more_work(); pthread_mutex_unlock(&mutex); return NULL;}Java's Object.wait() and notify()
Java's intrinsic locks and condition queues also use signal-and-continue:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Java implementation of condition variables public class BoundedBuffer<T> { private final Object[] buffer; private int count = 0, in = 0, out = 0; public BoundedBuffer(int capacity) { buffer = new Object[capacity]; } public synchronized void put(T item) throws InterruptedException { // Canonical while loop pattern while (count == buffer.length) { // Object.wait(): // 1. Releases the monitor lock // 2. Waits until notify()/notifyAll() // 3. Reacquires lock before returning // 4. May return spuriously! wait(); } buffer[in] = item; in = (in + 1) % buffer.length; count++; // Signal-and-continue: notify() doesn't yield notify(); // or notifyAll() // We continue here, still holding the lock // Awakened thread won't run until we exit synchronized block } @SuppressWarnings("unchecked") public synchronized T take() throws InterruptedException { // Must use while, not if! while (count == 0) { wait(); } T item = (T) buffer[out]; buffer[out] = null; out = (out + 1) % buffer.length; count--; notify(); return item; }}C++ std::condition_variable
C++11 introduced standardized threading with condition variables:
12345678910111213141516171819202122232425262728293031323334353637
// C++ std::condition_variable example #include <condition_variable>#include <mutex>#include <queue> template<typename T>class ThreadSafeQueue {private: std::queue<T> queue_; mutable std::mutex mutex_; std::condition_variable cond_; public: void push(T value) { std::lock_guard<std::mutex> lock(mutex_); queue_.push(std::move(value)); cond_.notify_one(); // Signal-and-continue // We continue, lock will be released when lock_guard destructs } T pop() { std::unique_lock<std::mutex> lock(mutex_); // wait() with predicate - handles while loop internally! // This is syntactic sugar provided by C++ cond_.wait(lock, [this] { return !queue_.empty(); }); // Equivalent to: // while (queue_.empty()) { // cond_.wait(lock); // } T value = std::move(queue_.front()); queue_.pop(); return value; }};C++ provides a convenient overload of wait() that takes a predicate lambda. This encapsulates the while loop pattern: cond.wait(lock, predicate) is equivalent to while (!predicate()) cond.wait(lock);. Use this form whenever possible—it's cleaner and harder to get wrong.
Signal-and-continue semantics lead to several common mistakes that manifest as subtle, hard-to-reproduce bugs. Understanding these patterns is essential for writing correct concurrent code.
Mistake 1: Using if Instead of while
1234567891011121314151617181920212223242526272829
// THE CLASSIC BUG monitor BuggyQueue { private queue: Queue<Item>; condition hasItems; procedure dequeue(): Item { // BUG: if instead of while if (queue.isEmpty()) { wait(hasItems); } // BUG: queue might be empty here! // Scenarios: // 1. Another consumer dequeued between signal and our lock acquisition // 2. Spurious wakeup occurred // 3. Signaler enqueued then dequeued before releasing lock return queue.remove(); // CRASH or unexpected behavior }} // FIXED:procedure dequeue(): Item { while (queue.isEmpty()) { // CORRECT wait(hasItems); } return queue.remove(); // Safe: we verified queue is non-empty}Mistake 2: Not Holding Lock During Signal
12345678910111213141516171819202122232425
// BUG: Signaling without holding the lock void producer() { mutex.lock(); shared_data = produce_item(); mutex.unlock(); // Released lock // BUG: Signaling without lock held // Race condition: between unlock and signal, // a consumer might check the condition, find it true, // AND consume the data before we even signal cond.notify_one(); // Lost signal if consumer runs first} // CORRECT:void producer() { mutex.lock(); shared_data = produce_item(); cond.notify_one(); // Signal while holding lock mutex.unlock();} // Some systems tolerate signaling without the lock,// but it's a race condition and bad practice.// Always signal while holding the associated lock.Mistake 3: Forgetting to Signal
12345678910111213141516171819202122232425262728
// BUG: Forgotten signal leads to indefinite waiting monitor LeakyBuffer { private buffer: Item[]; private count: integer = 0; condition notEmpty; condition notFull; procedure insert(item: Item) { while (count == MAX) { wait(notFull); } buffer[count++] = item; // BUG: Forgot to signal notEmpty! // Consumers will wait forever even though buffer has items } procedure remove(): Item { while (count == 0) { wait(notEmpty); // May wait forever } Item item = buffer[--count]; signal(notFull); // At least this one is present return item; }}if.*wait patterns—they are almost always bugsThe most frustrating aspect of signal-and-continue bugs is that they may not manifest in testing but appear in production under load. Code review and defensive patterns (while loops, assertions) are more reliable than testing alone.
We have comprehensively explored signal-and-continue semantics, the signaling policy used by all modern threading systems. Let us consolidate the key concepts:
while (!condition) wait(cv) to recheck conditions after waking. Never use if.What's Next
In the next page, we will formally compare Mesa vs Hoare semantics, providing a side-by-side analysis of the two signaling philosophies. We will clarify the terminology, understand the historical context, and see how the choice affects program structure.
You now understand signal-and-continue semantics in depth—the deferred activation model, the invalidation problem, the mandatory while loop pattern, spurious wakeups, and the performance reasons for universal adoption. Next, we formally compare this with signal-and-wait (Hoare semantics).