rubyguides

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 for Ractor.send(ractor, message).
  • Ractor.yield(value) — sends a value back to whoever called take on 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