The concurrent-ruby Library: Futures, Actors, and Queues
Intro Context
Ruby’s built-in Thread and Mutex classes take you a long way, but they leave a lot of coordination work in your hands. The concurrent-ruby library fills this gap by providing thread-safe collections, futures, actors, and semaphores that would otherwise require custom glue code. Instead of reimplementing concurrency patterns for every project, you get a higher-level toolbox that handles the coordination details for you.
That makes the library especially useful in applications that mix I/O, background work, and shared state. Instead of stitching together custom wrappers around arrays, hashes, and locks, you can lean on tested abstractions that already model the behavior you want. This guide walks through the pieces you are most likely to reach for in day-to-day Ruby code.
The concurrent-ruby library is also a good fit when you want a clearer boundary between the work you start and the work you wait for later. Futures and actors give you that separation explicitly, which makes it easier to describe concurrent behavior in a way other people can read without learning a pile of custom helper methods first.
This guide covers the parts of concurrent-ruby that come up most often: thread-safe data structures, the promises/futures API, synchronization primitives, and the actor model.
TL;DR
Use concurrent-ruby when you want higher-level tools for thread-safe collections, futures, actors, and coordination primitives. It helps you avoid repeating the same locking patterns across your app and gives you a clearer vocabulary for shared work.
thread-safe collections
Ruby’s built-in Array and Hash are not thread-safe. If two threads push to the same array simultaneously, you can lose writes. The same goes for hash key assignments. You can protect them with a Mutex, but that’s verbose and easy to get wrong.
concurrent-ruby gives you drop-in replacements. The key advantage is that every mutating method on these collection types is already synchronized, so you do not need to wrap every access in a mutex block. This makes the code shorter and eliminates a class of bugs where a developer forgets to acquire the lock before reading or writing. The example below shows ten threads each pushing 100 values into a shared array, producing exactly 1000 entries without any lost writes:
require 'concurrent'
arr = Concurrent::Array.new
threads = 10.times.map do
Thread.new do
100.times { arr << Thread.current.object_id }
end
end
threads.each(&:join)
puts arr.size # => 1000
Every method on Concurrent::Array is synchronized. You get the same guarantee with Concurrent::Hash and Concurrent::Set. No mutex boilerplate required.
h = Concurrent::Hash.new
threads = 5.times.map do |i|
Thread.new { h[:key] = i }
end
threads.each(&:join)
puts h[:key] # Safely written, one of the values
One caveat: on CRuby, Concurrent::Array locks against the object itself. In deeply nested concurrent operations you can still hit contention issues, but for most use cases these structures work correctly.
These containers are a good first step when you want safer shared state without changing the rest of your design. They protect the collection itself, but they do not solve every concurrency issue for you. If the objects inside are mutable, you still need to decide who owns them and when they can be changed.
async computation with promises
The Concurrent::Promises module gives you futures — objects representing values that may not exist yet. This is the most powerful part of the library for async work.
futures
Concurrent::Promises.future runs a block on a thread pool immediately and returns a Future:
require 'concurrent'
future = Concurrent::Promises.future do
sleep 0.5
42
end
puts future.completed? # => false (still running)
result = future.value # Blocks until done, returns 42
puts future.completed? # => true
The Future object gives you several ways to interact with it:
| Method | Behavior |
|---|---|
.value | Blocks and returns the result; raises if the block threw |
.value(timeout: 1) | Blocks with a timeout; returns nil if it times out |
.completed? | Non-blocking check |
.reason | The exception if the block failed |
.wait | Blocks until completed without returning a value |
delays
A delay does not run until something asks for its value. The block you pass to delay sits idle until the first call to .value, at which point the computation runs and the result is memoized for all future calls. This is especially useful when you want to defer expensive setup work like loading a large configuration file or initializing a connection pool until the moment the result is actually needed:
delay = Concurrent::Promises.delay { expensive_computation }
# Nothing has run yet
result = delay.value # Now it runs — and memoizes the result
This is useful when you want to defer expensive work until the result is actually needed.
composing futures
Concurrent::Promises.zip combines multiple futures into one that resolves when all complete:
f1 = Concurrent::Promises.future { sleep 0.3; 1 }
f2 = Concurrent::Promises.future { sleep 0.1; 2 }
f3 = Concurrent::Promises.future { sleep 0.2; 3 }
combined = Concurrent::Promises.zip(f1, f2, f3)
results = combined.value # => [1, 2, 3]
If you need the first result rather than all results, use any_fulfilled_future. For racing on any settlement (success or failure), use any_resolved_future.
This API is especially helpful when your code needs to start multiple independent tasks and then combine the results. It is a good fit for fan-out requests, background orchestration, or any place where waiting on one slow operation would waste the time available to the rest of the program.
Beyond futures, concurrent-ruby also provides synchronization primitives that control how threads access shared resources. These primitives cover patterns that a single mutex cannot express on its own, such as limiting concurrent access to a fixed number, waiting for a group of threads to finish, or allowing multiple readers while blocking writers.
synchronization primitives
Sometimes you need to coordinate threads beyond what a mutex can do. concurrent-ruby provides several primitives for this.
semaphore
A semaphore manages access to a shared resource with a fixed number of permits. Each acquire blocks when no permits are available, and each release gives one back. This is useful for limiting parallelism, such as throttling API calls:
require 'concurrent'
semaphore = Concurrent::Semaphore.new(3) # Max 3 concurrent accesses
10.times.map do |i|
Thread.new do
semaphore.acquire
puts "Thread #{i} acquired"
sleep 1
puts "Thread #{i} releasing"
semaphore.release
end
end.each(&:join)
countdown latch
A latch is a one-time barrier that works in a single direction: once the count reaches zero, all waiting threads are released and the latch stays open permanently. This is the right tool when a set of worker threads must all finish before the main thread proceeds, such as during application startup where several services need to initialize before the server begins accepting requests:
require 'concurrent'
latch = Concurrent::CountDownLatch.new(3)
threads = 3.times.map do |i|
Thread.new do
sleep rand
puts "Thread #{i} calling count_down"
latch.count_down
end
end
puts "Main thread waiting..."
latch.wait # Blocks until all 3 count_down calls
puts "All threads done!"
Once a latch reaches zero it stays at zero — it’s one-way.
cyclic barrier
Unlike a latch, a barrier is reusable. N threads all call wait, and when the Nth thread arrives, all are released simultaneously; the barrier resets:
require 'concurrent'
barrier = Concurrent::CyclicBarrier.new(3)
3.times.map do |i|
Thread.new do
puts "Thread #{i} waiting at barrier"
barrier.wait
puts "Thread #{i} passed!"
end
end.each(&:join)
# All three print "waiting" then all three print "passed!" together
This is useful when you need threads to rendezvous at a checkpoint before proceeding.
read-write lock
A read-write lock allows multiple concurrent readers but a single exclusive writer:
require 'concurrent'
rwlock = Concurrent::ReadWriteLock.new
shared_data = 0
readers = 5.times.map do
Thread.new { rwlock.with_read_lock { shared_data } }
end
writer = Thread.new do
rwlock.with_write_lock { shared_data = 42 }
end
readers.each(&:join)
writer.join
The with_read_lock and with_write_lock helpers acquire and release the lock automatically.
actor model
concurrent-ruby includes an Erlang-style actor implementation. Each actor runs on its own thread, processes messages from its mailbox sequentially, and maintains its own internal state. Because only one message is processed at a time, there are no race conditions inside the actor:
require 'concurrent/actor'
counter = Concurrent::Actor.define(:Counter) do |initial|
count = initial
loop do
message = receive
case message
when :inc then count += 1
when :dec then count -= 1
when :get then count
end
end
end
counter << :inc
counter << :inc
counter << :get # => 2
The << operator sends a message to the actor’s mailbox. Actors are especially useful for modeling stateful services where you want thread safety without mutex management.
Supervised actors (restarting on failure) live in the edge channel of the gem, which has a less stable API than the core library.
scheduled execution
Concurrent::ScheduledTask runs a block after a given delay and returns a Future:
require 'concurrent'
task = Concurrent::ScheduledTask.execute(2) { 42 }
puts task.completed? # => false
sleep 3
puts task.completed? # => true
puts task.value # => 42
This is the foundation of delayed and periodic job scheduling, and it’s what Sidekiq uses under the hood for retry logic. Because the returned object is a standard future, you can compose scheduled tasks with other promises, chain callbacks on the result, or check completion status the same way you would with any other future in the library.
error handling
When a future’s block raises an exception, the exception is captured and stored in the future’s .reason:
future = Concurrent::Promises.future { raise "oops" }
future.wait
puts future.reason # => #<RuntimeError: oops>
Always check .reason or wrap .value in a begin/rescue if your futures might fail. There’s no silent failure — the error is there, but you have to look for it.
how concurrent-ruby compares to alternatives
stdlib Thread gives you raw OS threads. You’re responsible for protecting shared state, avoiding deadlocks, and managing thread lifecycle. concurrent-ruby handles all of that for you through higher-level abstractions.
The async gem uses fibers instead of threads. Fibers are lightweight green threads that cooperate by yielding, so they do not run in parallel on multiple CPU cores, but they use far less memory. If you have thousands of concurrent I/O-bound connections, async wins. If you need true parallelism for CPU-bound work, concurrent-ruby wins.
Forward Link
Once the library feels familiar, the next step is to compare it with the lower-level concurrency tools that Ruby already gives you. The Ruby Threads Basics tutorial shows the shared-memory primitives, while the Ruby Ractors introduction shows the isolated-worker model. That comparison helps you choose the smallest concurrency tool that still fits your workload.
concurrent-ruby runs on CRuby, JRuby, and TruffleRuby. The thread pool executors auto-size based on the runtime environment, so your code doesn’t need to change between implementations.
If you only remember one thing, remember that concurrent-ruby is about coordination first and speed second. The library helps you model shared work safely, but it still works best when the surrounding code makes ownership and responsibility obvious.
summary
concurrent-ruby sits between low-level Ruby threads and a custom concurrency framework. It gives you reliable building blocks for shared collections, task orchestration, and inter-thread communication without making you reinvent those pieces yourself. If your app needs thread-safe coordination, it is one of the most practical gems in the Ruby ecosystem.
See Also
- Thread Basics in Ruby — stdlib Thread, Mutex, and thread lifecycle
- Fiber Basics in Ruby — cooperative concurrency with fibers
- Error Handling in Ruby — rescue, raise, and exception best practices