Ruby

Ractors in Ruby: Parallel Execution, Messages & Shareable Objects

Ractors in Ruby: Parallel Execution, Messages & Shareable Objects

Ractors are Ruby’s actor-model concurrency primitive: each Ractor runs Ruby code in parallel on its own thread, with its own isolated objects. Create one with a block via Ractor.new, pass data in with send and Ractor.receive, and collect results with take. As of Ruby 3.4, Ractors are still experimental — creating your first one prints a warning.

Every example and error message in this post was re-run on Ruby 3.4.10; the transcripts show what a plain ruby main.rb run prints. We’ll start with the actor model that ractors are built on, then work through creating ractors, message passing, shareable objects, and the errors you’ll hit along the way.

What is the Actor Model?

In computer science, the object-oriented model is popular, and in the Ruby community, many people are used to the term ‘everything is an object’.

Similarly, let me introduce you to the actor model, within which ‘everything is an actor’. The actor model is a mathematical model of concurrent computation in which the universal primitive of computation is an actor. An actor is capable of the following:

  • Receiving messages and responding to the sender
  • Sending messages to other actors
  • Determining how to respond to the next message received
  • Creating several other actors
  • Making local decisions
  • Performing actions (e.g., mutating data in a database)

Actors communicate via messages, process one message at a time, and maintain their own private state. However, they can modify this state via messages received, eliminating the need for a lock or mutex.

Received messages are processed one message at a time in the order of FIFO (first in, first out). The message sender is decoupled (isolated) from the sent communication, enabling asynchronous communication.

A few examples of the actor model implementation are Akka, Elixir, Pulsar, Celluloid, and ractors. A few examples of concurrency models include threads, processes, and futures.

What Are Ractors in Ruby?

Ractor is an actor-model abstraction that provides a parallel execution feature without thread-safety concerns.

Threads in CRuby take turns under the Global VM Lock (GVL), so only one thread runs Ruby code at any moment, and every thread can touch every object. Ractors flip both properties: each ractor runs on its own native thread with its own lock, so multiple ractors execute Ruby code on multiple cores at the same time — and they do not share everything. Most objects are unshareable; those made shareable are protected by an interpreter or locking mechanism.

Ractors are also unable to access any objects through variables not defined within their scope. This frees us from race conditions.

In 2020, when Ruby 3.0.0 was released, these were the words of Matz:

It’s multi-core age today. Concurrency is very important. With Ractor, along with Async Fiber, Ruby will be a real concurrent language.

Ractors do not claim to have solved all thread-safety problems. In the Ractor documentation, the following is clearly stated:

There are several blocking operations (waiting send, waiting yield, and waiting take) so you can make a program which has dead-lock and live-lock issues.

Some kind of shareable objects can introduce transactions (STM, for example). However, misusing transactions will generate inconsistent state.

Without ractors, you need to trace all state mutations to debug thread-safety issues. However, the beauty of ractors is that we can concentrate our efforts on suspicious shared code.

When and Why Should I Use Ractors in Ruby?

When you create a ractor for the first time, you’ll get a warning like this one:

Shell
main.rb:1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

On Ruby 3.4, the warning points at the line in your code that called Ractor.new (main.rb:1 here), and it prints once per process. It is a real warning — the API can change between Ruby versions — but it does not mean ractors are off-limits: the entire API used in this post runs unchanged from Ruby 3.0 through 3.4.

The headline promise is parallel execution. In the Ruby 3.0.0 release notes, you’ll find this benchmark example of the Tak function, where it is executed sequentially four times, and four times in parallel with ractors:

