Semaphores & Condition Variables
Counting semaphores, producer-consumer problem, and CV signaling
Semaphores & Condition Variables
Mutexes solve the problem of mutual exclusion—ensuring that only one thread accesses a shared resource at a time. But what about problems that require more expressive synchronization? What if you need to coordinate a fixed number of concurrent workers, signal from one thread to another that a condition has been met, or implement a producer-consumer pipeline where multiple threads process items at different rates?
This is where semaphores and condition variables come in. These are the higher-level synchronization primitives that let you express coordination patterns beyond simple mutual exclusion. Understanding them is essential for building any real concurrent system.
Overview
A semaphore is an integer counter that supports two atomic operations: wait (decrement) and signal (increment). When a thread calls wait and the counter is greater than zero, it decrements the counter and continues. When the counter is zero, the thread blocks until another thread calls signal. The key property that makes this work is that both operations are atomic—no two threads can simultaneously decrement past each other and miss a signal.
A condition variable is a synchronization primitive that lets a thread wait for an arbitrary condition (expressed as a boolean expression) to become true. Unlike a semaphore, which has only a counter, a condition variable is associated with a mutex and is used to wait for state changes that other threads control. You wait() on a condition variable while holding a lock, and another thread signal() or broadcast() when the state changes.
When to Use / When Not to Use
Use semaphores when: you need to limit concurrent access to a resource that can be safely used by N threads simultaneously (a counting semaphore with value N). Classic use cases include limiting the number of simultaneous connections to a database pool, restricting parallel file I/O operations, and implementing producer-consumer patterns where a bounded buffer has a fixed capacity.
Use condition variables when: you need to wait for a condition that depends on shared state. You must hold the associated mutex while calling wait(). The pattern is: acquire mutex, check condition, wait if false (atomically releasing the mutex and going to sleep), wake up, re-acquire mutex, check condition again. This is the standard monitor pattern used in nearly every multi-threaded coordination scenario.
Do not use a semaphore as a mutex. A binary semaphore (value 0 or 1) can be used for mutual exclusion, but it lacks the guarantee that the same thread that acquired the lock will be the one to release it. Mutexes enforce ownership semantics; semaphores do not. Using a semaphore as a mutex leads to subtle correctness bugs.
Do not use condition variables without a predicate. The spurious wakeup problem means that wait() can return even though no thread called signal(). Your condition check must be in a while loop, not an if statement.
Architecture or Flow Diagram
graph TD
A[Producer puts item in buffer] --> B{Buffer full?}
B -->|yes| C[Wait on semaphore empty]
B -->|no| D[Put item in buffer]
C --> E[Decrement empty count<br/>Sleep until signaled]
D --> F[Increment fill count]
F --> G[Signal full to wake Consumer]
G --> H[Consumer woken]
H --> I[Wait on full semaphore]
I --> J[Decrement fill count]
J --> K[Get item from buffer]
K --> L[Signal empty to wake Producer]
The producer-consumer pattern with a bounded buffer: the empty semaphore tracks how many empty slots exist (initialized to N), and the full semaphore tracks how many filled slots exist (initialized to 0). When the buffer is full, producers wait on empty and sleep. When consumers remove items, they signal on empty to wake producers.
Core Concepts
Counting Semaphores
A semaphore wraps an integer counter with two atomic operations:
wait()(oracquire()): If counter > 0, decrement and return immediately. If counter == 0, block until another thread increments.signal()(orrelease()): Increment the counter. If any threads are waiting, wake one of them.
With counter value 1, you have a binary semaphore—works like a mutex but without ownership semantics. With counter value N, you have a counting semaphore allowing up to N concurrent accesses.
Producer-Consumer with Bounded Buffer:
#include <semaphore.h>
#include <pthread.h>
#define BUFFER_SIZE 10
#define NUM_ITEMS 100
typedef struct {
sem_t empty; // Counts empty slots
sem_t full; // Counts filled slots
pthread_mutex_t mutex;
int buffer[BUFFER_SIZE];
int in;
int out;
} bounded_buffer_t;
void buffer_init(bounded_buffer_t *b) {
sem_init(&b->empty, 0, BUFFER_SIZE); // All slots empty initially
sem_init(&b->full, 0, 0); // No slots full initially
pthread_mutex_init(&b->mutex, NULL);
b->in = 0;
b->out = 0;
}
void buffer_produce(bounded_buffer_t *b, int item) {
sem_wait(&b->empty); // Wait for empty slot
pthread_mutex_lock(&b->mutex);
b->buffer[b->in] = item;
b->in = (b->in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&b->mutex);
sem_post(&b->full); // Signal that a slot is now full
}
int buffer_consume(bounded_buffer_t *b) {
sem_wait(&b->full); // Wait for full slot
pthread_mutex_lock(&b->mutex);
int item = b->buffer[b->out];
b->out = (b->out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&b->mutex);
sem_post(&b->empty); // Signal that a slot is now empty
return item;
}
The mutex protects the circular buffer data structure. The two semaphores track resource availability (empty slots vs. full slots) and handle blocking/waking without requiring the producer to poll.
Condition Variables
A condition variable is always paired with a mutex. The pattern:
- Acquire the mutex
- Check the condition (in a while loop!)
- Call
pthread_cond_wait()which atomically releases the mutex and goes to sleep - When woken, re-acquire the mutex and re-check the condition
#include <pthread.h>
typedef struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
int item_available;
int shutdown_requested;
} shared_state_t;
void *consumer(void *arg) {
shared_state_t *s = arg;
pthread_mutex_lock(&s->mutex);
while (!s->item_available && !s->shutdown_requested) {
pthread_cond_wait(&s->cond, &s->mutex); // Atomically: release mutex, sleep
}
if (s->shutdown_requested) {
pthread_mutex_unlock(&s->mutex);
return NULL;
}
int item = s->item_available; // Consume the item
s->item_available = 0;
pthread_mutex_unlock(&s->mutex);
return (void *)(intptr_t)item;
}
void producer_signal(shared_state_t *s, int item) {
pthread_mutex_lock(&s->mutex);
s->item_available = item;
pthread_mutex_unlock(&s->mutex);
pthread_cond_signal(&s->cond); // Wake ONE waiting thread (broadcast for all)
}
Why the while loop? POSIX guarantees spurious wakeups—wait() can return without signal() being called. This happens because the kernel’s futex implementation cannot always distinguish “this thread was genuinely signaled” from “this thread was kicked out of a futex queue for some other reason.” The correct pattern is always while (!condition) pthread_cond_wait(...), never if (!condition).
Signal vs. Broadcast
signal()(orpthread_cond_signal()): Wakes exactly one waiting thread. If multiple threads are waiting, the scheduler picks one—no guaranteed order.broadcast()(orpthread_cond_broadcast()): Wakes all waiting threads. Use when the condition change affects all waiters (e.g., “the shared resource has been updated, everyone should re-check”).
A common mistake: using signal() when the state change means “all waiters should proceed” (e.g., a shutdown notification). If multiple consumers should stop, use broadcast.
Production Failure Scenarios + Mitigations
The Semaphore Initialization Bug (Linux 2.6.32)
In some kernel versions, a race condition existed in the semaphore wakeup path that could cause a waiting thread to sleep indefinitely even after up() was called. The bug was in the interaction between the spinlock protecting the wait queue and the wakeup logic. It manifested as threads appearing to hang with 100% CPU usage (spinning in the sleep path). The fix involved reordering operations in the semaphore release path to ensure the wakeup was delivered before the semaphore counter was incremented.
Mitigation: Always use well-tested semaphore implementations from your language’s standard library. Do not implement your own semaphore primitives from atomics—the edge cases (especially wakeup ordering) are subtle and have caused real-world bugs in production kernels.
The Missing Broadcast in Connection Pool Shutdown
A connection pool implementation used a condition variable to signal when a connection became available. During shutdown, signal() was called to wake a single waiter. But three threads were waiting for connections, and one of them had the shutdown flag set. The signal went to the wrong thread, the shutdown thread remained blocked, and the pool’s shutdown method deadlocked waiting for all threads to terminate.
Mitigation: Use broadcast() for shutdown notifications so that all waiters see the shutdown request regardless of which one is chosen by the scheduler. Always validate the condition after waking—never assume the signal reached your specific thread for your specific reason.
The Lost Wakeup in Python threading.Event
Python’s threading.Event is implemented using a condition variable plus a boolean flag. A common user error: checking event.is_set() in a while loop, then calling event.wait() with a timeout. If the event is set between the check and the wait, the wait blocks indefinitely (the event was already “consumed” by the check). This is not a library bug—it’s a semantic subtlety in how events work vs. condition variables.
Mitigation: If you need persistent signaling (meaning the event stays set and all waiters see it), use threading.Event which maintains state. If you need transient signaling (notify waiters of a state transition), use a condition variable with a separate state variable you control.
Trade-off Table
| Aspect | Counting Semaphore | Binary Semaphore | Condition Variable + Mutex |
|---|---|---|---|
| Use case | Limited resource pool (N workers) | Simple signaling / mutual exclusion | Arbitrary condition coordination |
| Blocking semantics | Blocks on decrement when count=0 | Blocks when trying to “acquire” set semaphore | Blocks on wait until signal/broadcast |
| Ownership enforced | No — any thread can signal | No — any thread can signal | Yes — must hold associated mutex |
| Broadcast capability | Via post to all waiters (complex) | Via multiple signals | Native via broadcast() |
| State persistence | Counter persists until reset | State persists (set/clear) | State is external (your boolean) |
| Complexity | Low | Low | Medium |
Implementation Snippets
Python: Semaphore-Based Thread Pool
import threading
import queue
class ThreadPool:
def __init__(self, num_workers):
self._workers = num_workers
self._task_queue = queue.Queue()
self._semaphore = threading.Semaphore(num_workers)
self._threads = []
def submit(self, task, *args):
self._task_queue.put((task, args))
def start(self):
for _ in range(self._workers):
t = threading.Thread(target=self._worker)
t.start()
self._threads.append(t)
def _worker(self):
while True:
task, args = self._task_queue.get()
try:
task(*args)
finally:
self._task_queue.task_done()
def shutdown(self):
self._task_queue.join() # Wait for all tasks to complete
Python: Condition Variable for Producer-Consumer
import threading
class BlockingQueue:
def __init__(self, maxsize=0):
self._mutex = threading.Lock()
self._not_empty = threading.Condition(self._mutex)
self._not_full = threading.Condition(self._mutex)
self._queue = []
self._maxsize = maxsize
def put(self, item, timeout=None):
with self._not_full:
while len(self._queue) >= self._maxsize:
if not self._not_full.wait(timeout=timeout):
return False # Timed out
self._queue.append(item)
self._not_empty.notify() # Wake one consumer
return True
def get(self, timeout=None):
with self._not_empty:
while not self._queue:
if not self._not_empty.wait(timeout=timeout):
return None # Timed out
item = self._queue.pop(0)
self._not_full.notify() # Wake one producer
return item
C: Condition Variable with Predicate
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
int counter;
int done;
} shared_data_t;
void wait_for_count(shared_data_t *s, int target) {
pthread_mutex_lock(&s->mutex);
while (s->counter < target) { // ALWAYS while, never if!
pthread_cond_wait(&s->cond, &s->mutex);
}
pthread_mutex_unlock(&s->mutex);
}
void increment_and_signal(shared_data_t *s) {
pthread_mutex_lock(&s->mutex);
s->counter++;
if (s->counter >= 10) {
s->done = 1;
pthread_cond_broadcast(&s->cond); // Wake all waiters
}
pthread_mutex_unlock(&s->mutex);
}
Observability Checklist
- Semaphore wait time: How long threads spend blocked on
sem_wait(). Alert if p99 > 10ms for production systems - Queue depth histogram: For producer-consumer patterns, track buffer occupancy to detect producer starvation or consumer overload
- Signal-to-wake latency: Time between
signal()call and waiting thread resuming. High latency indicates scheduler pressure - Spurious wakeup rate: Count how often
wait()returns without a correspondingsignal(). High rates suggest incorrect condition variable usage - Thread count: Track how many threads are in a wait state vs. running state
- Lock order inversion: Use tools like helgrind or ThreadSanitizer to detect lock order violations that can lead to deadlock with condition variables
Security/Compliance Notes
Resource exhaustion via semaphore: If an untrusted process can call sem_wait() on a semaphore that is only incremented by a trusted process, a malicious actor could decrement the counter to zero and cause denial of service for all threads depending on it. Use proper permissions on named semaphores and never expose semaphore file descriptors to untrusted code.
Condition variable race on shutdown: When shutting down a multi-threaded system, ensure all condition variable waits are terminated before the associated mutex is destroyed. Destroying a mutex while threads are waiting on its condition variable is undefined behavior and can lead to crashes.
For compliance: any synchronization primitive protecting access to sensitive data (PII, financial records) must be documented. Use static analysis to find missing signal/broadcast calls on condition variables—threads waiting on an unset condition will sleep forever.
Common Pitfalls / Anti-patterns
1. Using if instead of while for condition variable wait. Spurious wakeups mean wait() can return even without signal(). Always re-check the condition in a while loop: while (!condition) cv.wait().
2. Calling signal() without holding the mutex. On some implementations (including POSIX), calling pthread_cond_signal() without holding the associated mutex produces undefined behavior. Always hold the mutex when signaling.
3. Broadcasting when signaling would suffice. broadcast() forces all waiting threads to wake and re-acquire the mutex, causing contention. If only one waiter should act on the condition, use signal().
4. Forgetting to call sem_post() after sem_wait(). If a semaphore is never incremented after being decremented, all subsequent waiters block forever. Use RAII wrappers or structured scope guards for semaphore operations.
5. Using a semaphore where a condition variable is needed. A semaphore with value 1 used as a mutex has no ownership semantics—if Thread A acquires and Thread B signals, Thread B can release a lock it never owned. Use condition variables + mutex when you need stateful waiting.
Quick Recap Checklist
- Semaphores provide atomic increment/decrement on an integer counter; threads block when decrementing a zero counter
- Use counting semaphores to limit concurrent access to N resources (e.g., connection pools, worker pools)
- Condition variables always use a while loop for the condition check due to spurious wakeups
- Always hold the associated mutex when calling signal() or broadcast()
- Use broadcast() for shutdown notifications so all waiters see the signal
- Do not use a semaphore as a mutex—it lacks ownership semantics
- Semaphores can be used to implement barriers (initialize to N, wait N times)
Interview Questions
The producer-consumer problem involves one or more producer threads creating items and placing them in a bounded buffer, and one or more consumer threads removing items from that buffer. The challenge is that producers must stop when the buffer is full, and consumers must stop when the buffer is empty—all without polling.
Semaphores solve this by encoding buffer capacity in the counter itself. Initialize two semaphores: empty with value N (number of slots) and full with value 0 (filled slots). Producers call wait(empty) before inserting (decrementing available slots), and signal(full) after inserting (incrementing filled slots). Consumers call wait(full) before removing and signal(empty) after removing. When the buffer is full, empty is zero and producers block. When empty, full is zero and consumers block. The mutex protects the actual buffer data structure against race conditions.
POSIX guarantees spurious wakeups—the kernel can return from pthread_cond_wait() without any corresponding signal() call. The underlying futex implementation cannot always distinguish "this thread was legitimately signaled" from "this thread was evicted from the futex wait queue for scheduling reasons." Because of this, you must always re-check the condition after waking: while (!condition) wait().
Using if instead of while means that on a spurious wakeup, your code would proceed assuming the condition was met, when in fact it was not. This leads to incorrect behavior, corrupted state, or infinite loops depending on what your code does with the (unfulfilled) condition.
signal() wakes exactly one waiting thread (if any are waiting). The scheduler picks one—no specific order is guaranteed. broadcast() wakes all waiting threads. Use signal() when only one waiter should act on the condition (e.g., "one item is available, the next consumer should take it"). Use broadcast() when the condition change affects all waiters and they all need to re-evaluate it (e.g., "shutdown requested," "configuration changed"). A common mistake is using signal() for a broadcast scenario, causing some waiters to miss the notification and hang forever.
A barrier synchronizes N threads so that none can proceed past the barrier until all have reached it. Implementation: initialize a counting semaphore with value 0 and a mutex with value 1. Each thread arriving at the barrier does: acquire the mutex, increment a counter, release the mutex. If the counter is less than N, the thread then waits on the semaphore (blocking). When the Nth thread arrives, it acquires the mutex, increments the counter, sees it equals N, and calls signal() N times (or uses a loop) to wake all the other threads. Alternatively, use a semaphore with value N and have each thread do wait() once and signal() once—but this requires careful tracking to ensure all have both waited and signaled before proceeding past the barrier point.
Use a condition variable when you need to wait for an arbitrary boolean condition that depends on shared state. The condition is evaluated in a while loop under a mutex, and you wait on the condition variable until another thread changes the state and signals. Use a semaphore when you are managing a count of available resources—you want threads to block when the count is zero and unblock when it becomes non-zero.
A good heuristic: if your problem can be expressed as "I need N of something," use a semaphore. If it can be expressed as "I need to wait until some state changes," use a condition variable. Condition variables are more expressive because the condition is arbitrary code, not just a count comparison. Semaphores are simpler to reason about for resource management because the counter itself encodes the complete state.
On Linux, semaphores use the futex (fast userspace mutex) syscall for waiting and waking. A semaphore maintains an atomic counter in userspace — the wait() operation checks the counter and only calls into the kernel if the counter is zero (blocking case). When signal() increments the counter from zero to one, it calls the futex_wake syscall to wake a waiting thread. This design minimizes kernel transitions for the common (non-contended) case.
The atomicity of the counter operations is guaranteed by hardware atomic instructions (compare-and-swap or load-linked/store-conditional on RISC architectures). When multiple threads contend, the kernel's futex wakeup ensures exactly one waiter is awakened per signal, even if the wakeup arrives simultaneously with other wait operations. This prevents the classic "lost wakeup" problem where a signal arrives between a thread checking the counter and blocking.
A monitor is a higher-level synchronization construct — a monitor object packages shared state, a mutex protecting that state, and condition variables for waiting on state changes, all into a single logical unit. Languages like Java and Mesa implement monitors by having each object contain an associated mutex and condition variable. Methods of the monitor automatically acquire the mutex on entry and release it on exit.
The condition variable in a monitor is used exactly as described earlier: you wait() on a condition while holding the monitor lock, which atomically releases the lock and goes to sleep. When another method calls notify(), the waiting thread wakes up, re-acquires the lock, and re-evaluates the condition. This automatic mutex management is the main convenience of monitors over raw condition variables — you cannot forget to hold the lock because the language enforces it.
Yes, a binary semaphore (value 0 or 1) can be implemented using a condition variable and a mutex with a counter. The implementation maintains an integer counter (0 or 1), a mutex, and a condition variable. The wait() operation locks the mutex, then waits on the condition while the counter is 0. The signal() operation locks the mutex, sets the counter to 1, signals the condition variable, and unlocks the mutex.
However, this differs from a true semaphore in one critical way: a mutex enforces ownership, meaning the same thread must acquire and release the lock. A binary semaphore has no ownership requirement — any thread can signal a binary semaphore regardless of which thread waited. A condition-variable-plus-mutex implementation would enforce ownership and is thus more like a mutex than a semaphore. For a true binary semaphore, you need atomic operations or platform-specific futex support without mutex semantics.
Mesa semantics (used by most modern systems including POSIX, Java, and Windows): when you receive a signal on a condition variable, you are moved from the wait queue to the ready queue, but you do NOT immediately acquire the lock. You must re-acquire the lock and re-check your condition. Multiple threads can be woken by one signal under Mesa semantics.
Hoare semantics (used by historical systems like early Dijkstra's monitors): the signaling thread gives up the lock directly to the waiting thread, which runs immediately upon waking. The waiting thread releases the lock when it waits again or exits the monitor. Only one thread is woken per signal, and that thread runs before any other thread can acquire the lock.
Hoare provides stronger guarantees but is harder to implement efficiently. Mesa is the practical choice — it is what POSIX gives you. The implication: always re-check your condition in a while loop because with Mesa semantics, by the time you wake up and acquire the lock, the state may have changed again.
A thread pool has N worker threads consuming tasks from a bounded queue, with producers adding tasks. The semaphore tasks starts at 0 and counts available tasks. Workers call wait(tasks) to block when the queue is empty. Producers add a task, call signal(tasks) to wake a worker, and use a mutex to safely push to the queue.
For bounded capacity, a second semaphore slots (initialized to the queue capacity) counts available slots. Producers wait on slots before adding (blocking when the queue is full) and signal tasks after adding. Workers signal slots after consuming a task. A mutex protects the queue data structure for the actual push/pop operations. This prevents both queue overflow and underflow while allowing producers and consumers to block when the queue is at its limits.
A lost wakeup occurs when a thread calls signal() on a condition variable but no thread is waiting at that moment — the waiting thread has not yet called wait(). When the waiting thread eventually calls wait(), it blocks forever because the signal has already been consumed.
This happens in race conditions where a producer sets a flag and signals before the consumer has entered the wait state. The consumer has not yet called wait(), so the signal is lost. The standard prevention pattern: always check the condition in a while loop, not an if. The consumer wakes up, re-checks the condition, and if the condition is already satisfied (the flag is set), it does not wait — it proceeds. This way, even if the signal was lost, the consumer still makes progress because it sees the state change.
If N threads are waiting and you know N threads should be woken, calling signal() N times requires N userspace-to-kernelspace transitions (each signal() potentially triggering a futex_wake syscall). broadcast() makes a single syscall that wakes all threads in the wait queue, which is significantly more efficient when many threads need to be woken.
More importantly, with N individual signals, the order in which waiters are woken is non-deterministic. With broadcast, all waiters are released simultaneously (or in rapid succession). If your use case requires all waiters to see a state change (e.g., a broadcast "shutdown now" notification), broadcast is both more correct and more efficient than multiple signals.
pthread_cond_wait(&cv, &mutex) blocks indefinitely until the condition is signaled. pthread_cond_timedwait(&cv, &mutex, &abstime) blocks until the condition is signaled OR until the absolute time specified in abstime is reached. Both atomically release the mutex and enter the wait state.
The return value distinguishes them: timedwait returns ETIMEDOUT if the timeout expired without a signal, versus 0 if it was signaled. timedwait is essential for implementing watchdog timers, connection pool timeouts, and deadlock detection in production systems. Never use sleep() instead of timedwait() because sleep would hold the mutex for the entire duration, blocking other threads.
Spurious wakeups exist because the underlying futex implementation on Linux (and similar mechanisms on other kernels) cannot always distinguish "this thread was legitimately woken by a signal" from "this thread was kicked out of the futex wait queue due to a queue manipulation operation (like a requeue or other internal operation)." The kernel errs on the side of waking a thread rather than risking a lost wakeup, so it occasionally wakes a thread that has not been signaled.
The correct defense is always a while loop: while (!condition) pthread_cond_wait(&cv, &mutex). On a spurious wakeup, the condition is still false, so the thread goes back to waiting. This is not a bug in your code — it is a documented POSIX behavior that must be handled. Using if instead of while is a classic concurrency bug that is extremely difficult to reproduce in testing because spurious wakeups are infrequent and non-deterministic.
Yes. Use a counting semaphore to control access and a mutex to protect the reader count. The semaphore is initialized to 1 (one writer or set of readers at a time). A readers counter tracks how many readers are active. To acquire a read lock: atomically increment readers (under mutex), and if this is the first reader (readers == 1), wait on the semaphore to block writers. To release a read lock: decrement readers under mutex, and if this is the last reader (readers == 0), signal the semaphore to allow a writer through.
To acquire a write lock: wait on the semaphore (blocks until no readers). To release: signal the semaphore. This is a read-preferring implementation — new readers are admitted while a writer is waiting only if they arrive before the writer successfully decrements the semaphore. More complex fairness mechanisms require additional tracking.
The Thundering Herd problem occurs when many threads are waiting on a condition variable and a signal wakes all of them — but only one can actually make progress. All N threads wake up, all try to acquire the same mutex, N-1 lose and go back to waiting, all call wait() again, generating more kernel overhead. This can cause significant performance degradation under high contention.
Solutions: If only one waiter should act on the condition, use signal() (which wakes exactly one thread) rather than broadcast(). If multiple waiters should act, but the work can be distributed, use a work-stealing pattern where each woken thread claims a unit of work. If broadcasting is truly required (all must re-evaluate the condition), ensure your condition check is fast so contention on the mutex is brief.
A mutex is owned by a specific thread. Only the thread that acquired a mutex can release it — this ownership is enforced by the implementation. If a thread tries to unlock a mutex it does not own, undefined behavior results. Most mutex implementations also track whether the same thread has locked it multiple times (re-entrancy / recursive mutexes) to allow nested critical sections.
A semaphore has no ownership concept. Any thread can signal a semaphore regardless of which thread waited on it. This is both a strength (flexibility in signaling from interrupt handlers, signal handlers, or different subsystems) and a risk (no guarantee that the thread waking another thread is "responsible" for the state change). Semaphores are not re-entrant by design — concurrent wait and signal operations on the same semaphore are subject to race conditions on the counter.
A periodic timer waits for a duration, performs an action, and repeats. Implementation: have a timer thread that locks a mutex, calculates the next absolute wake time (current time + period), and calls pthread_cond_timedwait() with that absolute time. When it returns (either by signal or timeout), it does the work, calculates the next deadline, and loops.
The key is using absolute time (CLOCK_REALTIME or CLOCK_MONOTONIC) with timedwait rather than relative durations. If your work takes time, using relative durations would cause drift — each interval would be work time plus the duration, accumulating error. Using absolute deadlines ensures intervals are consistent regardless of work duration. Use CLOCK_MONOTONIC for timers that should not jump when the system clock changes.
POSIX condition variables on Linux are implemented using futexes (fast userspace mutex). A futex is a kernel syscall (futex()) that provides fast userspace waiting and waking without entering the kernel if no wait/wake contention occurs. When a thread calls pthread_cond_wait(), it first acquires the associated mutex, checks the condition, and if the condition is false, calls into the kernel via futex(FUTEX_WAIT, ...) which atomically releases the mutex and goes to sleep.
When another thread calls pthread_cond_signal(), it acquires the mutex, changes the state, and calls futex(FUTEX_WAKE, ...) to wake one waiting thread. The woken thread returns from futex_wait, re-acquires the mutex, and continues. This two-tier approach (userspace for the non-contended case, kernel for contention) gives condition variables their performance characteristics.
Common bugs include: Calling signal() without holding the mutex — on some implementations this produces undefined behavior; always hold the mutex when signaling. Forgetting to initialize semaphores — uninitialized semaphores have arbitrary values and cause random failures. Mixing up semaphore and condition variable semantics — using a semaphore counter as a boolean flag for a condition variable leads to lost wakeups because semaphore counters don't retain state after wait (the counter decrements). Signal before wait — signaling before waiting loses the signal; always check conditions in a while loop. Not destroying condition variables — leaking kernel resources; always call pthread_cond_destroy() when done. Deadlock from lock ordering — when using multiple mutexes with condition variables, always acquire them in a consistent order.
Further Reading
- Concurrency Fundamentals — The problem space and why synchronization is needed
- Mutex Implementation — How mutexes are implemented in userspace and kernel
- Semaphores — Counting semaphores for resource management
- Readers-Writer Locks — Optimizing for read-heavy workloads
- Lock-Free Structures — Advanced techniques for highly concurrent systems
Conclusion
Semaphores and condition variables extend the basic mutual exclusion model into expressive coordination primitives. Semaphores excel at managing access to a fixed pool of resources—a counting semaphore with value N enforces that no more than N threads can acquire the resource simultaneously. Condition variables solve the harder problem of waiting for arbitrary state changes that other threads control, but only when used correctly: always with a while loop for spurious wakeup protection and always while holding the associated mutex.
The producer-consumer pattern with bounded buffers demonstrates the full power of these primitives working together. Beyond that, semaphores implement barriers, readers-writers problems, and thread pools. Condition variables are the foundation of monitors, which are the dominant synchronization model in higher-level languages. When designing concurrent systems, reach for condition variables when your problem involves “wait until X becomes true” and semaphores when it involves “ensure no more than N workers access this resource simultaneously.”
For continued learning, explore the relationship between condition variables and the broader monitor pattern, study semaphore implementations in Linux (which use futexes internally), and examine how asyncio in Python implements its condition-event primitives without native CV support.
Category
Related Posts
ASLR & Stack Protection
Address Space Layout Randomization, stack canaries, and exploit mitigation techniques
Assembly Language Basics: Writing Code the CPU Understands
Learn to read and write simple programs in x86 and ARM assembly, understanding registers, instructions, and the art of thinking in low-level operations.
Boolean Logic & Gates
Understanding AND, OR, NOT gates and how they combine into arithmetic logic units — the building blocks of every processor.