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.
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
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)
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"
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
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)
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
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
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
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
}
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
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
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
usingreceive_if
. - When
.send
is called on a ractor that is already terminated (not running), you get aRactor::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)
- 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 aRactor::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)
- Threads cannot be sent as messages using
.send
and.yield
. Doing this results in aTypeError
.
r = Ractor.new do
Ractor.yield(Thread.new{})
end
# <internal:ractor>:627:in `yield': allocator undefined for Thread (TypeError)
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
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
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
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 asOUTER_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)
- An incoming port or outgoing port can be closed using
Ractor#close_incoming
andRactor#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)