Project

General

Profile

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] 

 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_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_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 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 register_termination_port 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.

Back