Ruby 3 has introduced an experimental feature called Ractors(previously known as Guilds). Ractor is an actor model abstraction for Ruby. It allows us to execute many code blocks parallelly. Before going into Ractors, let's discuss what an actor model is.

What is an actor model?

In the actor model, everything is an actor, like everything is an object in the object-oriented model. Each actor is isolated from other actors. So an actor cannot access the state of another actor, which prevents multiple processes from modifying the same data in concurrent systems. Actors interact with the outside environment using messages. Due to these reasons, the actor model is preferred in concurrent computational systems. Ractor is an implementation of the actor model in ruby.

Creating a Ractor

A Ractor can be created using Ractor.new { }.

3.0.0 :001 > first_ractor = Ractor.new { puts 'Hello world! I am a ractor' }
<internal:ractor>:267: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
Hello world! I am a ractor
 => #<Ractor:#2 (irb):1 terminated>

A Ractor can be also created using a name.

3.0.0 :001 > first_ractor = Ractor.new(name: 'ractor_1') { nil }
 => #<Ractor:#2 ractor_1 (irb):1 terminated> 
3.0.0 :002 > first_ractor.name
 => "ractor_1" 

Ractors cannot access objects created outside their scope.

3.0.0 :001 > animal = 'Elephant'
 => "Elephant" 
3.0.0 :002 > Ractor.new { puts "#{animal} is a mammal" }
Traceback (most recent call last):
        5: from /Users/user_name/.rvm/rubies/ruby-3.0.0/bin/irb:23:in `<main>'
        4: from /Users/user_name/.rvm/rubies/ruby-3.0.0/bin/irb:23:in `load'
        3: from /Users/user_name/.rvm/rubies/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/irb-1.3.0/exe/irb:11:in `<top (required)>'
        2: from (irb):2:in `<main>'
        1: from <internal:ractor>:267:in `new'
ArgumentError (can not isolate a Proc because it accesses outer variables (animal).)

But we can pass outside objects as parameters to a ractor.

3.0.0 :001 > animal = 'Elephant'
 => "Elephant" 
3.0.0 :002 > Ractor.new animal do |a|
3.0.0 :003 >   puts "#{a} is a mammal"
3.0.0 :004 > end
Elephant is a mammal
 => #<Ractor:#2 (irb):2 terminated> 

Messaging in Ractors

Ractors communicate using a messaging system.
There are two types of messaging:

1. Push-type messaging

  • In this type, the sender knows the receiver of the message. But the receiver does not know the sender.
  • This is done using send() method to send the message and receive method to receive the message.
3.0.0 :001 > receiver = Ractor.new do
3.0.0 :002 >   message = Ractor.receive
3.0.0 :003 >   puts "received message is #{message}"
3.0.0 :004 > end
 => #<Ractor:#2 (irb):1 blocking> 
3.0.0 :005 > message = 'Hi!'
 => "Hi!" 
3.0.0 :006 > receiver.send(message)
received message is Hi!
 => #<Ractor:#2 (irb):1 terminated> 

Here the receiver has an active incoming port and it will remain active until it receives a message.

2. Pull-type messaging

  • In this type, the sender does not know the receiver. But the receiver knows from which sender it will receive the message.
  • This is done using yield() method to send the message and take method to receive the message.
3.0.0 :001 > sender = Ractor.new do
3.0.0 :002 >   message = 'Hi!'
3.0.0 :003 >   Ractor.yield(message)
3.0.0 :004 > end
 => #<Ractor:#2 (irb):1 blocking> 
3.0.0 :005 > message = sender.take
 => "Hi!" 
3.0.0 :006 > puts "received message is #{message}"
received message is Hi!
 => nil

Here the sender has an active outgoing port and it will remain active until the message is received. If yield method is not specified, then the last return object is returned.

Object sharing

For thread safety, only immutable objects are regarded as shareable objects in ractors. We can check if an object is shareable or not using Ractor.shareable?() method. Some examples of shareable objects are numbers, boolean, modules, classes, other ractors.
Strings are unsharable since they are mutable. .freeze method can be called on the string to make it immutable and thereby shareable.

3.0.0 :001 > Ractor.shareable?('I am a string')
 => false 
3.0.0 :002 > Ractor.shareable?('I am a string'.freeze)
 => true 

We can also make an object shareable using Ractor.make_shareable() method.

3.0.0 :001 > mutable_object = 'I am unshareable'
 => "I am unshareable" 
3.0.0 :002 > Ractor.shareable?(mutable_object)
 => false 
3.0.0 :003 > Ractor.make_shareable(mutable_object)
 => "I am unshareable" 
3.0.0 :004 > Ractor.shareable?(mutable_object)
 => true 

If unshareable objects are sent via ractors, a deep copy of the object is first created and then this deep copy is sent.

3.0.0 :001 > receiver = Ractor.new do
3.0.0 :002 >   message = receive
3.0.0 :003 >   puts message.object_id
3.0.0 :004 > end
 => #<Ractor:#2 (irb):1 blocking> 
3.0.0 :005 > mutable_object = 'I am unshareable'
 => "I am unshareable" 
3.0.0 :006 > puts mutable_object.object_id
260
 => nil 
3.0.0 :007 > receiver.send(mutable_object)
 => #<Ractor:#2 (irb):1 blocking> 
280

In the above example, object_id of the mutable_object changed at the receiver. It is because a deep copy of the mutable_object was created and sent. We can prevent sending deep copies by directly moving an unshareable object to a ractor. This is done by adding move: true parameter in send() method. But the object becomes inaccessible outside the ractor.

3.0.0 :001 > receiver = Ractor.new do
3.0.0 :002 >   message = receive
3.0.0 :003 >   puts message
3.0.0 :004 > end
 => #<Ractor:#2 (irb):1 blocking> 
3.0.0 :005 > mutable_object = 'I am unshareable'
 => "I am unshareable" 
3.0.0 :006 > puts mutable_object.inspect
"I am unshareable"
 => nil 
3.0.0 :007 > receiver.send(mutable_object, move: true)
I am unshareable
 => #<Ractor:#2 (irb):1 terminated> 
3.0.0 :008 > puts mutable_object.inspect
Traceback (most recent call last):
        5: from /Users/user_name/.rvm/rubies/ruby-3.0.0/bin/irb:23:in `<main>'
        4: from /Users/user_name/.rvm/rubies/ruby-3.0.0/bin/irb:23:in `load'
        3: from /Users/user_name/.rvm/rubies/ruby-3.0.0/lib/ruby/gems/3.0.0/gems/irb-1.3.0/exe/irb:11:in `<top (required)>'
        2: from (irb):8:in `<main>'
        1: from (irb):8:in `method_missing'
Ractor::MovedError (can not send any methods to a moved object)

Example - Movie seat booking using ractors

Let us assume that there are 50 seats for a movie. We can define a ractor seats that will provide movie seats on a first come first serve basis.

3.0.0 :001 > seats = Ractor.new {
3.0.0 :002 >   seat_no = 1
3.0.0 :003 >   50.times do
3.0.0 :004 >     Ractor.yield "Your seat number is #{seat_no}"
3.0.0 :005 >     seat_no += 1
3.0.0 :006 >   end
3.0.0 :007 >   'No more seats'
3.0.0 :008 > }
 => #<Ractor:#2 (irb):1 blocking> 

Now if a person needs a ticket he can call the take method in seats ractor and it will provide a seat.

3.0.0 :009 > ticket1 = Ractor.new seats do |seats|
3.0.0 :010 >   seat = seats.take
3.0.0 :011 >   puts seat
3.0.0 :012 > end
Your seat number is 1
 => #<Ractor:#3 (irb):9 terminated> 
3.0.0 :013 > ticket2 = Ractor.new seats do |seats|
3.0.0 :014 >   seat = seats.take
3.0.0 :015 >   puts seat
3.0.0 :016 > end
Your seat number is 2
 => #<Ractor:#4 (irb):13 terminated> 
3.0.0 :017 > ticket3 = Ractor.new seats do |seats|
3.0.0 :018 >   seat = seats.take
3.0.0 :019 >   puts seat
3.0.0 :020 > end
Your seat number is 3
 => #<Ractor:#5 (irb):17 terminated> 

This way many users can book seats concurrently using ractors. An outgoing port is created in the seats ractor using yield method for each seat. The last return value in the block 'No more seats' is returned if take method is called after all the 50 tickets are sold. If the take method is called again we get a port closed error.

3.0.0 :xxx > ticket51 = Ractor.new seats do |seats|
3.0.0 :xxx >     seat = seats.take
3.0.0 :xxx >     puts seat
3.0.0 :xxx > end
No more seats
 => #<Ractor:#x (irb):xx terminated> 
3.0.0 :xxx > ticket52 = Ractor.new seats do |seats|
3.0.0 :xxx >     seat = seats.take
3.0.0 :xxx >     puts seat
3.0.0 :xxx > end
#<Thread:0x0000000120059850 run> terminated with exception (report_on_exception is true):
<internal:ractor>:694:in `take': The outgoing-port is already closed (Ractor::ClosedError)
        from (irb):xx:in `block in <main>'
 => #<Ractor:#x (irb):xx terminated> 

Conclusion

Ractors can be very useful if we want to add concurrency to our code. Each ractor maintains a private state and they use message sharing for communication. So it helps us to avoid concurrency issues like race conditions. But ractors are still an experimental feature in Ruby 3. So it's best to not use them for large-scale concurrent applications. Future versions of ruby might polish this feature more and make it a permanent feature. Until then we can use ractors to write small-scale concurrent code.

References