(This English version is an AI-generated translation of the original Japanese post (https://product.st.inc/entry/2025/06/24/110606), with some modification by hands, using Gemini)
I am Koichi from STORES, Inc. In this article, I will introduce Ractor::Port, a new mechanism we recently introduced to revamp parts of the Ractor API (Ruby's mechanism for easy parallel processing) that had been bothering me for a long time. I never quite felt comfortable with the previous API?it felt like a bone stuck in my throat?and after thinking about it for five years, I finally made the decision.
Proposed Ticket: Feature #21262: Proposal: Ractor::Port - Ruby Issue Tracking System
Overview of Changes
Here is a quick summary of what has changed:
Deprecated / Removed
Ractor#take, Ractor.yield, Ractor.receive_if, Ractor#incoming_port, and Ractor#outgoing_port are gone.
Use These Instead
-
Waiting for a Ractor to finish: →
Ractor#join(Same asThread#join)
r = Ractor.new { fib(30) }
r.join
-
Waiting for a Ractor to finish and getting its return value: →
Ractor#value(Same asThread#value)
r = Ractor.new { fib(30) }
p r.value #=> 1346269
-
Creating a dedicated communication path from other Ractors: →
Ractor::Port(Think of it like a TCP port number)
port1 = Ractor::Port.new
port2 = Ractor::Port.new
Ractor.new(port1) { |port| port << :hello }
Ractor.new(port2) { |port| port << :world }
p port2.receive #=> Guaranteed to be :world
p port1.receive #=> Guaranteed to be :hello
- Every Ractor has a "default port" from the start, which can be accessed via
Ractor#default_port. Methods likeRactor#sendandRactor.receiveoperate on this default port. This meansRactor#sendand.receivecan still be used as before.
r = Ractor.new { p Ractor.receive } # Displays 42
r << 42
Additionally, there are methods like Ractor#monitor, unmonitor, and Ractor::Port#close, but they are not critical for typical use.
While we are usually very cautious about removing Ruby methods, Ractor has been labeled as "experimental" since Ruby 3.0. This allowed for a bold, incompatible change. Since it's still experimental, we assumed there aren't many people using it in production yet.
$ ruby -e "Ractor.new{}"
-e:1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
Background: Issues with the Existing API
Previously, communication between Ractors was organized into two models: Push (Ractor#send / .receive) and Pull (Ractor.yield / #take).
When trying to communicate with a "Server" Ractor that performs specific tasks using these primitives, things got complicated?especially as the number of Ractors increased.
| Problem | Specific Example | Resulting Issue |
|---|---|---|
| Difficulty identifying messages |
Ractor.receive is a single mailbox. Results from multiple servers get mixed up. |
You might receive a message from Server B when you expected one from Server A. |
| Mailbox contention | Library A and Library B both call Ractor.receive. |
One library might "accidently swallow" a message intended for the other. |
| Scattered APIs | Mixing receive_if, yield, #take, and select. |
Increased learning/implementation cost and unstable CI. |
Let's look at some code examples to understand these issues.
EX1: A Server with No Response
A simple "Fibonacci Server" that calculates numbers but doesn't return them.
def fib(n) = n > 1 ? fib(n-2) + fib(n-1) : 1
fib_srv = Ractor.new do
while true
param = Ractor.receive
result = fib(param)
# No way to send the result back
end
end
fib_srv << 10
This is fine for "fire-and-forget" tasks like sending an email, but usually, we want the result back.
EX2: Sending the Caller Ractor Along
To get a response, we can send the sender's info.
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()
p Ractor.receive #=> fib(10)
This works for a simple 1-to-1 interaction.
EX3: Confusion with Multiple Servers
What happens if we add a "Factorial Server"?
def fact(n) = n > 1 ? fact(n-1) * n : 1
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 # Is this the result of fib(10) or fact(10)?
When Ractor.receive is called, you don't know which server sent the message. Furthermore, if a server uses internal worker Ractors, the order of responses might not even match the order of requests.
EX4: Identifying with Tags
We can add "tags" (like Symbols) to identify messages. This is similar to how Erlang handles it.
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}
While this works, a problem remains: if an external library calls Ractor.receive internally, it might "steal" your message before you can call receive_if. You can only use this safely if you control all the code.
EX5: Creating a Channel
We could create a "Channel" using a dedicated Ractor, similar to Go language.
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
This works, but:
- Creating a Ractor for every channel is expensive.
- Performance suffers due to extra object copying.
- Conceptually, it feels less like the "Actor Model" and more like CSP.
I hesitated for five years because this didn't feel "Actor-like." Then, a proposal for "Ractor Channels" (Feature #21121) pushed me to rethink everything, leading to Ractor::Port.
The Proposal: Ractor::Port - Lightweight, Unique, One-Way Endpoints
Ractor::Port is a mechanism where "anyone can send to it," but "only the creator can receive from it."
| Feature | Channel | Port |
|---|---|---|
| Sending | Anyone | Anyone |
| Receiving | Anyone | Only the creator |
This small difference makes it much more aligned with the Actor model. It is lightweight to create and has overhead equivalent to Ractor#send.
A Port consists of two elements:
- Destination Ractor: Where to deliver.
- Unique Tag: An identifier unique to that Port.
To describe the concept, we can define Ractor::Port with existing (on Ruby 3.4) API.
class Ractor::Port
def initialize
@r = Ractor.current
@tag = genid()
end
def send(obj) # anywan can send
@r << [@tag, obj]
end
def receive # only the creator can receive
raise unless @r == Ractor.current
Ractor.receive_if { |(tag,res)| return res if tag == @tag }
# receive if the tag is identical
end
end
As you can see, it is enough simple and lightweight. Especially the implementation of synchronizations reduced dramatically.
For example, we can use Ractor::Port like:
port = Ractor::Port.new
Ractor.new(port) do |port|
port.send 42 # `port << 42` can also be used as an alias
end
port.receive #=> 42
In this model, you pass a Port object to indicate, "I want you to send the data here," and the sender uses Ractor::Port#send to transmit it. Semantically, this is exactly the same as calling ractor.send(tag, 42) (and the internal implementation reflects this logic).
On the receiving side, you use Ractor::Port#receive to pick up only the messages that were specifically sent to that port.
In this simple example, since we've only introduced a single Port, it might feel more cumbersome than the traditional Ractor#send. However, the true significance and utility of this approach become clear when dealing with more complex scenarios involving multiple communication channels.
Refactoring with Ports
Now, let's look at how we can rewrite the fib and fact servers specifically using Ractor::Port.
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)
Essentially, this is just replacing the previous Channel-based example with Ports. While the logic remains similar to an implementation using tag + receive_if, the key difference here is that the use of tags is now enforced. Additionally, it provides "peace of mind". You no longer have to worry about your messages being accidentally intercepted or "stolen" by a Ractor.receive call in some other part of the program.
Furthermore, Ractor.select has been updated to wait for multiple Ports simultaneously, allowing you to handle results as follows:
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
# ... handle other cases ...
else
raise "This should not happen (BUG)."
end
ports.delete(port)
end
In this example, if a message arrives at any of the four Ports stored in the ports array, Ractor.select returns that specific port along with the received result.
As a side note, it's worth comparing this to other systems:
-
IO.select: Returns the IO object that is "ready" (e.g., for reading), but you still have to perform the read yourself. -
Ractor.select: Actually completes the reception process and returns the value. -
Go's
selectstatement: Requires you to write the processing logic for each branch within the statement itself.
Each approach handles the "select" concept slightly differently.
Comparison: Port vs. Channels/Queues
First, let's summarize the key advantages of Port:
- Guaranteed Delivery: It ensures that messages are delivered only to the intended Ractor, preventing tag collisions.
-
Decoupled Communication: You can establish communication paths without relying on
Ractor.receive, which is prone to unintended message consumption ("message stealing"). -
Simpler Primitives: It replaces complex and hard-to-compose primitives like
.receive_if,.yield, and#takewith a cleaner abstraction. - Natural Actor Semantics: It aligns perfectly with the Actor model semantics that Ruby aims to achieve with Ractor.
Let's discuss about "why Port is Better than Channel (in Practice)".
- Safer than "Channel"
- When a
Port#sendsucceeds, it guarantees that the destination Ractor is still alive and running. - In contrast, with a Channel, there is no guarantee that a Ractor capable of receiving from that channel is currently active.
- While a Port doesn't guarantee the receiver will process the message (it might ignore it), it eliminates the failure case where a message vanishes into the void because the destination Ractor has already terminated. This removes a major source of non-deterministic bugs and makes the communication model much more predictable.
- Note: This predictability is one of the primary reasons Ruby chose the Actor model over CSP.
- When a
- Superior Performance
- Lighter Creation: Creating a Channel requires allocating container structures to store messages. A Port only requires a lightweight "Ractor + unique ID" data structure.
- Fewer Copies: Sending a message via a Channel typically involves two copies: Sender → Channel and Channel → Receiver. With a Port, the message is copied only once: Sender → Receiver. As we move toward Ractor-local GC (separating object spaces), this performance gap will become even more significant.
- Simplified Implementation
- Implementing
Port#receiveis straightforward because it only requires locking the receiving Ractor. - Conversely,
yield/takerequires rendezvous synchronization, necessitating locks on both the sender and receiver simultaneously—which is notoriously tricky. - The current implementation of
.selectis extremely difficult and remains unstable in CI (despite two major rewrites). Simplifying the specification to Ports will lead to fewer bugs and faster development. -
Personal Note: For me, the "Simplicity of Implementation" is the biggest win. Implementing
yield/takeand the supportingselectlogic was an absolute nightmare. I never managed to completely eliminate the bugs while maintaining high performance (avoiding a VM-wide global lock). Moving to Ports allows us to ditch that complexity.
- Implementing
They are downsides of Ports
- Unfamiliarity: The concept of a "Port" isn't widely known, especially for developers coming from Go who are used to Channels.
- Abstraction Overhead: For classic producer-consumer patterns, you may need an additional layer of abstraction.
For example, implementing Multiple Workers (Ractors) requires a bit more coordination:
controller = Ractor::Port.new
workers = (1..WN).times.map do
Ractor.new controller do |controller|
while true
# Notify controller that this worker is ready
controller << Ractor.current
# Wait for a task from the producer
port, param = Ractor.receive
result = task(param)
# Send the result back to the requested port
port << [param, result]
end
end
end
task_q = Queue.new
result_port = Ractor::Port.new
# In this new API, Ports are Thread-safe!
Thread.new do
while param = task_q.pop
# Get an available worker
worker = controller.receive
worker.send [result_port, param]
end
end
task_q << 42
task_q << 43
task_q << 44
3.times do
p result_port.receive
end
In this scenario, because the producer logic only supports a single Ractor, you would need a "mediator Ractor" if multiple Ractors were trying to feed tasks to these workers.
One interesting side effect of this refactoring is that Ractor Ports now support Threads. The previous Ractor API was essentially incompatible with Threads (it was "undefined behavior" territory), but the new, cleaner internal logic made it easy to add support.
One remaining concern is that it's difficult to implement something like Go's `context using only Ports. I'm still evaluating how much of a limitation this is in practice (and specifically, why Channel#close` wasn't sufficient for those use cases).
Waiting for Ractor Termination
If we remove Ractor#take, we lose the way to wait for a Ractor to finish and retrieve its block's return value. To address this, I decided to adopt the naming convention from Ruby's Thread class and introduced Ractor#join (to wait for termination) and Ractor#value (to wait and return the block's value).
r = Ractor.new { 42 }
r.join # wait for the termination of r
r.value #=> 42
A unique specification here is that Ractor#value can be called by at most one Ractor. If one Ractor is already waiting for a value via #value, and another Ractor attempts to call #value on the same target, it will raise an error.
r = Ractor.new { 42 }
p r.value #=> 42 (Main Ractor executes value)
Ractor.new(r) { |r| r.value } # This will raise an error
The reasoning behind this is safety: since the final result is not accessible by anyone else, it is safe for exactly one Ractor to access it. This allows us to pass the result without copying, meaning an "unshareable object" can be received as-is.
Actually, Ractor#take had a similar special behavior for the final return value. However, I always felt it was "dirty" (or inconsistent) for the same method to behave differently depending on the timing. I'm glad we could clean this up with Ractor#value.
Default Port and Compatibility
Every Ractor is equipped with one default port from the moment it is created. It is used, for example, to pass arguments to the Ractor, and it can be accessed via Ractor#default_port.
Standard operations like Ractor#send and Ractor.receive are now essentially operations on this default port.
r = Ractor.new do
Ractor.receive #=> 42
# This is equivalent to Ractor.current.default_port.receive
end
r << 42
# Semantically the same as r.default_port.send
# (We might restrict access to default_port from outside the Ractor in the future)
Monitor API
To implement join and value, I introduced a low-level Monitor API (inspired by Erlang).
r = Ractor.new { 42 }
r.monitor(monitor_port = Ractor::Port.new)
monitor_port.receive #=> :exited
When a Ractor terminates, it sends the symbol :exited to the registered monitor_port. If it crashes due to an exception, it sends :aborted. I'm not sure yet if we'll add more event types (we'll see if use cases arise).
This API makes implementing #join very simple (in fact, it is already implemented this way: see code). You can also use this to monitor the liveness of multiple Ractors:
monitor_port = Ractor::Port.new
10.times.map do
r = Ractor.new { unlimited_task }
r.monitor monitor_port
end
while true
msg = monitor_port.receive
do_something # e.g., restart a worker to replace the dead one
end
(Wait, looking at this now... if I only get :exited, I won't know which Ractor died. I'll probably need to include the source Ractor information. I'm debating between passing more info to the monitor port or creating a receive method that identifies the sender. We'll see which is better.)
I originally designed Ractor#take five years ago specifically to handle this kind of logic, but it turns out things don't always go as planned!
Closing Ports
Ports can be closed using the #close method.
Ractor.new(port = Ractor::Port.new) do |port|
loop {
port << 42 # Eventually raises ClosedError when trying to send to a closed port
}
end
port.close
Currently, only the Ractor that created the port can close it. Allowing anyone to close a port is a bit more complex (as it requires "waking up" all waiting threads), so I'll keep it this way until there is a clear, essential need for it.
Other Alternatives Considered
As mentioned, I considered Channel-like mechanisms.
Since a Port essentially enforces "tagged sends," another idea was to use Ractor#send(tag, ...). However, I was concerned about tag collisions. I decided that if we were going to use tags anyway, it would be more "Object-Oriented" to make the tag itself an object, hence, the Port object.
I didn't spend too much time on the name "Port"; it just seemed to fit, and I couldn't think of many better alternatives. I looked into other languages for similar mechanisms but didn't find many equivalents, save for the concept of TCP port numbers.
Future Plans
Ractor::Port has already been merged into the master branch (and will be shipped with Ruby 4.0), so you can try it out today. I would love to hear your honest feedback.
There are still a few "leftover" items I'm considering:
Is Ractor::Port.new too long?
I’ve been wondering if something like Ractor.port would be better (similar to the feel of IO.pipe). Since creating a port doesn't feel like a heavy operation, maybe a shorter factory method makes sense. What do you think?
Behavior on close
As a prerequisite, remember that a Port is an unshareable object. This means a copy is created whenever it is passed to another Ractor.
While implementing this, I realized something: even if other Ractors hold a copy of a Port to send messages to, if all local references to that Port within the receiving Ractor are garbage collected (GC'd), there is no longer anyone left to read from it. In that case, the Port should probably be closed automatically.
port = Ractor::Port.new
Ractor.new(port) do |port|
10.times { port << it } # Sending 10 messages
end
p port.receive #=> 0 (Returns the first message)
port = nil # The handle to receive the remaining 9 messages is gone!
...
# The program continues, but those 9 messages stay in memory indefinitely.
In cases like this, I believe the Port should close the moment it is GC'd in the receiving Ractor. This would allow us to:
- Reclaim memory: Free up the messages that were already sent (since no one can ever see them).
- Prevent waste: Stop other Ractors from performing unnecessary work by sending messages to a dead-end.
I'm considering using reference counting within the Port implementation to achieve this.
Also, as I touched on earlier, there is the question of whether other Ractors (senders) should be allowed to close a Port. It might be a good way to signal "I'm done sending," but it might also complicate the ownership model. The specification here is still likely to evolve.
Summary
With the introduction of Ractor::Port, the Ractor API has undergone a significant overhaul. I believe this refactoring clarifies how Ractors communicate and makes the entire system much more approachable.
Please take it for a spin and see how it feels!
And hopefully, this time, we can finally see that "experimental" warning removed from Ractor soon.
Top comments (0)