DEV Community

Cover image for An Introduction to Ractors in Ruby
Abiodun Olowode for AppSignal

Posted on • Originally published at blog.appsignal.com

An Introduction to Ractors in Ruby

In this post, we'll dive into ractors in Ruby, exploring how to build a ractor. You'll send and receive messages in ractors, and learn about shareable and unshareable objects.

But first, let's define the actor model and ractors, and consider when you should use ractors.

What is the Actor Model?

In computer science, the object-oriented model is very 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/fundamental agent 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.

Just like threads, ractors provide true parallelism. However, unlike threads, they do not share everything. Most objects are unshareable, and when they are 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 means that we can be free of the possibility of 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:

<internal:ractor>:267: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
Enter fullscreen mode Exit fullscreen mode

However, that does not mean that you should avoid using ractors. Due to parallel execution, ractors can complete processes way faster than when processes are carried out synchronously.

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:

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
Enter fullscreen mode Exit fullscreen mode

The results are as follows:

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)
Enter fullscreen mode Exit fullscreen mode

The Ruby 3.0.0 release notes state:

The result was measured on Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 cores, 8 hardware threads). It shows that the parallel version is 3.87 times faster than the sequential version.

So if you need a faster process execution time that can run in parallel on machines with multiple cores, ractors are not a bad idea at all.

Modifying class/module objects on multi-ractor programs can introduce race conditions and should be avoided as much as possible. However, most objects are unshareable, so the need to implement locks to prevent race conditions becomes obsolete. If objects are shareable, they are protected by an interpreter or locking mechanism.

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.

It is important to note that every example shown from this point onwards was performed in Ruby 3.1.2.

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"
Enter fullscreen mode Exit fullscreen mode

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

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

Recall how we talked about ractors being unable to access objects defined outside their scope? Let's see an example of that:

outer_scope_object = "I am an outer scope object"
Ractor.new do
  puts outer_scope_object
end
# <internal:ractor>:267:in `new': can not isolate a Proc because it accesses outer variables (outer_scope_object). (ArgumentError)
Enter fullscreen mode Exit fullscreen mode

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. However, objects can be passed to and from ractors via messages.

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.

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
Enter fullscreen mode Exit fullscreen mode

As seen above, 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:

r = Ractor.new do
  5**2
end
r.take # => 25
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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.

Let's put this all together in one example of customers sending their orders to a supermarket and receiving the fulfilled orders:

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
}
Enter fullscreen mode Exit fullscreen mode

The output is as follows:

The supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for 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 2 received by customer 2
The supermarket is preparing a pack of sugar for customer 0
This is a pack of sugar for customer 1 received by customer 1
This is a pack of sugar for customer 0 received by customer 0
The supermarket is preparing a pack of sugar for customer 4
This is a pack of sugar for customer 4 received by customer 4
Enter fullscreen mode Exit fullscreen mode

Running it a second time yields:

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 4
This is a pack of sugar for customer 4 received by customer 4
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 supermarket is preparing a pack of sugar for customer 3
The supermarket is preparing a pack of sugar for customer 2
This is a pack of sugar for customer 3 received by customer 3
This is a pack of sugar for customer 2 received by customer 2
Enter fullscreen mode Exit fullscreen mode

The output can most definitely be in a different order every time we run this (because ractors run concurrently, as we have established).

A few things to note 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.
  • When .send is called on a ractor that is already terminated (not running), you get a Ractor::ClosedError.
  • A ractor's outgoing port closes after .take is called on it if it runs just once (not in a loop).
r = Ractor.new do
  Ractor.receive
end
# => #<Ractor:#61 (irb):120 running>
r << 5
# => #<Ractor:#61 (irb):120 terminated>
r.take
# => 5
r << 9
# <internal:ractor>:583:in `send': The incoming-port is already closed (Ractor::ClosedError)
r.take
# <internal:ractor>:694:in `take': The outgoing-port is already closed (Ractor::ClosedError)
Enter fullscreen mode Exit fullscreen mode
  • Objects can be moved to a destination ractor via .send(obj, move: true) or .yield(obj, move: true). These objects become inaccessible at the previous destination, raising a Ractor::MovedError when you try to call any other methods on the moved objects.
r = Ractor.new do
  Ractor.receive
end
outer_object = "outer"
r.send(outer_object, move: true)
# => #<Ractor:#3 (irb):7 terminated>
outer_object + "moved"
# `method_missing': can not send any methods to a moved object (Ractor::MovedError)
Enter fullscreen mode Exit fullscreen mode
  • Threads cannot be sent as messages using .send and .yield. Doing this results in a TypeError.
r = Ractor.new do
  Ractor.yield(Thread.new{})
end
# <internal:ractor>:627:in `yield': allocator undefined for Thread (TypeError)
Enter fullscreen mode Exit fullscreen mode

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.

Ractor.shareable?(5)
# => true
Ractor.shareable?(true)
# => true
Ractor.shareable?([4])
# => false
Ractor.shareable?('string')
# => false
Enter fullscreen mode Exit fullscreen mode

As seen above, 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.

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

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.

SHAREABLE = 'share'.freeze
# => "share"
SHAREABLE.object_id
# => 350840
r = Ractor.new do
  loop do
    msg = Ractor.receive
    puts msg.object_id
  end
end
r.send(SHAREABLE)
# 350840
NON_SHAREABLE = 'can not share me'
NON_SHAREABLE.object_id
# => 572460
r.send(NON_SHAREABLE)
# 610420
Enter fullscreen mode Exit fullscreen mode

As seen above, the shareable object is the same within and outside the ractor. However, the unshareable one isn't because the ractor has a different object, just identical to it.

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 to note about sharing objects in ractors:

  • Ractor objects are also shareable objects.
  • 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.
  • Class and module objects are shareable, but instance variables or constants defined within them are not if assigned to unshareable values.
class C
  CONST = 5
  @share_me = 'share me'.freeze
  @keep_me = 'unaccessible'
  def bark
   'barked'
  end
end

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
# 5
# barked
# share me
# (irb):161:in `instance_variable_get': can not get unshareable values from instance variables of classes/modules from non-main Ractors (Ractor::IsolationError)
Enter fullscreen mode Exit fullscreen mode
  • An incoming port or outgoing port can be closed using Ractor#close_incoming and Ractor#close_outgoing, respectively.

Wrap Up and Further Reading on Ractors

In this article, we introduced the concept of ractors, including when and why to use them and how to get started. We also looked at how they communicate with one another, what objects are shareable and unshareable, and how to make objects shareable.

Ractors go deeper than this. Many other public methods can be called on ractors, like select to wait for the success of take, yield and receive, count, current, etc.

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.

Ractors are indeed experimental, but they certainly look like they have a bright future in Ruby's evolution.

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!

Top comments (0)