Sleep and Wake in Process Synchronization
Busy waiting is one of the most wasteful things a process can do. It just sits there in a loop, repeatedly checking whether it can proceed, burning through CPU cycles while accomplishing absolutely nothing.
The sleep and wake mechanism exists as a more sensible alternative. Instead of spinning, a process that cannot proceed simply goes to sleep and gets woken up when the situation changes.
The Two System Calls
| System Call | What It Does | When It Is Used |
|---|---|---|
| sleep() | The process suspends itself and stops consuming CPU time. It stays that way until something wakes it up. | When a process has nothing useful to do at the moment (buffer full for producer, buffer empty for consumer). |
| wakeup(pid) | Revives a specific sleeping process. The woken process gets back in the ready queue and picks up where it left off. | When a process changes something that a sleeping process was waiting for (item added or removed from buffer). |
This approach is far more efficient than busy waiting because the CPU is freed up to do other work while a process is sleeping.
The Producer-Consumer Problem
The classic way to explain sleep and wake is through the producer-consumer problem. You have a shared buffer with limited space. Producers create items and put them in. Consumers take items out. A shared count variable tracks how many items are currently in the buffer.
The rules are straightforward:
- If the buffer is full, the producer cannot add anything. It calls sleep() and waits.
- If the buffer is empty, the consumer has nothing to take. It calls sleep() and waits.
- When the producer adds an item and the consumer was sleeping, the producer calls wakeup() to nudge the consumer.
- When the consumer takes an item and frees up space and the producer was sleeping, the consumer calls wakeup() to let the producer know.
In a perfectly timed world, this works cleanly. The problem is that the real world is not perfectly timed.
The Race Condition Problem
Here is where things get messy. This is the classic "lost wakeup" problem:
Timeline of the Lost Wakeup:
1. Consumer checks buffer -> count is 0 (empty)
2. Consumer is ABOUT to call sleep()...
--- CONTEXT SWITCH (scheduler preempts consumer) ---
3. Producer adds an item -> count becomes 1
4. Producer sees count was 0 -> calls wakeup(consumer)
5. But consumer is NOT sleeping yet -> wakeup signal is LOST
--- CONTEXT SWITCH (back to consumer) ---
6. Consumer calls sleep() -> goes to sleep
7. Nobody will ever wake it up. The wakeup already happened.
... Producer eventually fills buffer, calls sleep() too ...
8. DEADLOCK: Both processes sleeping. Neither can wake the other.The Flag Bit Fix
One way to patch this specific problem is with a flag bit, a simple boolean variable:
- When the producer calls wakeup() and the consumer is not yet sleeping, the flag gets set to true instead of the signal being discarded.
- When the consumer finally gets scheduled and is about to call sleep(), it first checks the flag. If the flag is true, it knows the producer already tried to wake it up.
- It clears the flag and skips the sleep altogether, going straight to consuming.
Fixed flow with flag bit:
Consumer about to sleep:
|
+-- Check flag
| |
| +-- flag == true? -> Clear flag. Skip sleep. Proceed.
| |
| +-- flag == false? -> Actually call sleep().
|
Producer calls wakeup:
|
+-- Is consumer sleeping?
|
+-- Yes -> wake it up normally.
|
+-- No -> set flag = true (signal saved for later).Implementation in C++
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <queue>
const int BUFFER_SIZE = 5;
std::queue<int> buffer;
std::mutex mtx;
std::condition_variable producer_cv;
std::condition_variable consumer_cv;
bool wakeup_flag = false; // Flag bit to handle lost wakeup signals
void producer(int id) {
for (int i = 1; i <= 8; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
std::unique_lock<std::mutex> lock(mtx);
// Sleep if buffer is full
if ((int)buffer.size() == BUFFER_SIZE) {
std::cout << "Producer sleeping (buffer full)." << std::endl;
producer_cv.wait(lock, [] {
return (int)buffer.size() < BUFFER_SIZE;
});
std::cout << "Producer woke up." << std::endl;
}
buffer.push(i);
std::cout << "Producer " << id << " produced item " << i
<< ". Buffer size: " << buffer.size() << std::endl;
if (buffer.size() == 1) {
// Buffer was empty, consumer might be sleeping
wakeup_flag = true;
std::cout << "Producer calling wakeup for consumer." << std::endl;
consumer_cv.notify_one();
}
}
}
void consumer(int id) {
for (int i = 1; i <= 8; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(350));
std::unique_lock<std::mutex> lock(mtx);
// Check flag before sleeping
if (buffer.empty()) {
if (wakeup_flag) {
std::cout << "Consumer: flag was set, skipping sleep." << std::endl;
wakeup_flag = false;
} else {
std::cout << "Consumer sleeping (buffer empty)." << std::endl;
consumer_cv.wait(lock, [] {
return !buffer.empty() || wakeup_flag;
});
wakeup_flag = false;
std::cout << "Consumer woke up." << std::endl;
}
}
if (!buffer.empty()) {
int item = buffer.front();
buffer.pop();
std::cout << "Consumer " << id << " consumed item " << item
<< ". Buffer size: " << buffer.size() << std::endl;
if ((int)buffer.size() == BUFFER_SIZE - 1) {
std::cout << "Consumer calling wakeup for producer." << std::endl;
producer_cv.notify_one();
}
}
}
}
int main() {
std::cout << "Sleep and Wake Mechanism Simulation" << std::endl;
std::cout << "Buffer Capacity: " << BUFFER_SIZE << std::endl;
std::cout << "------------------------------------" << std::endl;
std::thread p(producer, 1);
std::thread c(consumer, 1);
p.join();
c.join();
std::cout << "------------------------------------" << std::endl;
std::cout << "Simulation complete." << std::endl;
return 0;
}Output:
Sleep and Wake Mechanism Simulation
Buffer Capacity: 5
------------------------------------
Producer 1 produced item 1. Buffer size: 1
Producer calling wakeup for consumer.
Consumer: flag was set, skipping sleep.
Consumer 1 consumed item 1. Buffer size: 0
Producer 1 produced item 2. Buffer size: 1
Consumer 1 consumed item 2. Buffer size: 0
Producer 1 produced item 3. Buffer size: 1
...
------------------------------------
Simulation complete.When Flag Bits Are Not Enough
The flag bit works fine for one producer and one consumer. Once you add more of each, the single flag cannot keep track of multiple pending wakeup signals. If three producers each try to wake a consumer, and the flag only holds one value, two of those signals get dropped.
The proper solution for multiple producers and consumers is semaphores. A semaphore can count how many wakeup signals are pending rather than just recording whether one happened or not. This makes it suitable for tracking the state of the buffer across many processes simultaneously.
| Mechanism | Handles Lost Wakeups | Multiple Producers/Consumers | Complexity |
|---|---|---|---|
| Raw sleep/wake | No. Wakeup signals can be lost. | Broken. Deadlock is possible. | Simple but dangerous. |
| sleep/wake + Flag Bit | Yes, for 1 producer and 1 consumer. | No. Single flag cannot track multiple signals. | Slightly better but limited. |
| Semaphores | Yes. Count tracks all pending signals. | Yes. Designed for N producers and M consumers. | The correct general-purpose solution. |
Why This Matters
The sleep and wake mechanism illustrates something important about concurrent programming: timing matters enormously and small windows of vulnerability in the logic can cause the entire system to deadlock.
The producer-consumer problem with sleep and wake is almost correct but not quite, and that gap between almost and correct is what drives the need for more robust primitives like semaphores and monitors. Understanding where sleep and wake falls short is what makes the more advanced solutions easier to appreciate.
The Lost Wakeup
Question 1 of 1Test your understanding of the timing flaw in sleep and wake.
