class Ractor

Ractor is a Thread-alike object that provides thread-safety by design.

Before Ractor introduction, Ruby had one Global Virtual Machine Lock (GVL) per process, which only one Thread at a time could possess, therefore prohibiting true concurrency.

Since Ruby 3.0, Ruby has one GVL per ractor (including the main ractor, which is implicit), and different ractors can be performed truly in parallel.

To achieve this, ractors severely limit data sharing between different ractors. Unlike threads, ractors can't access each other's data, nor the data in the outer scope.

# The simplest ractor
r = Ractor.new { puts "I am in Ractor!" }
sleep(0.1) # allow it to finish
# here "I am in Ractor!" would be printed

a = 1
r = Ractor.new { puts "I am in Ractor! a=#{a}" }
# fails immediately with
# ArgumentError (can not isolate a Proc because it accesses outer variables (a).)

Instead of accessing the shared state, the data should be passed to and from ractors via sending and receiving messages, thus making them actors (“Ractor” stands for “Ruby Actor”).

a = 1
r = Ractor.new {
  a_in_ractor = receive # receive blocks till somebody will pass message
  puts "I am in Ractor! a=#{a_in_ractor}"
}
r.send(a)  # pass it
sleep(0.1)
# here "I am in Ractor! a=1" would be printed

There are two pairs of methods for sending/receiving messages:

In addition to that, an argument to Ractor.new would be passed to block and available there as if received by Ractor.receive, and the last block value would be sent outside of the ractor as if sent by Ractor.yield.

A little demonstration on a classic ping-pong:

server = Ractor.new do
  puts "Server starts: #{self.inspect}"
  "Server sends: ping"
  Ractor.yield 'ping'                       # The server doesn't know the receiver and sends to whoever interested
  received = Ractor._receive                # The server doesn't know the sender and receives from whoever sent
  puts "Server received: #{received}"
end

client = Ractor.new(server) do |srv|        # The server is sent inside client, and available as srv
  puts "Client starts: #{self.inspect}"
  received = srv.take                       # The Client takes a message specifically from the server
  puts "Client received from " \
       "#{srv.inspect}: #{received}"
  puts "Client sends to " \
       "#{srv.inspect}: pong"
  srv.send 'pong'                           # The client sends a message specifically to the server
end

sleep(0.1)

This will output:

Server starts: #<Ractor:#2 test.rb:1 running>
Client starts: #<Ractor:#3 test.rb:8 running>
Client received from #<Ractor:#2 rac.rb:1 blocking>: ping
Client sends to #<Ractor:#2 rac.rb:1 blocking>: pong
Server received: pong

It is said that Ractor receives messages via the incoming port, and sends them to the outgoing port. Either one can be disabled with Ractor.close_incoming and Ractor.close_outgoing respectively.

Shareable and unshareable objects

When the object is sent to and from the ractor, it is important to understand whether the object is shareable or not. Shareable objects are basically those which can be used by several threads without compromising thread-safety; e.g. immutable ones. Ractor.shareable? allows to check this, and Ractor.make_shareable tries to make object shareable if it is not.

Ractor.shareable?(1)        #=> true -- numbers and other immutable basic values are
Ractor.shareable?('foo')    #=> true or false, depending on whether you have freeze_string_literals: true
Ractor.shareable?(-'foo')   #=> true
Ractor.shareable?(+'foo')   #=> false

str = +'foo'
str.frozen?                 #=> false
Ractor.make_shareable(str)
str.frozen?                 #=> true

When a shareable object is sent (via send or Ractor.yield), no additional processing happens, and it just becomes usable by both ractors. When an unshareable object is sent, it can be either copied or moved. The first is the default, and it makes the object's full copy by dumping and loading it with Marshal.

data = [+'foo', +'bar']
Ractor.new(data){
  data_in_ractor = Ractor.receive
  puts "In ractor: #{data_in_ractor.object_id}, #{data_in_ractor[0].object_id}"
}
r.send(data, move: true)
sleep(0.1)
puts "Outside: #{data.object_id}, #{data[0].object_id}"

This will output:

In ractor: 100, 120
Outside: 60, 80

(Note that object id of both array and string inside array have changed inside the ractor, showing it is different objects.)

Dumping data with Marshal may be slow, and sometimes impossible. Alternatively, move: true may be used on sending. This will move the object to the receiving ractor, making it inaccessible for a sending ractor.

