A counting semaphore implementation for Ruby with local and distributed (Redis) variants.
Tip
This gem was created for Cora, your personal e-mail assistant. Send them some love for allowing me to share it.
When you have a metered and limited resource that only supports a certain number of simultaneous operations you need a semaphore primitive. In Ruby, most semaphores usually controls access "one whole resource":
sem = Semaphore.new
sem.with_lease do
# Critical section where you hold access to the resource
end
This is well covered - for example - by POSIX semaphores if you are within one machine, and is known as a binary semaphore (it is either "open" or "closed"). There are also counting semaphores where you permit N of leases to be taken, which is available in the venerable redis-semaphore gem.
The problem comes if you need to hold access to a certain amount of a resource. For example, you know that you are doing 5 expensive operations in bulk, and you know that your entire application can only be doing 20 in total - governed by the API access limits. For that, you need a counting semaphore - such a semaphore is provided by concurrent-ruby for example. It allows you to acquire a certain number of permits and then release them.
This library provides both a simple LocalSemaphore
which can be used across threads or fibers, and a Redis-based RedisSemaphore
for coordination across processes and machines. Both implement a Lease-based API compatible with concurrent-ruby's Semaphore.
The recommended way to use the semaphore is with the with_lease
method, which provides automatic cleanup:
require 'counting_semaphore'
# Create a local semaphore with capacity of 10
semaphore = CountingSemaphore::LocalSemaphore.new(10)
# Acquire 3 permits and automatically release on block exit
semaphore.with_lease(3, timeout_seconds: 10) do
puts "Holding 3 permits"
# Do your work here - permits are automatically released when the block exits
end
The block receives the lease object, which you can inspect:
semaphore.with_lease(3) do |lease|
puts "Holding #{lease.permits} permits (ID: #{lease.id})"
# Automatic cleanup on block exit
end
The Redis semaphore works identically but coordinates across processes and machines:
require 'redis'
redis = Redis.new
semaphore = CountingSemaphore::RedisSemaphore.new(
10, # capacity
"api_ratelimit", # namespace (unique identifier)
redis: redis,
lease_ttl_seconds: 60 # lease expires after 60 seconds
)
# Use it the same way - works across multiple processes
semaphore.with_lease(3) do
puts "Doing distributed work with 3 permits"
# Permits automatically released when done
end
You can query the current state of the semaphore:
puts "Available permits: #{semaphore.available_permits}"
puts "Capacity: #{semaphore.capacity}"
puts "Currently in use: #{semaphore.currently_leased}"
For more control, you can manually acquire and release leases. This is useful when you can't use a block structure:
# Acquire permits (returns a Lease object)
lease = semaphore.acquire(2)
begin
# Do some work
puts "Working with 2 permits..."
ensure
# Always release the lease
semaphore.release(lease)
end
# Try to acquire immediately (returns nil if not available)
lease = semaphore.try_acquire(1)
if lease
begin
puts "Got the permit!"
ensure
semaphore.release(lease)
end
else
puts "Could not acquire permit"
end
# Try to acquire with timeout
lease = semaphore.try_acquire(2, 5.0) # Wait up to 5 seconds
if lease
begin
# Work with the permits
ensure
semaphore.release(lease)
end
end
# Acquire all currently available permits
drained_lease = semaphore.drain_permits
if drained_lease
begin
puts "Drained #{drained_lease.permits} permits for exclusive access"
# Do exclusive work
ensure
semaphore.release(drained_lease)
end
end
- Automatic Cleanup:
with_lease
ensures permits are always released - Type Safety: Lease objects ensure you can only release what you've acquired
- Cross-Semaphore Protection: Can't accidentally release a lease to the wrong semaphore
- Distributed Coordination: Redis semaphore works seamlessly across processes and machines
- Lease Expiration: Redis leases automatically expire to prevent deadlocks
This library aims for compatibility with Concurrent::Semaphore
from the concurrent-ruby gem, but with a key difference to support both local and distributed implementations.
The core difference from Concurrent::Semaphore
is that acquire
returns a lease object that must be passed to release
, rather than using numeric permit counts for both operations:
# concurrent-ruby style
semaphore.acquire(2)
# ... work ...
semaphore.release(2) # Must remember the count!
# counting_semaphore style
lease = semaphore.acquire(2)
# ... work ...
semaphore.release(lease) # Lease knows its own count
The Concurrent::Semaphore
API where acquire(n)
and release(n)
use arbitrary counts works well for in-memory semaphores, but creates challenges for distributed Redis-based implementations:
- Individual leases need TTLs: In Redis, each lease must have an expiration to prevent deadlocks from crashed processes
- Lease tracking is essential: Distributed systems need unique identifiers for each acquired lease
- Cross-process coordination: Releasing "2 permits" doesn't map cleanly to "which 2 leases?" across processes
- Ownership semantics: The lease object makes it explicit what you acquired and what you're releasing
A lease is a simple struct that contains:
semaphore
- reference to the semaphore it came fromid
- unique identifier (local counter for LocalSemaphore, Redis key for RedisSemaphore)permits
- number of permits held
This design:
- Prevents bugs: Can't accidentally release the wrong amount or to the wrong semaphore
- Works for both implementations: LocalSemaphore and RedisSemaphore use the same API
- Follows familiar patterns: Similar to file handles, database connections, and other resource management
- Maintains compatibility: The
with_lease
block form works identically to concurrent-ruby's usage
The library provides the same query methods as Concurrent::Semaphore
:
available_permits
- returns the number of permits currently availablecapacity
- returns the total capacity of the semaphorecurrently_leased
- returns the number of permits currently in use
Additionally, drain_permits
returns a lease object (or nil) instead of an integer, maintaining consistency with the lease-based design.
Add this line to your application's Gemfile:
gem "counting_semaphore"
And then execute:
$ bundle install
Or install it yourself as:
$ gem install counting_semaphore
There are no dependencies (but you need the redis
gem for development - or you can feed a compatible object instead).
Do a fresh checkout and run bundle install
. Then run tests and linting using bundle exec rake
.
Bug reports and pull requests are welcome on GitHub at https://github.com/julik/counting_semaphore.
The gem is available as open source under the terms of the MIT License.