Feature #21262
openProposal: `Ractor::Port`
Description
Proposal: Ractor::Port
In concurrent Ruby applications using Ractors, safely and efficiently communicating results between Ractors is a common challenge. We propose Ractor::Port
as a lightweight, safe, and ergonomic abstraction to simplify communication patterns, especially in request-response models.
# usage example
port = Ractor::Port.new
Ractor.new port do |port|
port << 42
port << 43
end
# Success: wait for sending
port.receive #=> 42
Ractor.new port do |port|
port.receive # Error: only the creator Ractor can receive from this port.
end
port.receive #=> 43
This is a similar concept to "Channel", but it is tightly coupled with the creator Ractor and no other Ractors can receive anything from that port.
In that sense, it is conceptually closer to a socket file descriptor (e.g., a destination and port number pair in TCP/IP).
We can implement Port
with Ractor.receive_if
like this:
class Ractor::Port
def initialize
@r = Ractor.current
@tag = genid()
end
def send obj
@r << [@tag, obj]
end
def receive
raise unless @r == Ractor.current
Ractor.receive_if do |(tag, result)
if tag == @tag
return result
end
end
end
end
With Ractor::Port
, we can deprecate Ractor.receive_if
, Ractor.yield
, and Ractor#take
. Ports act as clear, self-contained endpoints for message passing, which makes these older primitives redundant. Furthermore, Port-based communication is significantly easier to implement and reason about—especially when considering synchronization challenges around Ractor.select
and rendezvous semantics.
Background: Limitations of current communication patterns¶
Let's discuss how to make server-like service ractors.
No response server¶
We can make server-like Ractors like this:
# EX1
def fib(n) = n > 1 : fib(n-2) + fib(n-1) : 1
# A ractor calculate fib(n)
fib_srv = Ractor.new do
while true
param = Ractor.receive
result = fib(param)
end
end
fib_srv << 10
In this case, the main Ractor requests fib_srv
to calculate fib(10)
.
However, currently, there is no way to retrieve the result.
Return value to the sender ractor¶
There are several possible approaches.
First, we can send the sender Ractor along with the parameter, and ask the server to send the result back to the sender.
# EX2
fib_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fib(param)
sender << result
end
end
fib_srv << [10, Ractor.current]
do_some_work()
Ractor.receive #=> fib(10)
This approach works well in simple cases.
However, with EX2, handling multiple concurrent responses becomes difficult. The results are pushed into the same mailbox, and since Ractor.receive
retrieves messages without discriminating the source, it's unclear which server returned which result.
# EX3
def fact(n) = n > 1 : fact(n-1) * n
fib_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fib(param)
sender << result
end
end
fact_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fact(param)
sender << result
end
end
fib_srv << [10, Ractor.current]
fib_srv << [20, Ractor.current]
fact_srv << [10, Ractor.current]
fact_srv << [20, Ractor.current]
do_some_work()
Ractor.receive
#=> fib(10) or fact(10), which?
# If the servers uses Ractors more (calculate them in parallel),
# fib(20) and fact(20) can be returned.
Because Ractor.receive
retrieves all messages indiscriminately, developers must add their own tagging logic to distinguish results. While tagging (as shown in EX4) helps, it introduces additional complexity and brittleness.
Responses with request ID¶
The following code returns a result with request id (a pair of the name of server and a parameter).
# EX4
fib_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fib(param)
sender << [[:fib, param], result]
end
end
fact_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fact(param)
sender << [[:fact, param], result]
end
end
fib_srv << [10, Ractor.current]
fib_srv << [20, Ractor.current]
fact_srv << [10, Ractor.current]
fact_srv << [20, Ractor.current]
do_some_work()
Ractor.receive_if do |id, result|
case id
in [:fib, n]
p "fib(#{n}) = #{result}"
in [:fact, n]
p "fact(#{n}) = #{result}"
end
end
# or if you want to use specific results, like:
p fib20: Ractor.receive_if{|id, result| id => [:fib, 20]; result}
p fact10: Ractor.receive_if{|id, result| id => [:fact, 10]; result}
p fact20: Ractor.receive_if{|id, result| id => [:fact, 20]; result}
p fib10: Ractor.receive_if{|id, result| id => [:fib, 10]; result}
This approach closely resembles pattern matching in Erlang or Elixir, where responses are tagged and matched structurally.
However, this solution still has an issue: if do_some_work()
uses Ractor.receive
, it may accidentally consume any message. In other words, Ractor.receive
can only be safely used when you're certain that no other code is using it.
(Another trivial issue is, different servers can return same identity, like [:fact, num]
returned by NewsPaper server. It is confusing).
Using channels¶
To solve this issue, we can make a channel with different Ractors.
Channels can be implemented using Ractors, as illustrated below.
# EX5
# Servers are completely same to EX3
fib_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fib(param)
sender << result
end
end
fact_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fact(param)
sender << result
end
end
# Create a new channel using a Ractor
def new_channel
Ractor.new do
while true
Ractor.yield Ractor.receive
end
end
end
fib_srv << [10, fib10_ch = new_channel]
fib_srv << [20, fib20_ch = new_channel]
fact_srv << [10, fact10_ch = new_channel]
fact_srv << [20, fact20_ch = new_channel]
do_some_work()
p fib20: fib20_ch.take # wait for fib(20)
p fact10: fact10_ch.take # wait for fact(10)
p fib10: fib10_ch.take # wait for fib(10)
p fact20: fact10_ch.take # wait for fact(20)
# or
chs = [fib10_ch, fib20_ch, fact10_ch, fact20_ch]
while !chs.empty?
ch, result = Ractor.select(*chs) # wait for multiple channels
p ch, result
chs.delete ch
end
Channel approach solves the issue of EX4. The above implementation introduce some overhead to create channel ractors, but we can introduce special implementation to reduce this Ractor creation overhead.
However, in the Actor model, the communication pattern is to send a message to a specific actor. In contrast, channels are used to send messages through a shared conduit, without caring which receiver (if any) handles the message. Also, channels can have some overhead, as discussed below.
Summary of background¶
Currently, when implementing request-response patterns with Ractors, developers face challenges in tracking results, managing identifiers, and avoiding message conflicts. Existing primitives like receive_if
, take
, or channels implemented with Ractors are either error-prone or inefficient.
Proposal¶
Introduce Ractor::Port
as an alternative to channels. It is a natural extension of the Actor model. In fact, it is thin wrapper of current send/receive model as illustrated at the top of this proposal.
With the Ractor::Port
, we can rewrite above examples with it.
# EX6
# Completely same as EX3's servers
fib_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fib(param)
sender << result
end
end
fact_srv = Ractor.new do
while true
param, sender = Ractor.receive
result = fact(param)
sender << result
end
end
fib_srv << [10, fib10_port = Ractor::Port.new]
fib_srv << [20, fib20_port = Ractor::Port.new]
fact_srv << [10, fact10_port = Ractor::Port.new]
fact_srv << [20, fact20_port = Ractor::Port.new]
do_some_work()
p fib10_port.receive #=> fib(10)
p fib20_port.receive #=> fib(20)
p fact10_port.receive #=> fact(10)
p fact20_port.receive #=> fact(20)
# or
ports = [fib10_port, fib20_port, fact10_port, fact20_port]
while !ports.empty?
port, result = Ractor.select(*ports)
case port
when fib10_port
p fib10: result
...
else
raise "This should not happen (BUG)."
end
ports.delete(port)
end
Ractor::Port
resolves key pain points in message passing between Ractors:
- It guarantees that incoming messages are only delivered to the intended Ractor, preventing tag collisions.
- It enables message routing without relying on global receive blocks (
Ractor.receive
), which are prone to unintended consumption. - It replaces more complex primitives like
.receive_if
,.yield
, and#take
with a simpler, composable abstraction. - It maps cleanly to the Actor model semantics Ruby intends to support with Ractors.
While the pattern looks similar to using channels, the semantics and guarantees are different in meaningful ways.
The advantages of using Ports include:
- Safer than channels in practice
- When using a Port, if
#send
succeeds, it means the destination Ractor is still alive (i.e., it's running). - In contrast, with a channel, there's no guarantee that any Ractor is still available to receive from it.
- Of course, even with a port, there's no guarantee that the destination Ractor will actually process the message — it might ignore it.
- But at least you don't need to worry about the Ractor having already terminated unexpectedly.
- In other words, using a port eliminates one major failure case, making the communication model more predictable.
- This is one of the reasons why Ruby went with the "Actor" model (hence the name Ractor), instead of the "CSP" model.
- When using a Port, if
- Faster than channels in both creation and message transmission
- When creating a channel, we need to prepare a container data structure. When creating a port, it is lightweight data (a pair of Ractor and newly created ID).
- On the channel transmission, we need copying a data to channel and a copying to the receiving ractor. On the port, it only needs to copy from the src ractor to the dst ractor. This issue becomes more significant due to Ractor-local garbage collection and isolation of object spaces.
- Easy to implement. We only need to implement
Port#receive
to synchronize with other ractors.-
#send/.receive
is easy to implement because we only need to lock the receiving ractor. -
.yield/#take
is not easy to implement because we need to lock taking and receiving ractors because it is rendezvous style synchronization. -
.select
is DIFFICULT to support current spec. Now CI isn't stable yet. - A simpler spec reduces bugs, and maybe leads to faster implementation.
-
Disadvantages:
- It is not a well-known concept, especially for Go language users.
- We need additional abstraction like producer(s)-consumer(s) concurrent applications.
For (2), I want to introduce an example code. We can write a 1-producer, multiple-consumer pattern with a channel.
# channel version of 1 producer & consumers
ch = new_channel
RN = 10 # make 10 consumers
consumers = RN.times.map do
Ractor.new ch do
while param = ch.receive
task(param)
end
end
end
tasks.each do |task|
ch << task
end
With Port, we need to introduce a load balancing mechanism:
# Port version of 1 producer & consumers
control_port = Ractor::Port.new
consumers = RN.times.map do
Ractor.new control_port control_port do |control_port|
while true
control_port << [:ready, Ractor.current] # register - ready
param = Ractor.receive # it assumes task doesn't use Ractor.receive
task(param)
end
end
end
tasks.each do |task|
control_port.receive => [:ready, consumer]
# send a task to a ready consumer
consumer << task
end
Of course we can make a library for that (like OTP on Erlang).
Default port of Ractors¶
Each Ractor has a default port and Ractor#send
is equal to Ractor.current.default_port#send
. Of course, Ractor.receive
is equal to Ractor.current.default_port.receive
.
For the simple case, we can keep to use Ractor#send
and Ractor.receive
Deprecation of Ractor#take and Ractor.yield¶
With the Port concept, we can focus solely on send and receive—that is, direct manipulation of a Ractor’s mailbox. Ports provide a clean and functional alternative to Ractor#take
and Ractor.yield
, making them unnecessary in most use cases.
Moreover, Ports are significantly easier to implement, as they require only locking the receiving Ractor, while yield/take involve complex rendezvous-style synchronization. By removing these primitives, we can simplify the specification and reduce implementation complexity—especially around features like Ractor.select
, which are notoriously hard to get right.
Ractor.select
with ports
We should wait for multiple port simultaneously so Ractor.select()
should accept ports. Now Ractor.select()
can also receiving and yielding the value, but if we remove the #take
functionality, Ractor.select
only need to support ports.
Wait for termination¶
Ractor#take
is designed from an idea of getting termination result (like Thread#value
). For this purpose, we can introduce Ractor#join
or Ractor#value
like Threads or we can keep the name Ractor#take
for this purpose.
We can make Ractor#join
as a following pseudo-code:
class Ractor
def join # wait for the termination
monitor port = Port.new
port.receive
ensure
monitor nil # unregister / it should be discussed
end
# when this ractor terminates, send a message to the registered port
def monitor port
@monitor_port = port
end
private def atexit
@monitor_port << termination_message
end
end
# there are some questions.
# * can we register multiple ports?
# * should we support `#join` and `#value` like threads?
# or should we support only `#join` to return the value?
# * or keep this name as `#take`?
Ractor.new do
42
end.join #=> 42 (or true?)
It is very similar to monitor
in Erlang or Elixir.
We can also make a supervisor in Erlang like that:
sv_port = Ractor::Port.new
rs = N.times.map do
Ractor.new do
do_something()
end.monitor sv_port
end
while termination_notice = sv_port.receive
p termination_notice
end
# With Ractor#take, we can write similar code if there is no Ractor.yield
rs = N.times.map do
Ractor.new do
do_something()
end
end
while r, msg = Ractor.select(*rs)
p [r, msg]
end
Discussion¶
send
with tag (symbols)
If we force users to send a tagged message every time, we can achieve the same effect as Port concept, because a Port can be thought of as a combination of a tag and a destination Ractor.
r = Ractor.new do
loop do
tag, msg = Ractor.receive # return 2 values
case tag
when :TAG
p [tag, msg]
else
# ignore
end
end
end
r.send :TAG, 42
r.send :TAGE, 84 # this typo and the message is silently ignored
However it has two issues:
- If we make a typo in tag name, the message will be silently ignored.
- The tag name may conflict with unrelated codes (libraries)
Ractor.yield
and Ractor#take
with channel ractor
If we want to leave the .yield
and #take
, we can emulate them with channel ractor.
class Ractor
def initialize
@yield_ractor = Ractor.new do
takers = []
while tag, msg = Ractor.receive
case tag
when :register
@takers << msg
when :unregister
@takers.delete msg
when :yield
@takers.pop << msg
end
end
end
end
def self.yield obj
@yield_ractor << [:yield, obj]
end
def take
@yield_ractor << [:register, port = Ractor::Port.new]
port.receive
ensure
@yield_ractor << [:unregister, port]
end
end
Opening and closing the port¶
This proposal doesn't contain opening and closing the port, but we can discuss about it. To introduce this attribute, we need to manage which ports (tags) are opening.
Implementation¶
Now the native implementation is not finished, but we can implement it using the Ractor.receive_if
mechanism, so we estimate that only a few weeks of work are needed to complete it.
Summary¶
This proposal introduces the following features and deprecations.
-
Ractor::Port
-
Port#send(msg)
– sends a message to the creator of the port. -
Port#receive
– receives a message from the port. - A port is a lightweight data structure (a pair of a Ractor and a tag).
-
-
Ractor#join
orRactor#value
– to wait for Ractor termination (likeThread#join
) -
Ractor#monitor
– to observe when another Ractor terminates - Deprecations:
Ractor#take
Ractor.yield
Ractor.receive_if
Thank you for reading this long proposal. If you have any use cases that cannot be addressed with Ractor::Port
, I'd love to hear them.
P.S. Thanks to mame for reviewing this proposal and suggesting that I use ChatGPT to improve the writing.