data = [+'foo', +'bar']
r = Ractor.new{
  data_in_ractor = Ractor.receive
  puts "In ractor: #{data_in_ractor.object_id}, #{data_in_ractor[0].object_id}"
}
r.send(data, move: true)
sleep(0.1)
puts "Outside: moved? #{Ractor::MovedObject === data}"
puts "Outside: #{data.inspect}"

This will output:

In ractor: 100, 120
Outside: moved? true
test.rb:9:in `method_missing': can not send any methods to a moved object (Ractor::MovedError)

Notice that even inspect (and more basic methods like __id__) is inaccessible on a moved object.

Ractors vs threads

Each ractor creates its own thread. New threads can be created from inside ractor, sharing GVL with other threads of this ractor, and having access to its data.

Ractor.new {
  a = 1
  Thread.new { puts "Thread in ractor: a=#{a}" }.join
}
sleep(0.1)
# Here "Thread in ractor: a=1" will be printed

Public Class Methods

count() click to toggle source

Returns total count of Ractors currently running.

Ractor.count                   #=> 1
r = Ractor.new { sleep(0.1) }
Ractor.count                   #=> 2
sleep(0.1)                     # wait till r will finish
Ractor.count                   #=> 1
# File ractor.rb, line 206
def self.count
  __builtin_cexpr! %q{
    ULONG2NUM(GET_VM()->ractor.cnt);
  }
end
current() click to toggle source

Returns the currently executing Ractor.

Ractor.current #=> #<Ractor:#1 running>
# File ractor.rb, line 193
def self.current
  __builtin_cexpr! %q{
    rb_ec_ractor_ptr(ec)->self
  }
end
make_shareable(obj) click to toggle source

Tries to make object shareable by ractors, if it is not. Typically, it means deeply freezing it.

obj = [+'test']            # an array with unfrozen string inside
Ractor.shareable?(obj)     #=> false
Ractor.make_shareable(obj) #=> ["test"]
Ractor.shareable?(obj)     #=> true
obj.frozen?                #=> true
obj[0].frozen?             #=> true

See also the “Shareable and unshareable objects” section in the Ractor class docs.

# File ractor.rb, line 569
def self.make_shareable obj
  __builtin_cexpr! %q{
    rb_ractor_make_shareable(obj);
  }
end
new(*args, name: nil, &block) click to toggle source

Create a new Ractor with args and a block.

A block (Proc) will be isolated (can't access to outer variables). self inside the block will refer to the current Ractor.

Ractor.new { puts "Hi, I am #{self.inspect}" }
sleep(0.1)
# Prints "Hi, I am #<Ractor:#2 test.rb:1 running>"

args passed to the method would be propagated to block args by the same rules as objects passed through send/Ractor.receive: if args are not shareable, they will be copied (via Marshal, which might be ineffective).

arg = [1, 2, 3]
puts "Passing: #{arg} (##{arg.object_id})"
Ractor.new(arg) { |received_arg|
 puts "Received: #{received_arg} (##{received_arg.object_id})"
}
sleep(0.1)
# Prints:
# Passing: [1, 2, 3] (#280)
# Received: [1, 2, 3] (#300)

Ractor's name can be set for debugging purposes:

r = Ractor.new(name: 'dummy') {}
p r
#=> #<Ractor:#3 dummy test.rb:1 terminated>
# File ractor.rb, line 182
def self.new(*args, name: nil, &block)
  b = block # TODO: builtin bug
  raise ArgumentError, "must be called with a block" unless block
  loc = caller_locations(1, 1).first
  loc = "#{loc.path}:#{loc.lineno}"
  __builtin_ractor_create(loc, name, args, b)
end
receive() click to toggle source

Receive an incoming message from the current Ractor's incoming port's queue, which was sent there by send.

r = Ractor.new {
  v1 = Ractor.receive
  puts "Received: #{v1}"
}
r.send('message1')
sleep(0.1)
# Here will be printed: "Received: message1"

The method blocks if the queue is empty.

r = Ractor.new {
  puts "Before first receive"
  v1 = Ractor.receive
  puts "Received: #{v1}"
  v2 = receive # self.receive is an synonym for Ractor.receive
  puts "Received: #{v2}"
}
sleep(0.1)
puts "Still not received"
r.send('message1')
sleep(0.1)
puts "Still received only one"
r.send('message2')
sleep(0.1)

Output:

Before first receive
Still not received
Received: message1
Still received only one
Received: message2

If close_incoming was called on the ractor, the method raises Ractor::ClosedError:

Ractor.new {
  close_incoming
  receive
}
sleep(0.1)
# in `receive': The incoming port is already closed => #<Ractor:#2 test.rb:1 running> (Ractor::ClosedError)
# File ractor.rb, line 317
def self.receive
  __builtin_cexpr! %q{
    ractor_receive(ec, rb_ec_ractor_ptr(ec))
  }
