A synchronization primitive that controls access to shared resources through a counter mechanism, preventing race conditions in concurrent programs.
Overview
Semaphores are synchronization primitives that manage access to shared resources in concurrent systems. Edsger Dijkstra introduced semaphores in 1965 as a solution to critical section problems in operating systems. A semaphore maintains an integer counter that represents the number of available resource units and provides atomic operations to increment or decrement this counter.
The semaphore mechanism operates on two fundamental operations: wait (P operation, from Dutch "proberen" meaning "to test") and signal (V operation, from "verhogen" meaning "to increment"). When a thread requests a resource through the wait operation, the semaphore decrements its counter. If the counter reaches zero, subsequent wait operations block until another thread releases a resource through the signal operation.
Semaphores solve coordination problems that simpler mechanisms like mutexes cannot address alone. While a mutex provides binary locking (locked or unlocked), a semaphore counts multiple resource units. This distinction makes semaphores suitable for scenarios like connection pools, thread pools, or any situation requiring control over multiple identical resources.
# Conceptual semaphore behavior
semaphore = Semaphore.new(3) # Initialize with 3 permits
# Thread 1
semaphore.wait # Counter: 3 -> 2
use_resource
semaphore.signal # Counter: 2 -> 3
# Thread 2
semaphore.wait # Counter: 2 -> 1
use_resource
semaphore.signal # Counter: 1 -> 2
The counter value determines semaphore behavior. A positive counter indicates available resources. Zero means all resources are in use but no threads are waiting. A negative counter (in some implementations) represents the number of blocked threads waiting for resources.
Semaphores appear in two primary forms: counting semaphores and binary semaphores. Counting semaphores manage multiple resource units with a counter that can range from zero to an arbitrary maximum. Binary semaphores restrict the counter to zero or one, functioning similarly to mutexes but with different semantics around ownership and signaling.
Key Principles
Semaphores enforce mutual exclusion and synchronization through atomic operations on an integer counter. The atomicity of wait and signal operations guarantees that no race conditions occur during counter modifications. The operating system or runtime ensures these operations complete without interruption, making them safe for concurrent access.
The wait operation tests the counter value and blocks if no resources are available:
wait(S):
while S.value <= 0:
block current thread
S.value = S.value - 1
The signal operation increments the counter and wakes one blocked thread if any exist:
signal(S):
S.value = S.value + 1
if threads are blocked on S:
wake one thread
The semaphore counter represents resource availability, not resource ownership. Unlike mutexes where the locking thread must unlock, any thread can signal a semaphore regardless of which thread called wait. This property enables coordination patterns where producer threads signal consumer threads without symmetric pairing.
Semaphore initialization establishes the maximum concurrency level. A semaphore initialized to N allows N threads simultaneous access to the protected resource. Setting the initial value to 1 creates a binary semaphore for mutual exclusion. Setting it to 0 creates a semaphore for pure synchronization where threads wait until another thread signals.
The fairness property determines how blocked threads receive resources when they become available. Fair semaphores use FIFO queuing to wake threads in the order they blocked. Unfair semaphores may wake any blocked thread, potentially causing starvation where some threads wait indefinitely while others repeatedly acquire resources.
Semaphores operate at a lower level than monitors or condition variables. They provide building blocks for constructing higher-level synchronization mechanisms. The simplicity of semaphores makes them efficient but also error-prone, as developers must manually ensure proper pairing of wait and signal operations.
The counting capability distinguishes semaphores from binary locks. A counting semaphore tracks multiple identical resources, such as database connections or worker threads. Each resource unit decrements the counter on acquisition and increments it on release. This counting mechanism prevents oversubscription of limited resources.
Semaphore semantics differ from mutex semantics in ownership. A mutex has an owner—the thread that acquired it must release it. A semaphore has no owner; any thread can call signal regardless of whether it previously called wait. This flexibility enables producer-consumer patterns where producer threads signal consumers they have never synchronized with directly.
Ruby Implementation
Ruby does not include a built-in Semaphore class in its standard library. Developers implement semaphores using Thread::Mutex and Thread::ConditionVariable, or use the concurrent-ruby gem which provides a tested implementation.
class Semaphore
def initialize(size)
@size = size
@mutex = Thread::Mutex.new
@cv = Thread::ConditionVariable.new
end
def wait
@mutex.synchronize do
while @size <= 0
@cv.wait(@mutex)
end
@size -= 1
end
end
def signal
@mutex.synchronize do
@size += 1
@cv.signal
end
end
end
This implementation uses a mutex to protect the counter and a condition variable to block threads when no resources are available. The while loop in wait rechecks the condition after waking to handle spurious wakeups, where condition variables wake without an explicit signal.
The concurrent-ruby gem provides a production-ready Semaphore class:
require 'concurrent'
semaphore = Concurrent::Semaphore.new(5)
threads = 10.times.map do |i|
Thread.new do
semaphore.acquire
puts "Thread #{i} acquired resource"
sleep(rand)
puts "Thread #{i} releasing resource"
semaphore.release
end
end
threads.each(&:join)
The Concurrent::Semaphore API uses acquire and release instead of wait and signal, following Java's naming convention. This implementation includes timeout support for acquire operations:
semaphore = Concurrent::Semaphore.new(2)
# Try to acquire with 1 second timeout
if semaphore.try_acquire(1)
begin
# Use resource
ensure
semaphore.release
end
else
puts "Could not acquire resource within timeout"
end
Ruby's Global Interpreter Lock (GIL) affects semaphore behavior. The GIL allows only one thread to execute Ruby code at a time, but threads can block on I/O or synchronization primitives. Semaphores remain useful in Ruby for coordinating I/O-bound operations, managing connection pools, and controlling access to external resources.
class ConnectionPool
def initialize(max_connections)
@semaphore = Concurrent::Semaphore.new(max_connections)
@connections = []
end
def with_connection
@semaphore.acquire
connection = acquire_connection
begin
yield connection
ensure
release_connection(connection)
@semaphore.release
end
end
private
def acquire_connection
# Return existing or create new connection
end
def release_connection(connection)
# Return connection to pool
end
end
pool = ConnectionPool.new(10)
threads = 20.times.map do
Thread.new do
pool.with_connection do |conn|
# Execute database query
end
end
end
The ensure block guarantees that semaphore release occurs even if exceptions occur during resource usage. This pattern prevents resource leaks that would permanently decrement the semaphore counter.
Ruby threads use native system threads, making semaphores effective for coordinating system resources. When Ruby code blocks on a semaphore, the underlying thread yields to the operating system, allowing other threads to run. This behavior makes semaphores suitable for managing file handles, network sockets, and other system resources.
Practical Examples
A download manager limits concurrent downloads to prevent overwhelming the network:
require 'concurrent'
require 'net/http'
class DownloadManager
def initialize(max_concurrent)
@semaphore = Concurrent::Semaphore.new(max_concurrent)
end
def download(url)
@semaphore.acquire
begin
puts "Starting download: #{url}"
uri = URI(url)
response = Net::HTTP.get_response(uri)
puts "Completed download: #{url} (#{response.body.length} bytes)"
response.body
ensure
@semaphore.release
end
end
end
manager = DownloadManager.new(3)
urls = [
'http://example.com/file1',
'http://example.com/file2',
'http://example.com/file3',
'http://example.com/file4',
'http://example.com/file5'
]
threads = urls.map do |url|
Thread.new { manager.download(url) }
end
threads.each(&:join)
The semaphore limits three simultaneous downloads. When three downloads run, the fourth thread blocks until one completes. This prevents network saturation and respects server rate limits.
A worker pool processes tasks with limited concurrency:
class WorkerPool
def initialize(worker_count)
@semaphore = Concurrent::Semaphore.new(worker_count)
@queue = Queue.new
end
def submit(task)
@queue.push(task)
end
def start
loop do
task = @queue.pop
@semaphore.acquire
Thread.new do
begin
task.call
ensure
@semaphore.release
end
end
end
end
end
pool = WorkerPool.new(5)
# Submit tasks
100.times do |i|
pool.submit(-> {
puts "Processing task #{i}"
sleep(rand)
})
end
# Start processing
Thread.new { pool.start }
sleep(10) # Let tasks process
The semaphore ensures no more than five workers execute simultaneously. As workers complete tasks, they release permits, allowing new workers to start.
A rate limiter controls API request frequency using a semaphore that refreshes permits periodically:
class RateLimiter
def initialize(requests_per_second)
@semaphore = Concurrent::Semaphore.new(requests_per_second)
@requests_per_second = requests_per_second
start_refresh_thread
end
def execute(&block)
@semaphore.acquire
begin
block.call
ensure
# Don't release here - let refresh thread handle it
end
end
private
def start_refresh_thread
Thread.new do
loop do
sleep(1.0 / @requests_per_second)
@semaphore.release rescue nil
end
end
end
end
limiter = RateLimiter.new(10) # 10 requests per second
100.times do |i|
Thread.new do
limiter.execute do
puts "API request #{i} at #{Time.now}"
end
end
end
sleep(15)
The refresh thread periodically adds permits to the semaphore, maintaining the desired request rate. This pattern implements token bucket rate limiting.
A batch processor groups operations and uses a semaphore to coordinate batch completion:
class BatchProcessor
def initialize(batch_size)
@batch_size = batch_size
@items = []
@mutex = Thread::Mutex.new
@semaphore = Concurrent::Semaphore.new(0)
end
def add_item(item)
@mutex.synchronize do
@items << item
if @items.size >= @batch_size
@semaphore.release
end
end
end
def process_batch
@semaphore.acquire
items_to_process = @mutex.synchronize do
batch = @items.shift(@batch_size)
batch
end
# Process batch
puts "Processing batch of #{items_to_process.size} items"
items_to_process.each do |item|
# Process item
end
end
end
processor = BatchProcessor.new(10)
# Producer thread
Thread.new do
100.times do |i|
processor.add_item(i)
sleep(0.01)
end
end
# Consumer threads
3.times.map do
Thread.new do
10.times { processor.process_batch }
end
end.each(&:join)
The semaphore blocks consumer threads until enough items accumulate for a full batch. The producer signals the semaphore when a batch is ready, coordinating asynchronous production and consumption.
Common Patterns
The producer-consumer pattern uses semaphores to coordinate threads that produce and consume items:
class ProducerConsumer
def initialize(buffer_size)
@buffer = []
@mutex = Thread::Mutex.new
@empty_count = Concurrent::Semaphore.new(buffer_size)
@full_count = Concurrent::Semaphore.new(0)
end
def produce(item)
@empty_count.acquire
@mutex.synchronize do
@buffer << item
puts "Produced: #{item} (buffer size: #{@buffer.size})"
end
@full_count.release
end
def consume
@full_count.acquire
item = @mutex.synchronize do
item = @buffer.shift
puts "Consumed: #{item} (buffer size: #{@buffer.size})"
item
end
@empty_count.release
item
end
end
pc = ProducerConsumer.new(5)
# Producer threads
producers = 2.times.map do |i|
Thread.new do
10.times do |j|
pc.produce("item-#{i}-#{j}")
sleep(rand * 0.1)
end
end
end
# Consumer threads
consumers = 3.times.map do
Thread.new do
7.times do
pc.consume
sleep(rand * 0.2)
end
end
end
(producers + consumers).each(&:join)
Two semaphores coordinate production and consumption. The empty_count semaphore tracks available buffer slots. The full_count semaphore tracks items ready for consumption. Producers wait for empty slots and signal full slots. Consumers wait for full slots and signal empty slots.
The readers-writers pattern allows multiple concurrent readers but exclusive writer access:
class ReadersWriters
def initialize
@readers = 0
@mutex = Thread::Mutex.new
@write_lock = Concurrent::Semaphore.new(1)
@reader_lock = Concurrent::Semaphore.new(1)
end
def read
@reader_lock.acquire
@mutex.synchronize do
@readers += 1
@write_lock.acquire if @readers == 1
end
@reader_lock.release
begin
yield
ensure
@mutex.synchronize do
@readers -= 1
@write_lock.release if @readers == 0
end
end
end
def write
@write_lock.acquire
begin
yield
ensure
@write_lock.release
end
end
end
rw = ReadersWriters.new
shared_data = { value: 0 }
# Reader threads
readers = 5.times.map do |i|
Thread.new do
10.times do
rw.read do
puts "Reader #{i} read: #{shared_data[:value]}"
end
sleep(rand * 0.1)
end
end
end
# Writer threads
writers = 2.times.map do |i|
Thread.new do
5.times do |j|
rw.write do
shared_data[:value] += 1
puts "Writer #{i} wrote: #{shared_data[:value]}"
end
sleep(rand * 0.5)
end
end
end
(readers + writers).each(&:join)
The write_lock semaphore ensures exclusive writer access. The first reader acquires the write lock to block writers. Subsequent readers increment the counter without blocking. The last reader releases the write lock, allowing writers to proceed.
The barrier pattern synchronizes multiple threads at a rendezvous point:
class Barrier
def initialize(thread_count)
@thread_count = thread_count
@arrived = 0
@mutex = Thread::Mutex.new
@barrier = Concurrent::Semaphore.new(0)
@release = Concurrent::Semaphore.new(0)
end
def wait
@mutex.synchronize do
@arrived += 1
if @arrived == @thread_count
@thread_count.times { @release.release }
end
end
@release.acquire
@mutex.synchronize do
@arrived -= 1
if @arrived == 0
@thread_count.times { @barrier.release }
end
end
@barrier.acquire
end
end
barrier = Barrier.new(5)
threads = 5.times.map do |i|
Thread.new do
puts "Thread #{i} phase 1"
sleep(rand)
barrier.wait
puts "Thread #{i} phase 2"
sleep(rand)
barrier.wait
puts "Thread #{i} phase 3"
end
end
threads.each(&:join)
Threads block at the barrier until all threads arrive. The last arriving thread releases all blocked threads simultaneously. This pattern coordinates phases in parallel algorithms where each phase depends on completing the previous phase.
The multiplex pattern limits concurrent access to multiple resources:
class Multiplex
def initialize(resource_count)
@semaphore = Concurrent::Semaphore.new(resource_count)
end
def execute
@semaphore.acquire
begin
yield
ensure
@semaphore.release
end
end
end
multiplex = Multiplex.new(3)
threads = 10.times.map do |i|
Thread.new do
multiplex.execute do
puts "Thread #{i} using resource"
sleep(1)
puts "Thread #{i} finished"
end
end
end
threads.each(&:join)
The semaphore count determines maximum concurrency. This pattern applies to connection pools, thread pools, or any scenario requiring controlled parallelism.
Common Pitfalls
Deadlock occurs when threads circularly wait for resources. Consider two threads acquiring semaphores in different orders:
semaphore_a = Concurrent::Semaphore.new(1)
semaphore_b = Concurrent::Semaphore.new(1)
# Thread 1
Thread.new do
semaphore_a.acquire
sleep(0.1) # Increase chance of deadlock
semaphore_b.acquire
# Never reaches here
semaphore_b.release
semaphore_a.release
end
# Thread 2
Thread.new do
semaphore_b.acquire
sleep(0.1)
semaphore_a.acquire
# Never reaches here
semaphore_a.release
semaphore_b.release
end
sleep(5)
puts "Deadlocked"
Thread 1 holds semaphore_a and waits for semaphore_b. Thread 2 holds semaphore_b and waits for semaphore_a. Neither thread progresses. Prevent deadlock by acquiring semaphores in consistent order across all threads:
# Both threads acquire in same order
def safe_acquire(semaphore_a, semaphore_b)
semaphore_a.acquire
semaphore_b.acquire
begin
yield
ensure
semaphore_b.release
semaphore_a.release
end
end
Forgetting to release semaphores causes resource leaks. Exceptions in critical sections particularly risk this:
semaphore = Concurrent::Semaphore.new(5)
# Dangerous - exception prevents release
def process_item(semaphore, item)
semaphore.acquire
# If this raises exception, semaphore never releases
risky_operation(item)
semaphore.release
end
# Safe - ensure guarantees release
def process_item_safe(semaphore, item)
semaphore.acquire
begin
risky_operation(item)
ensure
semaphore.release
end
end
Always wrap resource usage in begin-ensure blocks to guarantee semaphore release regardless of exceptions.
Starvation occurs when threads perpetually fail to acquire resources. Unfair semaphores allow newly arriving threads to acquire permits before long-waiting threads:
semaphore = Concurrent::Semaphore.new(1)
# High-priority threads constantly compete
10.times do
Thread.new do
loop do
semaphore.acquire
# Quick operation
sleep(0.001)
semaphore.release
sleep(0.001) # Brief pause
end
end
end
# Low-priority thread may starve
Thread.new do
semaphore.acquire
puts "Finally acquired!"
semaphore.release
end
sleep(10)
The low-priority thread competes with ten aggressive threads. Without fairness guarantees, it may never acquire the semaphore. Use fair semaphores or implement application-level fairness mechanisms.
Priority inversion occurs when low-priority threads block high-priority threads:
semaphore = Concurrent::Semaphore.new(1)
# Low priority thread
low_priority = Thread.new do
semaphore.acquire
sleep(5) # Long operation
semaphore.release
end
# High priority thread
high_priority = Thread.new do
sleep(0.1) # Start slightly after low priority
semaphore.acquire # Blocks on low priority thread
puts "High priority running"
semaphore.release
end
The high-priority thread waits for the low-priority thread to complete. Priority inheritance protocols address this by temporarily elevating the blocking thread's priority, but Ruby's standard library does not implement this.
Spurious wakeups in custom semaphore implementations cause incorrect behavior without proper condition rechecking:
class BrokenSemaphore
def initialize(size)
@size = size
@mutex = Thread::Mutex.new
@cv = Thread::ConditionVariable.new
end
def wait
@mutex.synchronize do
if @size <= 0 # WRONG: should be while, not if
@cv.wait(@mutex)
end
@size -= 1
end
end
end
Using if instead of while fails to recheck the condition after waking. Spurious wakeups cause threads to proceed when resources remain unavailable. Always use while loops when waiting on condition variables.
Signal-wait race conditions occur when signaling before waiting:
semaphore = Concurrent::Semaphore.new(0)
# Thread 1 signals first
Thread.new do
semaphore.release
puts "Signaled"
end
sleep(0.5) # Ensure Thread 1 completes
# Thread 2 waits second - will block forever
Thread.new do
puts "Waiting"
semaphore.acquire # Blocks forever
puts "Acquired"
end
sleep(5)
The signal occurs before the wait, losing the notification. The waiting thread never wakes because the semaphore counter returns to zero. Initialize semaphores correctly for intended synchronization patterns.
Mismatched acquire-release counts corrupt semaphore state:
semaphore = Concurrent::Semaphore.new(3)
# Release without acquire
5.times do
Thread.new do
# Forgot to acquire!
semaphore.release
end
end
sleep(0.1)
# Now semaphore has 8 permits instead of 3
8.times do
semaphore.acquire # All succeed
end
Releasing without corresponding acquisition inflates the semaphore count beyond its intended maximum. This allows more concurrent access than designed, potentially overwhelming resources. Track acquire-release pairs carefully and consider using block-based patterns that guarantee proper pairing.
Reference
Core Operations
| Operation | Description | Behavior |
|---|---|---|
| initialize(n) | Create semaphore with n permits | Sets initial counter value |
| wait/acquire | Request resource | Decrements counter, blocks if zero |
| signal/release | Return resource | Increments counter, wakes one thread |
| try_acquire(timeout) | Attempt acquisition with timeout | Returns false if timeout expires |
Semaphore Types
| Type | Counter Range | Use Case |
|---|---|---|
| Counting | 0 to N | Multiple identical resources |
| Binary | 0 or 1 | Mutual exclusion, signaling |
| Bounded | 0 to max | Resource pools with capacity limits |
Common Patterns
| Pattern | Semaphores Used | Purpose |
|---|---|---|
| Producer-Consumer | 2 (empty, full) | Coordinate bounded buffer |
| Readers-Writers | 2 (write, reader) | Multiple readers, exclusive writer |
| Barrier | 2 (arrive, depart) | Synchronize thread phases |
| Multiplex | 1 (resource count) | Limit concurrent access |
| Rate Limiter | 1 (tokens) | Control operation frequency |
Ruby Implementation
require 'concurrent'
# Create semaphore
semaphore = Concurrent::Semaphore.new(5)
# Acquire with ensure
semaphore.acquire
begin
# Use resource
ensure
semaphore.release
end
# Try with timeout
if semaphore.try_acquire(1.0)
begin
# Use resource
ensure
semaphore.release
end
end
# Query available permits
available = semaphore.available_permits
Deadlock Prevention
| Strategy | Description | Implementation |
|---|---|---|
| Ordered acquisition | Acquire semaphores in consistent order | Define global ordering |
| Timeout | Use try_acquire with timeout | Handle timeout failures |
| Deadlock detection | Monitor wait-for graphs | Check for cycles periodically |
| Resource hierarchy | Assign levels to resources | Acquire lower levels first |
Performance Characteristics
| Operation | Time Complexity | Notes |
|---|---|---|
| acquire (available) | O(1) | Counter decrement |
| acquire (unavailable) | O(1) amortized | Thread queuing |
| release | O(1) | Counter increment, thread wake |
| try_acquire | O(1) | Non-blocking check |
Error Scenarios
| Problem | Cause | Solution |
|---|---|---|
| Deadlock | Circular wait | Ordered acquisition |
| Resource leak | Missing release | Use ensure blocks |
| Starvation | Unfair scheduling | Fair semaphore implementation |
| Race condition | Incorrect initialization | Proper initial counter value |
| Counter overflow | Extra releases | Match acquire-release pairs |
Integration with Ruby Constructs
| Ruby Feature | Semaphore Usage | Example |
|---|---|---|
| Thread pool | Limit worker count | Semaphore.new(worker_count) |
| Connection pool | Limit connections | Acquire before query |
| File I/O | Limit open handles | Release after close |
| Network requests | Rate limiting | Periodic permit refresh |
| Queue processing | Batch coordination | Signal on batch ready |