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:
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:
The results are as follows:
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.
Arguments can also be passed to Ractor.new
, and these arguments become parameters for the ractor block.
Recall how we talked about ractors being unable to access objects defined outside their scope? Let's see an example of that:
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.
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:
Besides returning a message, a ractor can also send a message to its outgoing port via .yield
.
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:
The output is as follows:
Running it a second time yields:
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).
- 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.
- Threads cannot be sent as messages using
.send
and.yield
. Doing this results in aTypeError
.
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
.
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.
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.
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.
- 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!