Loading content...
A binary semaphore is a semaphore whose value is restricted to 0 or 1. Despite being a special case of the counting semaphore, binary semaphores are so fundamental and widely used that they deserve dedicated study. They provide the conceptual foundation for mutual exclusion—ensuring that only one thread can access a critical section at a time.
Binary semaphores sit at a fascinating intersection: they are simple enough to reason about formally, yet powerful enough to implement any synchronization pattern. They are the building blocks from which mutexes, condition variables, and higher-level constructs can be built.
This page explores binary semaphores in depth: their semantics, the critical distinction from mutexes, their use in signaling patterns, implementation considerations, and their role as the ancestor of modern locking primitives.
By the end of this page, you will be able to: (1) Define binary semaphores precisely and explain their restriction to {0, 1}, (2) Distinguish binary semaphores from mutexes (a crucial distinction), (3) Implement mutual exclusion using binary semaphores, (4) Apply binary semaphores in signaling patterns, and (5) Understand when to choose binary semaphores over alternatives.
A binary semaphore is a semaphore S where:
The P and V operations behave as follows:
P(S):
V(S):
Note the key distinction from counting semaphores: V on a binary semaphore at value 1 is idempotent—it keeps the value at 1 rather than incrementing to 2.
| Current Value | Operation | New Value | Thread Effect |
|---|---|---|---|
| 1 | P() | 0 | Thread proceeds (acquired) |
| 0 | P() | 0 | Thread blocks (waits) |
| 0 | V() | 1 | Value set to 1, waiter woken |
| 1 | V() | 1 | No change (idempotent) |
Binary semaphores are initialized to either 0 or 1, with distinct purposes:
Initialized to 1: Mutual Exclusion
P(); critical_section(); V();Initialized to 0: Signaling/Synchronization
123456789101112131415161718192021222324252627282930
// Pattern 1: Mutual Exclusion (init = 1)binary_semaphore_t mutex;binary_sem_init(&mutex, 1); // Available void critical_operation() { P(&mutex); // Acquire (blocks if 0) // Only one thread here at a time access_shared_resource(); V(&mutex); // Release (sets to 1)} // Pattern 2: Signaling (init = 0)binary_semaphore_t event;binary_sem_init(&event, 0); // Not yet signaled // Thread A: Waitervoid wait_for_event() { P(&event); // Block until signaled // Event has occurred react_to_event();} // Thread B: Signalervoid signal_event() { do_preparation(); V(&event); // Signal (sets to 1, wakes waiter) continue_work();}Don't confuse binary semaphores with boolean variables. A boolean variable has no blocking semantics—reading 'false' just gives you false. A binary semaphore with value 0 will BLOCK the calling thread until another thread sets it to 1. The blocking behavior is the entire point; the value being binary is incidental to the semantics.
One of the most important distinctions in synchronization is between binary semaphores and mutexes. They appear similar but have fundamentally different semantics.
Binary Semaphore:
Mutex:
This difference has profound implications:
| Aspect | Binary Semaphore | Mutex |
|---|---|---|
| Ownership | None (any thread can V) | Thread-owned (only locker unlocks) |
| Value tracking | 0 or 1 | Locked/Unlocked + owner thread ID |
| V/Unlock check | Always succeeds | May fail/abort if wrong thread |
| Primary use | Signaling, simple exclusion | Mutual exclusion with ownership |
| Recursive locking | Not supported (deadlock) | Can be supported (recursive mutex) |
| Priority inheritance | Not possible (no owner) | Possible (boost owner priority) |
| Debugging | Harder (no owner info) | Easier (can identify holder) |
123456789101112131415161718192021222324252627282930313233
// Demonstrating the ownership difference // === BINARY SEMAPHORE: No ownership ===binary_semaphore_t sem;binary_sem_init(&sem, 1); void thread_A() { P(&sem); // Thread A "acquires" do_work_A(); // Thread A finishes, but imagine it crashes here...} void thread_B() { // Thread A never released! But with semaphore: V(&sem); // Thread B CAN release (no error) // This may be a bug, or intentional signaling} // === MUTEX: Ownership enforced ===mutex_t mtx;mutex_init(&mtx); void thread_A() { mutex_lock(&mtx); // Thread A owns do_work_A(); // If A crashes without unlocking, mutex is stuck // But the system KNOWS thread A owns it} void thread_B() { mutex_unlock(&mtx); // ERROR! Thread B doesn't own mtx // Behavior: undefined (crash, abort, silent corruption)}Debugging: When a deadlock occurs, a mutex can tell you which thread holds it. Binary semaphores cannot—there's no "holder."
Priority Inheritance: If Thread L holds a mutex and high-priority Thread H waits, the system can boost L's priority. With binary semaphores, there's no owner to boost.
Recursive Locking: A recursive mutex allows the same thread to lock multiple times (incrementing a count). Binary semaphores cannot support this—the second P() by the same thread would deadlock.
Error Detection: A mutex can detect unlock-without-lock or wrong-thread-unlock errors. Semaphores cannot.
Use mutexes for mutual exclusion (protecting critical sections). Use binary semaphores for signaling between threads or when ownership semantics don't apply. Using semaphores for pure mutual exclusion works but loses ownership benefits. Using mutexes for signaling (unlock from different thread) is typically an error or requires special API (e.g., Windows events).
The most common use of binary semaphores is implementing mutual exclusion—ensuring only one thread can execute a critical section at a time.
Initialize semaphore to 1, bracketing the critical section with P and V:
123456789101112131415161718192021222324
binary_semaphore_t critical_section_guard; void init() { binary_sem_init(&critical_section_guard, 1); // Available} // All threads use this pattern:void protected_operation() { // === ENTRY SECTION === P(&critical_section_guard); // Acquire exclusive access // Only ONE thread proceeds past this point at a time // === CRITICAL SECTION === // Safe to access/modify shared data read_shared_data(); modify_shared_data(); write_shared_data(); // === EXIT SECTION === V(&critical_section_guard); // Release for others // === REMAINDER SECTION === // Non-critical work}Claim: At most one thread can be in the critical section at any time.
Proof:
guard.value = 1guard.value becomes 0guard.value = 0 and blocksguard.value becomes 1Key insight: The critical section is protected because:
A critical challenge: ensuring V() is called on ALL exit paths:
12345678910111213141516171819202122232425262728293031323334353637383940414243
// BAD: V not called on error path (semaphore leak)void buggy_operation() { P(&guard); if (validate_input() != OK) { return; // BUG! guard still held, will cause deadlock } do_work(); V(&guard);} // GOOD: V called on all pathsvoid correct_operation() { P(&guard); if (validate_input() != OK) { V(&guard); // Release before return return; } do_work(); V(&guard);} // BETTER: Use cleanup mechanism (C++ RAII, Java try-finally, Go defer)// C with cleanup label:void robust_operation() { P(&guard); if (validate_input() != OK) { goto cleanup; } if (prepare_resources() != OK) { goto cleanup; } do_work(); cleanup: V(&guard); // Always executed}In languages with destructors (C++, Rust) or defer (Go), wrap P/V in a guard object: constructor calls P(), destructor calls V(). This ensures V() is called when the guard goes out of scope, even if exceptions occur. Java's try-with-resources and Python's 'with' statement serve the same purpose for semaphores implementing the appropriate interfaces.
Beyond mutual exclusion, binary semaphores excel at signaling—one thread waiting for an event that another thread will cause.
Initialize semaphore to 0. Waiter calls P() (blocks). Signaler calls V() (wakes waiter).
1234567891011121314151617181920212223242526
// One-time event signalingbinary_semaphore_t initialization_complete; void init() { binary_sem_init(&initialization_complete, 0); // Not yet complete} void initializer_thread() { // Perform lengthy initialization load_configuration(); connect_to_database(); warm_up_caches(); // Signal completion V(&initialization_complete); // Value: 0 → 1 // Can continue with other work} void worker_thread() { // Wait for initialization before starting P(&initialization_complete); // Blocks until V() // Guaranteed: initialization is complete start_processing_requests();}Two threads must both reach a point before either continues:
12345678910111213141516171819202122232425262728293031
// Rendezvous: both threads wait for each otherbinary_semaphore_t arrived_A;binary_semaphore_t arrived_B; void init() { binary_sem_init(&arrived_A, 0); binary_sem_init(&arrived_B, 0);} void thread_A() { do_work_before_rendezvous(); V(&arrived_A); // Signal: A has arrived P(&arrived_B); // Wait for B to arrive // Both are here do_work_after_rendezvous();} void thread_B() { do_work_before_rendezvous(); V(&arrived_B); // Signal: B has arrived P(&arrived_A); // Wait for A to arrive // Both are here do_work_after_rendezvous();} // Note: Order of V then P prevents deadlock// If both did P then V: deadlock (both waiting, neither signals)If V() is called multiple times before any P(), a binary semaphore stays at 1 (not 2, 3...). This means 'extra' signals are lost. For counting signals, use a counting semaphore. For complex conditions, use condition variables which don't have 'state'—they only wake currently-waiting threads.
Implementing binary semaphores raises specific considerations beyond general semaphore implementation.
The key implementation detail is preventing the value from exceeding 1:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Binary semaphore implementationtypedef struct binary_semaphore { int value; // Only 0 or 1 spinlock_t lock; wait_queue_t waiters;} binary_sem_t; void binary_sem_init(binary_sem_t *s, int initial) { assert(initial == 0 || initial == 1); s->value = initial; spinlock_init(&s->lock); wait_queue_init(&s->waiters);} void P(binary_sem_t *s) { spinlock_acquire(&s->lock); while (s->value == 0) { // Block and release lock atomically wait_queue_add(&s->waiters, current_thread); current_thread->state = BLOCKED; spinlock_release(&s->lock); schedule(); // Switch to another thread spinlock_acquire(&s->lock); // Reacquire on wakeup } // value was 1; make it 0 s->value = 0; spinlock_release(&s->lock);} void V(binary_sem_t *s) { spinlock_acquire(&s->lock); // Set value to 1 (even if already 1) s->value = 1; // NOT s->value++ (would break binary property) // Wake ONE waiter if any exist if (!wait_queue_empty(&s->waiters)) { thread_t *waiter = wait_queue_remove_first(&s->waiters); waiter->state = READY; add_to_ready_queue(waiter); } spinlock_release(&s->lock);}Some systems provide a strict binary semaphore (or BoundedSemaphore in Python) that errors if V is called when value is already 1:
12345678910111213141516171819202122232425
// Strict binary semaphore: V on value=1 is an errorint V_strict(binary_sem_t *s) { spinlock_acquire(&s->lock); if (s->value == 1) { // Already signaled - this is likely a bug spinlock_release(&s->lock); return -EOVERFLOW; // Or assert/abort } s->value = 1; if (!wait_queue_empty(&s->waiters)) { thread_t *waiter = wait_queue_remove_first(&s->waiters); waiter->state = READY; add_to_ready_queue(waiter); } spinlock_release(&s->lock); return 0;} // Python's BoundedSemaphore// >>> sem = threading.BoundedSemaphore(1)// >>> sem.release() # ValueError: Semaphore released too many times| Aspect | Standard Binary | Strict Binary | Trade-off |
|---|---|---|---|
| V on value=1 | No-op (stays at 1) | Error/exception | Strictness vs. tolerance |
| Bug detection | Bugs may be hidden | Bugs surface quickly | Debugging vs. robustness |
| Signaling use | Multiple V's okay | Must track signal state | Flexibility vs. correctness |
| Implementation | Simpler | Slightly more complex | Complexity vs. safety |
Use strict binary semaphores during development to catch bugs early. Standard binary semaphores are more forgiving but may hide logic errors. For mutual exclusion (matched P/V pairs), strictness helps catch missing P's. For signaling (where V may happen 'extra'), standard may be more appropriate.
Binary semaphores are powerful building blocks. We can construct many higher-level synchronization primitives from them.
A mutex adds ownership tracking to a binary semaphore:
123456789101112131415161718192021222324252627282930
// Mutex built from binary semaphore + ownershiptypedef struct mutex { binary_sem_t sem; thread_id_t owner; // Who currently holds it bool locked; // Is it held?} mutex_t; void mutex_init(mutex_t *m) { binary_sem_init(&m->sem, 1); // Available m->owner = INVALID_THREAD_ID; m->locked = false;} void mutex_lock(mutex_t *m) { P(&m->sem); // Acquire semaphore m->owner = current_thread_id(); m->locked = true;} int mutex_unlock(mutex_t *m) { if (!m->locked || m->owner != current_thread_id()) { return -EPERM; // Not owner! Error. } m->locked = false; m->owner = INVALID_THREAD_ID; V(&m->sem); // Release semaphore return 0;} // Now we have ownership semantics!We can even build counting semaphores from binary ones:
1234567891011121314151617181920212223242526272829303132333435363738
// Counting semaphore from binary semaphorestypedef struct counting_sem { int value; // The count binary_sem_t mutex; // Protects 'value' binary_sem_t delay; // For blocking waiters} counting_sem_t; void counting_sem_init(counting_sem_t *s, int initial) { s->value = initial; binary_sem_init(&s->mutex, 1); binary_sem_init(&s->delay, 0); // Initially blocks} void counting_P(counting_sem_t *s) { P(&s->mutex); s->value--; if (s->value < 0) { // Must wait - release mutex so others can signal V(&s->mutex); P(&s->delay); // Block here until V } else { V(&s->mutex); }} void counting_V(counting_sem_t *s) { P(&s->mutex); s->value++; if (s->value <= 0) { // There are waiters (value was negative) V(&s->delay); // Wake one } V(&s->mutex);} // Note: This implementation uses "negative value = waiter count" semanticsThis ability to build other primitives demonstrates that binary semaphores are computationally universal for synchronization—any synchronization problem solvable with other primitives can be solved with binary semaphores alone.
However, this doesn't mean you should always use raw binary semaphores:
Binary semaphores are to synchronization what assembly language is to programming: powerful, foundational, and sometimes necessary, but often better encapsulated in higher-level abstractions. Understand them deeply so you can debug and implement when needed, but prefer higher-level primitives for application code.
Binary semaphore availability and semantics vary across platforms. Let's examine the major systems.
POSIX doesn't distinguish binary from counting semaphores at the API level—both use sem_t. You create a binary semaphore by initializing with 1:
1234567891011121314151617181920
#include <semaphore.h> // POSIX: Binary semaphore is just sem_t with value 0 or 1sem_t binary_sem; void init_as_mutex() { sem_init(&binary_sem, 0, 1); // pshared=0 (same process), value=1} void init_as_signal() { sem_init(&binary_sem, 0, 0); // value=0, will block until signaled} // Note: POSIX doesn't enforce the binary property!// You could call sem_post() multiple times and value becomes 2, 3, ...// It's up to you to use it correctly as binary // For true mutex semantics with ownership, use pthread_mutex_t:#include <pthread.h>pthread_mutex_t real_mutex = PTHREAD_MUTEX_INITIALIZER;C++20 introduced std::binary_semaphore as a standard library type:
12345678910111213141516171819202122232425262728
#include <semaphore>#include <thread> // C++20 explicit binary semaphore typestd::binary_semaphore signal_sem{0}; // For signaling (init 0)std::binary_semaphore mutex_sem{1}; // For exclusion (init 1) // Signaling examplevoid producer() { do_preparation(); signal_sem.release(); // V operation (C++ naming)} void consumer() { signal_sem.acquire(); // P operation process_result();} // Mutex usage (but prefer std::mutex for this)void protected_operation() { mutex_sem.acquire(); // Lock // Critical section modify_shared_data(); mutex_sem.release(); // Unlock} // std::binary_semaphore is typedef for std::counting_semaphore<1>// The template parameter is the maximum value (1 for binary)| Platform | Type/API | Binary Enforcement | Notes |
|---|---|---|---|
| POSIX | sem_t with value 0/1 | Not enforced | Same API as counting |
| C++20 | std::binary_semaphore | Compile-time max 1 | typedef for counting_semaphore<1> |
| Java | Semaphore(1) | Not enforced | Same class, just permits=1 |
| Python | BoundedSemaphore(1) | Runtime enforced | Raises ValueError on over-release |
| Windows | CreateSemaphore(1, 1) | lMaximumCount enforced | Separate max from initial |
| FreeRTOS | xSemaphoreCreateBinary() | Separate type | Optimized binary implementation |
In embedded RTOS like FreeRTOS, binary semaphores often have optimized implementations separate from counting semaphores. They use less memory and have faster operations since they don't need to track a count. If your platform offers a dedicated binary semaphore type, prefer it over counting semaphores initialized to 1.
We have explored binary semaphores comprehensively—their definition, the critical distinction from mutexes, usage patterns, and implementation details. Let's consolidate the key insights:
Congratulations! You have mastered the fundamental concepts of semaphores—Dijkstra's breakthrough invention, the P (wait) and V (signal) operations, counting semaphores for resource management, and binary semaphores for mutual exclusion and signaling. You now possess the foundational knowledge to understand and solve the classic synchronization problems explored in subsequent modules: producer-consumer, readers-writers, and dining philosophers.