Ractor-Based Concurrency
Ruby threads can’t run Ruby code in parallel — the Global VM Lock (GVL) ensures only one thread runs Ruby code at a time. This matters when you want to use multiple CPU cores for actual parallel work. Ractors solve this by giving each concurrent execution context its own isolated memory space and thread, with communication happening purely through message passing.
If you haven’t worked with Ractors before, start with the Ractor Introduction tutorial first. This guide focuses on inter-Ractor communication patterns and practical deployment scenarios.
How message passing works in Ractors
Ractors implement the actor model: each Ractor has an incoming port (for receiving messages) and an outgoing port (for replying). The public API reflects this:
Ractor.send(ractor, message)— sends a message to a Ractor’s incoming port. Non-blocking.Ractor.receive— blocks until a message arrives at the current Ractor’s incoming port.ractor << message— syntactic sugar forRactor.send(ractor, message).Ractor.yield(value)— sends a value back to whoever calledtakeon this Ractor.Ractor.take(ractor)— blocks until the target Ractor yields a value.
Here’s the simplest possible conversation between two Ractors:
worker = Ractor.new do
msg = Ractor.receive
Ractor.yield(msg.upcase)
end
# Ractor.select waits until at least one Ractor has a value ready.
# It returns [ractor, value] for whichever Ractor produced a value first.
winner, result = Ractor.select(worker, Ractor.new { :done })
# winner is the Ractor that produced a value first
Ractor.select is useful when you want to wait on multiple Ractors at once. Ruby 3.3 added this method to handle multi-way coordination without having to poll sequentially.
Shareable vs non-shareable objects
Each Ractor has its own heap. Objects don’t cross Ractor boundaries unless they’re shareable. Ruby’s rule: only frozen/immutable objects are shareable by default.
Ractor.shareable?(42) # => true
Ractor.shareable?(true) # => true
Ractor.shareable?("hello") # => false (strings are mutable)
Ractor.shareable?("hello".freeze) # => true
# Deep-freeze a hash to share it
config = { timeout: 30 }.freeze
Ractor.shareable?(config) # => true
Non-shareable objects get deep-cloned when passed across Ractor boundaries. This is safe, since each Ractor gets its own independent copy, but it creates a bottleneck for large data structures. A hash with thousands of entries copied on every message will slow down your pipeline and increase memory pressure noticeably. If you need to share the same complex object repeatedly, freeze it once and pass the frozen reference instead of paying the clone cost every time:
data = { cache: {} }
Ractor.make_shareable(data)
# data and everything it refers to is now frozen
Ractor.make_shareable(obj) recursively freezes an object. Use it when you need to pass a large constant table to a Ractor without paying the copy cost on every message.
Building a Channel
Ractors don’t have built-in channels like Go does, but you can build one trivially. A channel is just a Ractor that loops forever, receiving messages and yielding them back to consumers:
channel = Ractor.new do
loop { Ractor.yield Ractor.receive }
end
Producers send messages to the channel and consumers pull them back out. This design keeps the producers wait-free because send never blocks: the message lands in the channel Ractor’s mailbox immediately and the caller moves on. Consumers use take, which blocks until a value arrives, so the channel doubles as a natural backpressure mechanism. Multiple producers can all send concurrently without coordinating with each other, and multiple consumers can call take to pull work off the queue:
# Producer
channel.send("job:1")
channel.send("job:2")
# Consumer (blocks until a value is available)
while job = channel.take
process(job)
end
This channel design is wait-free for producers (send never blocks) and blocking for consumers (take blocks until something is available).
Worker pool pattern
A common setup is a fixed pool of workers sharing a job queue. Each worker runs truly in parallel for CPU-bound work, with no GVL contention between them.
def worker_pool(num_workers, &block)
job_queue = Ractor.new do
loop { Ractor.yield Ractor.receive }
end
num_workers.times do |i|
Ractor.new(job_queue, name: "worker-#{i}", &block)
end
job_queue
end
pool = worker_pool(4) do |jobs|
while job = jobs.take
result = job[:task].call
job[:reply] << result
end
end
# Dispatch jobs
num_jobs.times do |i|
reply_port = Ractor.new { Ractor.receive }
pool.send({ task: -> { compute(i) }, reply: reply_port })
result = reply_port.take
puts "Job #{i} => #{result}"
end
Each worker is a Ractor with a name for easier debugging. The reply_port pattern (a tiny Ractor just to receive one result) is a clean way to get values back to the caller.
Parallel Computation
For CPU-bound work that can be divided into independent chunks, Ractors give you genuine parallelism:
def parallel_sum(range, num_chunks)
chunk_size = range.size / num_chunks
chunks = num_chunks.times.map do |i|
start = range.begin + i * chunk_size
stop = (i == num_chunks - 1) ? range.end : start + chunk_size - 1
Range.new(start, stop)
end
ractors = chunks.map do |chunk|
Ractor.new(chunk) { |r| r.sum }
end
ractors.map { |r| Ractor.take(r) }.sum
end
puts parallel_sum(1..1_000_000, 8)
With 8 Ractors on an 8-core machine, this divides the sum into 8 chunks and computes them in parallel. The final .map { |r| Ractor.take(r) }.sum blocks until all Ractors have yielded their partial sums.
Cancelling and closing Ractors
A Ractor runs until its block returns or you explicitly close it:
r = Ractor.new { loop { Ractor.receive } }
r << :stop # sends a shutdown signal
# Or close the incoming port — Ractor.receive raises Ractor::ClosedError
r.close_incoming
Ruby 3.2 added Ractor.cancel(ractor) for forcible termination, though this should be a last resort since it doesn’t give the Ractor a chance to clean up.
What Ractors don’t solve
Ractors are not a silver bullet. A few things to keep in mind:
Each Ractor still has the GVL. A single Ractor can’t split its CPU-bound work across cores. That’s why you need multiple Ractors for parallelism — each one holds its own GVL. If you only spawn one Ractor for CPU work, it won’t be faster than a thread.
Debugging is harder. Stack traces from Ractors are isolated. Named Ractors help (Ractor.new(name: "my-worker")) so you can identify them in logs, but you’ll want to lean on unit tests for Ractor-heavy code.
Thousands of Ractors is not the answer. Each Ractor has real memory overhead and startup cost. For I/O-bound concurrency (many network requests), threads or the async gem are lighter. Save Ractors for CPU parallelism or when you genuinely need actor-style isolation.
Key takeaways
- Ractors give you parallel execution by isolating memory and passing messages instead of sharing mutable state.
- They are most useful for CPU-bound work that can be split into independent chunks.
- Shareable objects must be frozen or explicitly made shareable before crossing a Ractor boundary.
- Ractor-heavy designs work best when the communication pattern is simple and predictable.
- For I/O-heavy apps, threads, fibers, or an evented library are often easier to reason about.
Common mistakes
One common mistake is trying to use Ractors like threads with shared objects. That breaks the isolation model and usually leads to errors or hidden copies. Keep the data small, frozen, and explicit.
Another mistake is building a Ractor tree with too many tiny workers. Ractors are not free. If each task takes only a few microseconds, the communication overhead can outweigh the parallelism benefits. Batch work into chunks that are large enough to justify the coordination cost.
It is also worth watching the handoff boundary. Anything you send to another Ractor should be thought of as immutable input or a one-way message. If the code reads like two workers are cooperating on the same mutable state, you are probably fighting the model.
Frequently asked questions
Should I use Ractors for web requests?
Usually not. Web requests are mostly I/O-bound, so threads or fibers are easier to fit into a typical Ruby application. Ractors make more sense when the work is CPU-heavy and can be split cleanly across cores.
Can I share caches between Ractors?
Not directly, unless the data is shareable. In practice, that usually means freezing the objects or building a message-passing layer that owns the cache. If you need a mutable shared cache, Ractors are probably the wrong abstraction.
What should I do first when learning Ractors?
Start with a tiny producer and consumer example, then add a second worker, then a larger pool. That progression makes it much easier to see where isolation, message passing, and coordination start to matter.
Conclusion
Ractors are the right fit when you need real parallelism and can keep the communication model simple. They reward careful design and punish sloppy sharing, so they work best in code that already has clear boundaries.
If your workload is CPU-bound and naturally chunked, Ractors can be a strong tool. If your workload is mostly waiting on network or disk, it is usually easier to reach for threads or fibers first and save Ractors for the cases where their isolation model genuinely helps.
See Also
- Ractor Introduction — basics of creating Ractors and the isolation model
- Thread Concurrency in Ruby — how Ruby’s threads, Mutex, and ConditionVariable work
- Fiber Scheduler — cooperative scheduling with Fibers and the Fiber Scheduler interface