end
Also aliased as: recv
recv()
Alias for: receive
select(*ractors, [yield_value:, move: false]) → [ractor or symbol, obj] click to toggle source

Wait for the first ractor to have something in its outgoing port, reads from this ractor, and returns that ractor and the object received.

r1 = Ractor.new { Ractor.yield 'from 1' }
r2 = Ractor.new { Ractor.yield 'from 2' }

r, obj = Ractor.select(r1, r2)

puts "received #{obj.inspect} from #{r.inspect}"
# Prints: received "from 1" from #<Ractor:#2 test.rb:1 running>

If one of the ractor's awaited is the current ractor, and it would be selected, r will contain :receive symbol instead of the ractor object.

r1 = Ractor.new(Ractor.current) { |main|
  main.send 'to main'
  Ractor.yield 'from 1'
}
r2 = Ractor.new {
  Ractor.yield 'from 2'
}

r, obj = Ractor.select(r1, r2, Ractor.current)
puts "received #{obj.inspect} from #{r.inspect}"
# Prints: received "to main" from :receive

If yield_value is provided, it is yielded (with Ractor.yield) if no ractor is selected. In this case, the pair [:yield, nil] would be returned:

r1 = Ractor.new(Ractor.current) { |main|
  puts "Received from main: #{main.take}"
}

puts "Trying to select"
r, obj = Ractor.select(r1, Ractor.current, yield_value: 123)
sleep(0.1)
puts "Received #{obj.inspect} from #{r.inspect}"

This will print:

Trying to select
Received from main: 123
Received nil from :yield

move boolean flag defines whether yielded value should be copied (default) or moved.

# File ractor.rb, line 259
def self.select(*ractors, yield_value: yield_unspecified = true, move: false)
  raise ArgumentError, 'specify at least one ractor or `yield_value`' if yield_unspecified && ractors.empty?

  __builtin_cstmt! %q{
    const VALUE *rs = RARRAY_CONST_PTR_TRANSIENT(ractors);
    VALUE rv;
    VALUE v = ractor_select(ec, rs, RARRAY_LENINT(ractors),
                            yield_unspecified == Qtrue ? Qundef : yield_value,
                            (bool)RTEST(move) ? true : false, &rv);
    return rb_ary_new_from_args(2, rv, v);
  }
end
shareable?(obj) click to toggle source

Checks if the object is shareable by ractors.

Ractor.shareable?(1)        #=> true -- numbers and other immutable basic values are
Ractor.shareable?('foo')    #=> true or false, depending on whether you have freeze_string_literals: true
Ractor.shareable?(-'foo')   #=> true
Ractor.shareable?(+'foo')   #=> false

See also the “Shareable and unshareable objects” section in the Ractor class docs.

# File ractor.rb, line 552
def self.shareable? obj
  __builtin_cexpr! %q{
    rb_ractor_shareable_p(obj) ? Qtrue : Qfalse;
  }
end
yield(obj, move: false) click to toggle source

Send a message to the current ractor's outgoing port to be consumed by take.

r = Ractor.new { Ractor.yield 'Hello from ractor' }
puts r.take
# Prints: "Hello from ractor"

The method is blocking, and will return only when somebody will consume the sent message.

r = Ractor.new {
  Ractor.yield 'Hello from ractor'
  puts "Ractor: after yield"
}
sleep(0.1)
puts "Still not taken"
puts r.take

This will print:

Still not taken
Hello from ractor
Ractor: after yield

If the outgoing port was closed with close_outgoing, the method will raise:

r = Ractor.new {
  close_outgoing
  Ractor.yield 'Hello from ractor'
}
sleep(0.1)
# `yield': The outgoing-port is already closed (Ractor::ClosedError)

The meaning of move argument is the same as for send.

# File ractor.rb, line 446
def self.yield(obj, move: false)
  __builtin_cexpr! %q{
    ractor_yield(ec, rb_ec_ractor_ptr(ec), obj, move)
  }
end

Public Instance Methods

<<(obj, move: false)
Alias for: send
close_incoming() click to toggle source

Closes the incoming port and returns its previous state. All further attempts to Ractor.receive in the ractor, and send to the ractor will fail with Ractor::ClosedError.

r = Ractor.new { sleep(500) }
r.close_incoming  #=> false
r.close_incoming  #=> true
r.send('test')
# Ractor::ClosedError (The incoming-port is already closed)
# File ractor.rb, line 523
def close_incoming
  __builtin_cexpr! %q{
    ractor_close_incoming(ec, RACTOR_PTR(self));
  }