Ruby
def tarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require 'benchmark'
Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }
 
  # parallel version with ractors
  x.report('par'){
    4.times.map do
      Ractor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end

When Ruby 3.0.0 shipped, the release notes reported the parallel version as 3.87 times faster than the sequential one, measured on Ubuntu 20.04 with an Intel Core i7-6700 (4 cores, 8 hardware threads).

We re-ran the same benchmark for this update on Ruby 3.4.10, inside a ruby:3.4 Docker container on a 20-core x86_64 Linux host, and got a very different picture:

Shell
         user     system      total        real
seq 61.545872   0.000927  61.546799 ( 61.574874)
par501.996085   0.035992 502.032077 (133.566881)

The parallelism itself worked — the user column adds up CPU time across all four cores, and four ractors ran at once. But each tarai call ran several times slower inside a non-main ractor than on the main one, and that per-ractor overhead swallowed the whole gain: 133 seconds of wall-clock time in parallel against 61 sequentially. A second run reproduced the same result.

That is the honest state of ractor performance: it changes from Ruby version to Ruby version, in both directions. Ractors can deliver real speedups on multi-core machines — and they can also lose to sequential code on the same workload that once showcased them. Benchmark your own workload on your own Ruby version before betting on a speedup.

Two guardrails follow from this. First, modifying class/module objects in multi-ractor programs can introduce race conditions and should be avoided as much as possible. Second, ractors are not a background job system: for production background work, a job backend such as Solid Queue or Sidekiq is the practical choice — ractors shine on CPU-bound computation inside a single process.

If you put Ractors to work on a real workload, you can wrap the parallel section in a custom instrumentation span to measure what the extra cores buy you in production.

Creating Your First Ractor in Ruby

Creating a ractor is as easy as creating any class instance. Call Ractor.new with a block — Ractor.new { block }. This block is run in parallel with every other ractor.

Ruby
r = Ractor.new { puts "This is my first ractor" }
# This is my first ractor
 
# create a ractor with a name
r = Ractor.new name: 'second_ractor' do
  puts "This is my second ractor"
end
# This is my second ractor
 
r.name
# => "second_ractor"

Arguments can also be passed to Ractor.new, and these arguments become parameters for the ractor block.

Ruby
my_array = [4,5,6]
Ractor.new my_array do |arr|
  puts arr.each(&:to_s)
end
# 4
# 5
# 6

Passing arguments is not a convenience — it is the only sanctioned way to get outside data into a ractor at creation time, and reaching for an outer variable instead raises the first error most people meet.

can not isolate a Proc because it accesses outer variables (ArgumentError)

A ractor cannot read objects through variables defined outside its block:

Ruby
outer_scope_object = "I am an outer scope object"
Ractor.new do
  puts outer_scope_object
end
Shell
<internal:ractor>:282:in 'Ractor.new': can not isolate a Proc because it accesses outer variables (outer_scope_object). (ArgumentError)
	from main.rb:2:in '<main>'

We get an error on the invocation of .new, related to a Proc not being isolated. This is because Proc#isolate is called at a ractor’s creation to prevent sharing unshareable objects. The fix is to pass the object as an argument to Ractor.new (as in the my_array example), send it as a message, or make it a shareable frozen constant — all covered in the next sections.

The error names the exact variable it caught, and it can catch variables you never meant to share. Reusing an outer scope’s local name inside the block is enough to trip it — here, a rescue => e inside the ractor block collides with an e that already exists outside:

Ruby
begin
  raise "boom"
rescue => e
  puts e.message
end
 
r = Ractor.new do
  raise "inside"
rescue => e
  puts e.message
end
r.take
Shell
boom
<internal:ractor>:282:in 'Ractor.new': can not isolate a Proc because it accesses outer variables (e). (ArgumentError)
	from main.rb:7:in '<main>'

Because e is already a local variable in the outer scope, the block’s rescue => e assigns to that outer variable instead of creating a fresh one — which makes it an outer variable the Proc cannot isolate. Rename the variable inside the block, or move the work into a method, and the error disappears. When this error names a variable you don’t recognize, look for name collisions like this one.

Sending and Receiving Messages in Ractors

Ractors send messages via an outgoing port and receive messages via an incoming port. The incoming port can hold an infinite number of messages and runs on the FIFO principle.

The .send method works the same way a mailman delivers a message in the mail. The mailman takes the message and drops it at the door (incoming port) of the ractor.

However, dropping a message at a person’s door is not enough to get them to open it. .receive is then available for the ractor to open the door and receive whatever message has been dropped.

The ractor might want to do some computation with that message and return a response, so how do we get it? We ask the mailman to .take the response.

Ruby
tripple_number_ractor = Ractor.new do
  puts "I will receive a message soon"
  msg = Ractor.receive
  puts "I will return a tripple of what I receive"
  msg * 3
end
# I will receive a message soon
tripple_number_ractor.send(15) # mailman takes message to the door
# I will return a tripple of what I receive
tripple_number_ractor.take # mailman takes the response
# => 45

The return value of a ractor is also a sent message and can be received via .take. Since this is an outgoing message, it goes to the outgoing port.

Here’s a simple example:

Ruby
r = Ractor.new do
  5**2
end
r.take # => 25

Besides returning a message, a ractor can also send a message to its outgoing port via .yield.

Ruby
r = Ractor.new do
  squared = 5**2
  Ractor.yield squared*2
  puts "I just sent a message out"
  squared*3
end
r.take
# => 50
r.take
# => 75

The first message sent to the outgoing port is squared*2, and the next message is squared*3. Therefore, when we call .take, we get 50 first. We have to call .take a second time to get 75 as two messages are sent to the outgoing port.

This all comes together in one example of customers sending their orders to a supermarket and receiving the fulfilled orders:

Ruby
supermarket = Ractor.new do
  loop do
    order = Ractor.receive
    puts "The supermarket is preparing #{order}"
    Ractor.yield "This is #{order}"
  end
end
 
customers = 5.times.map{ |i|
  Ractor.new supermarket, i do |supermarket, i|
    supermarket.send("a pack of sugar for customer #{i}")
    fulfilled_order = supermarket.take
    puts "#{fulfilled_order} received by customer #{i}"
  end
}
customers.each(&:take)

The output is as follows:

Shell
The supermarket is preparing a pack of sugar for customer 0
This is a pack of sugar for customer 0 received by customer 0
The supermarket is preparing a pack of sugar for customer 1
The supermarket is preparing a pack of sugar for customer 2
This is a pack of sugar for customer 2 received by customer 2
This is a pack of sugar for customer 1 received by customer 1
The supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for customer 4
This is a pack of sugar for customer 4 received by customer 4
This is a pack of sugar for customer 3 received by customer 3

Running it a second time yields:

Shell
The supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for customer 4
The supermarket is preparing a pack of sugar for customer 0
This is a pack of sugar for customer 4 received by customer 4
This is a pack of sugar for customer 0 received by customer 0
The supermarket is preparing a pack of sugar for customer 2
This is a pack of sugar for customer 2 received by customer 2
This is a pack of sugar for customer 3 received by customer 3
The supermarket is preparing a pack of sugar for customer 1
This is a pack of sugar for customer 1 received by customer 1

The output can be in a different order every time we run this, because ractors run in parallel.

A few more details about sending and receiving messages:

  • Messages can also be sent using << msg, instead of .send(msg).
  • You can add a condition to a .receive using receive_if.
  • Objects that cannot be copied — a Thread, for instance — cannot be sent as messages. The TypeError this raises inside the ractor reaches you wrapped: when a ractor dies with an exception, the next .take on it raises Ractor::RemoteError, with the original exception attached as its cause.
Ruby
r = Ractor.new do
  Ractor.yield(Thread.new{})
end
r.take
Shell
<internal:ractor>:644:in 'Ractor.yield': allocator undefined for Thread (TypeError)
	from main.rb:2:in 'block in <main>'
<internal:ractor>:711:in 'Ractor#take': thrown by remote Ractor. (Ractor::RemoteError)
	from main.rb:4:in '<main>'

(Transcript trimmed to its key lines.) Any exception a ractor raises internally arrives at the caller in this shape — thrown by remote Ractor. (Ractor::RemoteError) — so when you see it, rescue it and inspect cause to find the real error. Two more errors have sections of their own.

The incoming-port is already closed and The outgoing-port is already closed (Ractor::ClosedError)

A ractor whose block runs once (not in a loop) terminates after returning its value, and both of its ports close: the incoming port when the ractor terminates, and the outgoing port once its last message has been taken. Sending to, or taking from, a closed port raises Ractor::ClosedError — the message tells you which port it was:

Ruby
r = Ractor.new do
  Ractor.receive
end
r << 5
r.take # => 5
r << 9
Shell
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

Calling .take a second time raises the outgoing variant instead:

Shell
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

The fix depends on which side raised. For the incoming variant, the ractor you are sending to has already terminated — keep it alive with a loop do ... end around Ractor.receive (as in the supermarket example) if it should serve more than one message. For the outgoing variant, you have taken more values than the ractor produced — call .take once per returned or yielded message. You can also close ports deliberately, with Ractor#close_incoming and Ractor#close_outgoing.

can not send any methods to a moved object (Ractor::MovedError)

Objects can be moved to a destination ractor via .send(obj, move: true) or .yield(obj, move: true). These objects become inaccessible at the source: the variable that held the object now holds a Ractor::MovedObject placeholder, and calling any method on it raises Ractor::MovedError.

Ruby
r = Ractor.new do
  Ractor.receive
end
outer_object = "outer"
r.send(outer_object, move: true)
r.take # => "outer"
outer_object + "moved"
Shell
main.rb:7:in 'Ractor::MovedObject#method_missing': can not send any methods to a moved object (Ractor::MovedError)
	from main.rb:7:in '<main>'

The object still exists — it lives in the destination ractor now, which is why r.take returns "outer". The fix is a rule of thumb: move an object only when the sender is done with it. If both sides need the value, send it without move: true and let the ractor work on a copy (the default, covered next).

Shareable and Unshareable Objects

Shareable objects are objects that can be sent to and from a ractor without compromising thread safety. An immutable object is a good example because once created, it cannot be changed — e.g., numbers and booleans.

You can check the shareability of an object via Ractor.shareable? and make an object shareable via Ractor.make_shareable.

Ruby
Ractor.shareable?(5)
# => true
Ractor.shareable?(true)
# => true
Ractor.shareable?([4])
# => false
Ractor.shareable?('string')
# => false

Immutable objects are shareable and mutable ones aren’t. In Ruby, we usually call the .freeze method on a string to make it immutable. This is the same method ractors apply to make an object shareable.

Ruby
str = 'string'
Ractor.shareable?(str)
# => false
Ractor.shareable?(str.freeze)
# => true
arr = [4]
arr.frozen?
# => false
Ractor.make_shareable(arr)
# => [4]
arr.frozen?
# => true

Messages sent via ractors can either be shareable or unshareable. When shareable, the same object is passed around. However, when unshareable, ractors perform a full copy of the object by default and send the full copy instead. Comparing object IDs on either side of the port shows the difference (on Ruby 3.4, object IDs are small sequential numbers):

Ruby
SHAREABLE = 'share'.freeze
SHAREABLE.object_id
# => 16
r = Ractor.new do
  loop do
    msg = Ractor.receive
    puts msg.object_id
  end
end
r.send(SHAREABLE)
# 16
NON_SHAREABLE = 'can not share me'
NON_SHAREABLE.object_id
# => 24
r.send(NON_SHAREABLE)
# 32

The shareable object is the same within and outside the ractor. However, the unshareable one isn’t because the ractor has a different object, identical in content only.

Another method to send an exact object when it is unshareable is the previously discussed move: true. This moves an object to a destination without needing to perform a copy.

A few things worth knowing about sharing objects in ractors:

  • Ractor objects are themselves shareable.
  • Constants that are shareable, but defined outside the scope of a ractor, can be accessed by a ractor. Recall our outer_scope_object example? Give it another try, defined as OUTER_SCOPE_OBJECT = "I am an outer scope object".freeze — it prints without error.
  • Class and module objects are shareable — but that shareability has a boundary, and crossing it raises the last error in this post.

can not get unshareable values from instance variables (Ractor::IsolationError)

Class and module objects are shareable, but instance variables or constants defined within them are not if assigned to unshareable values. Reading such an instance variable from a non-main ractor raises Ractor::IsolationError:

Ruby
class C
  CONST = 5
  @share_me = 'share me'.freeze
  @keep_me = 'unaccessible'
  def bark
   'barked'
  end
end
 
r = Ractor.new C do |c|
  puts c::CONST
  puts c.new.bark
  puts c.instance_variable_get(:@share_me)
  puts c.instance_variable_get(:@keep_me)
end
r.take
Shell
5
barked
share me
main.rb:14:in 'Kernel#instance_variable_get': can not get unshareable values from instance variables of classes/modules from non-main Ractors (Ractor::IsolationError)
<internal:ractor>:711:in 'Ractor#take': thrown by remote Ractor. (Ractor::RemoteError)
	from main.rb:16:in '<main>'

(Transcript trimmed to its key lines.) The shareable pieces come through: the constant, the method call, and the frozen @share_me. The unfrozen @keep_me raises the Ractor::IsolationError inside the ractor — and, as with every in-ractor exception, r.take re-raises it wrapped in Ractor::RemoteError.

The fix follows the pattern of this whole section: freeze the value (@keep_me = 'unaccessible'.freeze) or make it shareable with Ractor.make_shareable so the ractor can read it — or read it on the main ractor and pass the result in as a message or block argument.

The State of Ractors on Current Ruby

On Ruby 3.4, ractors remain exactly what the warning says: experimental. The API this post uses has been stable in practice since Ruby 3.0 — every example here runs on 3.4.10 with the same semantics it had in 2022 — but the experimental label is not decoration. Our benchmark re-run showed the performance profile can shift dramatically between versions, and Ruby’s developers are redesigning the Ractor API for a future version of Ruby, so the message-passing methods described here may change names or behavior. Check the release notes for your Ruby version before building on them.

What does real-world use look like today? Ractors fit CPU-bound parallel computation over independent data — parsing, number crunching, batch transformations — inside a single process. The main constraint is the ecosystem: most gems are not Ractor-safe, because anything that touches unshareable global state (class-level caches, configuration objects, database connections) raises inside a non-main ractor. That keeps ractors out of the typical Rails request path for now. For I/O-bound concurrency, threads and fibers remain the better fit, and for production background work, job systems are the practical choice — the When and Why section covers that trade-off.

None of this makes ractors a dead end. The isolation model — no shared mutable state, communication only by message — is the part worth learning, because it is the direction Ruby’s parallelism story is heading, whatever the method names end up being.

Wrap Up and Further Reading on Ractors

The one pattern to carry out of this post: every exception raised inside a ractor reaches you wrapped in Ractor::RemoteError — rescue it and inspect cause.

Ractors go deeper than this. Other public methods worth exploring include Ractor.select, which waits on several ractors at once and returns a [ractor, value] pair; Ractor.main and Ractor.count for introspecting the running process; and receive_if for receiving messages selectively.

To expand your knowledge about ractors, check out the ractor documentation. This GitHub gist might also interest you if you’d like to experimentally compare ractors with threads.

Happy coding!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

Are Ractors still experimental in Ruby?
Yes. On Ruby 3.4, creating your first Ractor prints a warning that Ractor is experimental and behavior may change in future versions. The core API works for parallel computation, but most gems and libraries are not Ractor-safe yet.
What is the difference between Ractors and threads in Ruby?
Threads share every object and run one at a time under the Global VM Lock. Ractors run in parallel on multiple cores and share almost nothing: objects must be immutable, made shareable, or copied or moved between Ractors as messages.
How do Ractors send and receive messages?
Call send (or <<) on a Ractor to push a message to its incoming port, Ractor.receive inside the Ractor to read it, Ractor.yield to emit output, and take on the Ractor to collect results. Those are the method names on Ruby 3.4.
When should I use Ractors?
Use Ractors for CPU-bound work that benefits from multiple cores, such as parallel computation over independent data. For I/O-bound work, threads or fibers remain a better fit, and for production background work, a job system such as Solid Queue or Sidekiq is the practical choice.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Abiodun Olowode

Abiodun Olowode

Our guest author Abiodun is a software engineer who works with Ruby/Rails and React. She is passionate about sharing knowledge via writing/speaking and spends her free time singing, binge-watching movies, and watching football games.

All articles by Abiodun Olowode

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal