Ractor - getting started

… and check why 5600+ Rails engineers read also this

Ractor - getting started

This is just my notes from learning. Just remember:

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

You have been warned. Let’s start:

Creating an actor

A simple actor is created as follows:

simple = Ractor.new do
  # do something
end

You can pass arguments to a created actor using the actor’s initializer. You can also set a name for an actor here. Arguments that are passed to Ractor.new() become block parameters for the given block. However, an interpreter does not pass the parameter object references, but rather sends them as messages.

obj = Object.new
val = "I'm a value"

simple = Ractor.new(obj, val, name: "Simple actor") do |obj, val|
  "#{name} has: obj: #{obj.object_id}, val: #{val.object_id}"
end

puts "Created here: obj: #{obj.object_id}, val: #{val.object_id}"
puts simple.take

If you check the results:

Created here: obj: 167328, val: 167336
Simple actor has: obj: 167344, val: 167352

It’s clear that the number of arguments that have been passed is not the same as the number created. See more…

Another thing to keep in mind is:

> simple
=> #<Ractor:#5 Simple actor (irb):57 terminated>

This simple actor has yielded the result and it is now terminated. It won’t accept any more messages, which causes an error:

> simple.send "anything"
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

Also, you can’t get the result again because it has already been taken from the output mailbox.

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

You can define an actor as something that yields message repeatedly. Each take will “consume” single yield.

up_to_3_times = Ractor.new do 
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
end

Interesting thing is the up_to_3_times actor yields 4 values.

> up_to_3_times.take
=> 3
> up_to_3_times.take
=> 2
> up_to_3_times.take
=> 1
> up_to_3_times.take
=> nil
> up_to_3_times.take
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

The first 3 works as expected. But there is 4th one that yields a nil value. That’s because block return value is also yielded by an actor. Updated actor:

up_to_3_times = Ractor.new do
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
  "finished here"
end

It produces:

> loop { puts up_to_3_times.take }
3
2
1
finished here
=> nil

To avoid that you can use close_outgoing method.

up_to_3_times = Ractor.new do
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
  close_outgoing
end

It produces:

> loop { puts up_to_3_times.take }
3
2
1
=> nil

The samples above have already used the actor’s communication mechanism. But let’s dive into it and intentionally send & receive messages.

Sending & receiving messages

Actors communicate with other parts by sending and receiving messages. Here is an example of an actor that receives a string of text and displays it in upper case:

upcase = Ractor.new do
  msg = Ractor.receive
  Ractor.yield msg.upcase
end

The actor is waiting for a message. To send a message, use:

> upcase.send "abc"
=> #<Ractor:#14 (irb):123 running>

The actor now has received a message, processed it and yielded the result to the outbox. To check the result you need to take it from the actor’s outbox.

> upcase.take
=> "ABC"

Just remember this is short-lived actor. So let’s make it alive for some more time.

Long running actors

If you want you actor to stay for a while and process more than single message the implementation could be similar to:

upcase = Ractor.new do
  loop do
    Ractor.yield receive.upcase
  end
end

Now we could make some texts all capital letters multiple times:

upcase.send "aaa"
upcase.send "bbb"
upcase.send "ccc"

Whenever we send a message to an actor, it will go into their inbox. The receive method will get the message from the inbox. Then, the actor can process it. Finally a processed message will be placed in the actor’s outbox. Then we can get the results:

> loop { puts upcase.take }
AAA
BBB
CCC

But wait. The loop isn’t finished. We’ve entered an infinite loop. The upcase actor is waiting for the next message to process. The receive method waits for any message in the actor’s inbox. The main thread is in an infinite loop, waiting for a new message in the actor’s outbox. The take method blocks execution until any message is available. This is what happens when you use this type of actor communication in the example.

Types of actor’s communication

There are two ways actors can communicate:

  • Push type
    • Using the send & receive method pair
    • The sender knows the destination of the message
    • The receiver does not know the sender, and it accepts all messages
    • The send puts a message in the receiver’s infinite inbox queue, and it does not block the execution
    • The send is non-blocking, and the receive waits for any message to be available in the actor’s inbox
    • Used by most actor-based languages
  • Pull type
    • Using the yield & take method pair
    • The sender yields the message, but it does not know its destination
    • The receiver knows the sender and takes the message from its outbox
    • The receiver will block when there is no message
    • Both yield & take are blocking execution. The outbox could have only one message, and yield waits until the previous message is taken from the actor’s outbox The take method blocks execution and waits for a message to be available in the actor’s outbox

Actor’s lifecycle

Actor lives as long as its incoming port is open. In this example:

r = Ractor.new do
  loop do
    close_incoming if receive == 0
  end
  "I'm done"
end

We could see the behavior:

> r
=> #<Ractor:#18 (irb):93 running>

The actor is alive and waits for incoming messages.

> r.send 123
=> #<Ractor:#18 (irb):93 running>

When a message is received, it is processed, and the actor keeps running.

> r.send 0
=> #<Ractor:#18 (irb):93 terminated>

When the system receives a termination message, it closes the incoming port (meaning it stops accepting incoming messages) and marks the actor as “terminated.” If you try to send another message to the actor after this, you’ll get an error.

> r.send 42
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

However, we could still send the message to the actor’s outbox.

irb(main):102> r.take
=> "I'm done"

The message was placed there because the actor was having difficulty thinking of new lines. We can see that close_incoming also breaks the actor’s infinite loop.

Next, when you try to send a message from the outbox, you get an error.

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

If we run the same tests again using the close_outgoing method (which closes the actor’s outgoing port) we’ll see something interesting

r = Ractor.new do
  loop do
    close_outgoing if receive == 0
  end
  "I'm done"
end

At the beginning, the actor is alive and running.

> r
=> #<Ractor:#19 (irb):105 running>

It accepts messages:

> r.send 123
=> #<Ractor:#19 (irb):105 running>

The termination message does not change actor’s state:

> r.send 0
=> #<Ractor:#19 (irb):105 running>

The actor still accepts messages:

> r.send 42
=> #<Ractor:#19 (irb):105 running>

But message cannot be fetched from the actor’s outbox:

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

However, the actor is still running:

irb(main):116> r
=> #<Ractor:#19 (irb):105 running>

If there’s an error in the actor’s execution block, things are a bit different.

r = Ractor.new do
  loop do
    raise "Error" if receive == 0
  end
  "I'm done"
end

At the start, the actor runs and reads incoming messages.

irb(main):123> r.send 123
=> #<Ractor:#20 (irb):117 running>

After the termination message an error is thrown and the actor is terminated:

irb(main):124> r.send 0
#<Thread:0x000000011fd64000 run> terminated with exception (report_on_exception is true):
(irb):119:in 'block (2 levels) in <top (required)>': Error (RuntimeError)

The actor is terminated and won’t accept any more messages:

> r
=> #<Ractor:#20 (irb):117 terminated>
> r.send 42
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

It also does not allow to take messages from its outbox either:

> r.take
<internal:ractor>:711:in 'Ractor#take': thrown by remote Ractor. (Ractor::RemoteError)

Summary

one actor is no actor, they come in systems

That’s just the beginning…

More reading:

You might also like