end
close_outgoing() click to toggle source

Closes the outgoing port and returns its previous state. All further attempts to Ractor.yield in the ractor, and take from the ractor will fail with Ractor::ClosedError.

r = Ractor.new { sleep(500) }
r.close_outgoing  #=> false
r.close_outgoing  #=> true
r.take
# Ractor::ClosedError (The outgoing-port is already closed)
# File ractor.rb, line 538
def close_outgoing
  __builtin_cexpr! %q{
    ractor_close_outgoing(ec, RACTOR_PTR(self));
  }
end
name() click to toggle source

The name set in Ractor.new, or nil.

# File ractor.rb, line 506
def name
  __builtin_cexpr! %q{ RACTOR_PTR(self)->name }
end
recv()
Alias for: receive
send(obj, move: false) click to toggle source

Send a message to a Ractor's incoming queue to be consumed by Ractor.receive.

r = Ractor.new do
  value = Ractor.receive
  puts "Received #{value}"
end
r.send 'message'
# Prints: "Received: message"

The method is non-blocking (will return immediately even if the ractor is not ready to receive anything):

r = Ractor.new { sleep(5) }
r.send('test')
puts "Sent successfully"
# Prints: "Sent successfully" immediately

If close_incoming was called on the ractor, the method raises Ractor::ClosedError.

r =  Ractor.new {
  sleep(500)
  receive
}
r.close_incoming
r.send('test')
# Ractor::ClosedError (The incoming-port is already closed)
# The error would be raised immediately, not when ractor will try to receive

Attempt to send to ractor which already finished its execution results in the same error:

r = Ractor.new {}
sleep(0.1)
p r
# "#<Ractor:#6 (irb):23 terminated>"
r.send('test')
# Ractor::ClosedError (The incoming-port is already closed)

If the obj is unshareable, by default it would be copied into ractor with Marshal. If the move: true is passed, object is moved into ractor and becomes inaccessible to sender.

r = Ractor.new { puts "Received: #{receive}" }
msg = +'message' # create non-frozen string
r.send(msg, move: true)
sleep(0.1)
p msg

This prints:

Receieved: message
in `p': undefined method `inspect' for #<Ractor::MovedObject:0x000055c99b9b69b8>

All references to the object and its parts will become invalid in sender.

r = Ractor.new { puts "Received: #{receive}" }
s = +'message'
ary = [s]
copy = ary.dup
r.send(ary, move: true)
sleep(0.1)

s.inspect
# Ractor::MovedError (can not send any methods to a moved object)
ary.class
# Ractor::MovedError (can not send any methods to a moved object)
copy.class
# => Array, it is different object
copy[0].inspect
# Ractor::MovedError (can not send any methods to a moved object)
# ...but its item was still a reference to `s`, which was moved
# File ractor.rb, line 406
def send(obj, move: false)
  __builtin_cexpr! %q{
    ractor_send(ec, RACTOR_PTR(self), obj, move)
  }
end
Also aliased as: <<
take() click to toggle source

Take a message from ractor's outgoing port, which was put there by Ractor.yield or at ractor's finalization.

r = Ractor.new {
  Ractor.yield 'explicit yield'
  'last value'
}
puts r.take #=> 'explicit yield'
puts r.take #=> 'last value'
puts r.take # Ractor::ClosedError (The outgoing-port is already closed)

The fact that the last value is also put to outgoing port means that take can be used as some analog of Thread#join (“just wait till ractor finishes”), but don't forget it will raise if somebody had already consumed everything ractor have produced.

If the outgoing port was closed with close_outgoing, the method will raise Ractor::ClosedError.

r = Ractor.new {
  sleep(500)
  Ractor.yield 'Hello from ractor'
}
r.close_outgoing
r.take
# Ractor::ClosedError (The outgoing-port is already closed)
# The error would be raised immediately, not when ractor will try to receive

If the exception have happened in the Ractor, it is propagated on take as a Ractor::RemoteError.

r = Ractor.new { raise "Something weird happened" }

begin
  r.take
rescue => e
  p e         # => #<Ractor::RemoteError: thrown by remote Ractor.>
  p e.ractor  # => #<Ractor:#2 test.rb:1 terminated>
  p e.cause   # => #<RuntimeError: Something weird happened>
end
# File ractor.rb, line 489
def take
  __builtin_cexpr! %q{
    ractor_take(ec, RACTOR_PTR(self))
  }
end