Ractor Introduction in Ruby

· 6 min read · Updated March 29, 2026 · advanced
ruby concurrency ruby-3

What Is a Ractor?

Ruby’s threads share memory by default. Two threads can read and write the same variables freely — which means you need mutexes, condition variables, and careful discipline to avoid data races. In MRI Ruby, threads are also limited by the Global VM Lock, so only one thread runs Ruby bytecode at a time even on a multi-core machine.

Ractors solve both problems. A Ractor is an independent actor that runs in its own memory space with its own thread. They communicate purely through message passing, never by sharing state. And because each Ractor has its own thread, the Global VM Lock does not stop different Ractors from running in true parallel on separate CPU cores.

The name is a portmanteau of “Ruby” and “Actor” — Ruby’s take on the actor model concurrency pattern.

Creating Your First Ractor

A Ractor runs the block you pass to Ractor.new:

r = Ractor.new do
  "hello from a Ractor"
end

result = r.take  # blocks until the Ractor finishes
puts result     # => "hello from a Ractor"

The block is fully isolated from the outer scope — it cannot see variables defined outside it. Any values you need inside the Ractor must be passed as arguments to Ractor.new:

name = "Ruby"
r = Ractor.new(name) do |msg|
  "Hello, #{msg}!"
end
r.take  # => "Hello, Ruby!"

Each Ractor has a name you can inspect:

r = Ractor.new(name: "worker") do
  loop { Ractor.yield Ractor.receive }
end
r.name  # => 'worker'

Communication Protocols

Ractors support two complementary communication patterns. You will pick the right one depending on which side knows the other.

Push: send and receive

Use this when the sender knows which Ractor it is sending to. The sender does not block.

# Sender side
ractor.send(obj)

# Inside the Ractor — receives the next message in the mailbox
msg = Ractor.receive

# Selective receive — only accept messages matching the block
msg = Ractor.receive_if { |m| m.is_a?(String) }

receive_if filters messages so your Ractor can ignore values it is not ready to process.

Pull: yield and take

Use this when the receiver knows which Ractor it is waiting on. The Ractor calling yield blocks until another Ractor calls take:

# Inside a Ractor
Ractor.yield("result")

# From main or another Ractor
result = ractor.take

You can combine both protocols in the same Ractor — yield a result, then receive new work from the caller. This is the classic producer pattern:

receiver = Ractor.new do
  loop do
    msg = Ractor.receive      # get work
    Ractor.yield msg * 2       # send result back
  end
end

receiver.send(21)
receiver.take  # => 42

Error Handling

If a Ractor raises an exception, it does not crash the whole process. The error is captured and re-raised when you call take:

r = Ractor.new { raise "boom" }

begin
  r.take
rescue Ractor::RemoteError => e
  puts e.cause.message  # => "boom"
end

In practice, you usually let take surface the Ractor::RemoteError and handle it at the call site. For most production use cases, that explicit handling is what you want.

Waiting on Multiple Ractors with Ractor.select

Ractor.select waits until at least one of the given Ractors is ready to communicate, then returns both the Ractor and the value it produced:

r1 = Ractor.new { Ractor.yield "from r1" }
r2 = Ractor.new { Ractor.yield "from r2" }

ready, value = Ractor.select(r1, r2)
puts "#{ready.name}: #{value}"

This is useful for fan-out patterns where multiple workers produce results and you process whichever arrives first. Without Ractor.select, you would have to poll each Ractor sequentially, which wastes cycles and adds latency.

Isolation Rules and Data Passing

Objects passed between Ractors are deep-copied by default. This is the safe default — since each Ractor has its own memory space, a copy is the only sensible behavior.

Some objects are shareable across Ractors without copying. An object is shareable if it is immutable (frozen and containing no references to mutable objects):

i = 123                    # immutable — shareable
s = "hello".freeze         # frozen string — shareable
a = [1, 2, 3].freeze       # shareable (all elements are immutable)

Move Semantics

When you want to transfer ownership of an object so the sender loses access, use move: true in send:

ractor.send(obj, move: true)
# The original reference to `obj` is now nil in the sending Ractor

After a move, the original reference is set to nil. The receiving Ractor gets the actual object. This gives you single-owner semantics — useful for large data structures where copying would be expensive.

Ractors vs Threads vs Fibers

ThreadsFibersRactors
ParallelismConcurrency only (GVL)Cooperative, one at a timeTrue parallelism (multi-core)
MemorySharedCooperativeIsolated (no shared state)
SynchronizationMutexes, queuesNone neededMessage passing only
GVLLimits CPU parallelismN/ABypassed between Ractors
IntroducedRuby 1.8/1.9Ruby 1.9Ruby 3.0

Fibers still have their place — they are lightweight and useful for cooperative multitasking within a single thread, such as pausing and resuming I/O operations. But for CPU-bound work that benefits from multiple cores, Ractors are the right tool.

A Practical Example: Producer-Consumer Pipeline

Here is a producer-consumer setup with multiple workers consuming from a shared channel:

# Channel that receives work and yields results
channel = Ractor.new do
  loop { Ractor.yield Ractor.receive }
end

# Workers that pull from the channel
workers = 4.times.map do |i|
  Ractor.new(channel, name: "worker-#{i}") do |ch|
    loop do
      work = ch.take
      ch.send(work * 2)
    end
  end
end

# Send work and collect results
results = 8.times.map { |n| channel.send(n) }
answers = results.map { channel.take }

puts answers.sort  # => [0, 2, 4, 6, 8, 10, 12, 14]

Each worker pulls the next available item from the channel, processes it, and sends the result back. The channel serializes access so only one Ractor holds any given piece of work at a time.

Watch Out for Deadlocks

Because Ractors block when they call yield or receive with no counterpart ready, it is easy to create circular waits:

r1 = Ractor.new { Ractor.yield Ractor.receive }
r2 = Ractor.new { Ractor.yield Ractor.receive }

Ractor.select(r1, r2)  # Both Ractors are waiting on each other — deadlock

In this code, r1 blocks on receive and r2 blocks on receive, so neither ever reaches the yield. This is the same class of problem as a Thread#join cycle. Plan your message flows so Ractors are never waiting on each other in a loop.

When to Reach for Ractors

Ractors shine for CPU-bound work that you want to parallelize across cores. Image processing, numerical computation, parsing large files — anything that keeps all cores busy and benefits from true parallelism.

For I/O-bound tasks (network requests, file reads), threads are usually sufficient because the GVL is released while waiting. The overhead of Ractors only pays off when the work is heavy enough to justify it.

Ractors also work well when you want to isolate a subsystem completely. If a component only communicates via messages, it cannot corrupt state in the rest of your program by accident.

Conclusion

Ractors give Ruby’s concurrency toolkit something it was missing for a long time: a way to run real parallel work without the thread-safety pitfalls of shared memory. The actor-model communication pattern takes some getting used to, but the safety guarantees are worth it for complex concurrent code.

Start with threads for simple concurrency needs. Add fibers for cooperative multitasking within a single thread. And when you need genuine CPU parallelism with clean isolation, Ractors are the tool Ruby gives you.

See Also: Ruby Threads Basics, Ruby Fiber Basics, Ruby Error Handling