Feature #21262
Updated by ko1 (Koichi Sasada) 8 days ago
# 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. ```ruby # 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"](https://bugs.ruby-lang.org/issues/21121), 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: ```ruby 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: ```ruby # 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. ```ruby # 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. ```ruby # 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). ```ruby # 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 do_some_work() 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. ```ruby # 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() do_something() 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. ```ruby # 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() do_something 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. * 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 doesn't stable yet. * A simpler spec reduces bugs, and maybe leads to faster implementation. Disadvantages: 1. It is not a well-known concept, especially for Go language users. 2. 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. ```ruby # 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: ```ruby # 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: ```ruby 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: ```ruby 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. ```ruby 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. ```ruby 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` or `Ractor#value` – to wait for Ractor termination (like `Thread#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.