In Ruby 4.0, how do Ractors exchange messages with Ractor::Port and Ractor.select now that Ractor.yield and Ractor#take are gone?
answer
- only the creator can receive
- send never blocks
- every Ractor has a default port
- select returns [port, message]
- move: true, then Ractor::MovedError
basics
~20 sA Ractor::Port is a mailbox: any Ractor can send (or <<) to it without blocking, but only the Ractor that created it can receive. Ractor.select(*ports) waits on several ports or Ractors and returns [source, message].
solid answer
~40 sRuby 4.0 replaced `Ractor.yield`/`Ractor#take` with `Ractor::Port`. You create a port in the Ractor that will read from it, pass the port to other Ractors, and they call `port.send(msg)` or `port << msg`, which never blocks. Only the creating Ractor may call `port.receive`, which blocks until a message arrives. Each Ractor also has a `default_port`, used by `r.send(msg)` and `Ractor.receive`. `Ractor.select(*ports_or_ractors)` blocks until one port has a message or one Ractor has finished and returns `[port_or_ractor, obj]`. Unshareable messages are deep-copied by default; `send(obj, move: true)` transfers ownership instead, and later use of `obj` in the sender raises `Ractor::MovedError`. Closing a port makes further sends raise `Ractor::ClosedError`.
code
ruby · 9 linesport = Ractor::Port.new
Ractor.new(port) do |out|
buffer = "pixels".dup
out.send(buffer, move: true)
buffer.size # raises Ractor::MovedError
end
port.receive # => "pixels"go deeper
Recall that ports are mailboxes: anyone sends, only the owner receives, and Ractor.receive reads the current Ractor's own default port.
Explain the default port shortcuts, what Ractor.select returns for ports versus Ractors, and when send copies, references or moves a message.
Design a pool with a shared results port, shutdown messages and back-pressure, and migrate old yield/take code to ports without losing error handling.
Weigh building on an API that changed shape in 4.0 against stable process or queue boundaries, and decide how much Ractor plumbing to hide behind your own abstraction.
## What changed in 4.0 Up to Ruby 3.4, Ractors communicated through a push/pull pair: a Ractor called `Ractor.yield(obj)` and another called `r.take` to pull that value. Ruby 4.0 removed `Ractor.yield`, `Ractor#take`, `Ractor#close_incoming` and `Ractor#close_outgoing` and introduced **`Ractor::Port`**. Final results are read with `Ractor#join` and `Ractor#value`; everything sent while a Ractor runs goes through ports. ## Ractor::Port in one table | Method | Who may call it | Behaviour | |---|---|---| | `Ractor::Port.new` | any Ractor | creates a port owned by the calling Ractor | | `port.send(obj, move: false)` / `port << obj` | any Ractor | enqueues a message and returns immediately; the queue is unbounded | | `port.receive` | only the creating Ractor | blocks until a message arrives; others get `Ractor::Error` | | `port.close` | only the creating Ractor | later sends raise `Ractor::ClosedError`; queued messages can still be received | | `port.closed?` | any Ractor holding the port | `true` or `false` | You can pass a port to `Ractor.new` as an argument or inside a message; whoever receives it can send to the same mailbox, but still cannot receive from it. A Ractor's ports are closed automatically when it terminates. ## The default port Every Ractor has one port created for it, available as `r.default_port`. The familiar calls are shortcuts for it: - `r.send(msg)` (alias `r << msg`) sends to `r`'s default port and returns `r`. - `Ractor.receive` reads from the current Ractor's default port. Because `Ractor#send` is redefined, calling `r.send(:some_method)` sends the Symbol as a message instead of calling a method; use `__send__` or `public_send` for reflection on a Ractor. ## A worker pool for image hashing ```ruby results = Ractor::Port.new workers = 4.times.map do Ractor.new(results) do |out| while (path = Ractor.receive) out << [path, File.binread(path).sum] end end end paths = Dir["photos/*.jpg"] paths.each_with_index { |p, i| workers[i % 4] << p } workers.each { it << nil } hashes = paths.size.times.map { results.receive }.to_h workers.each(&:join) ``` Each worker loops on its default port until it receives `nil`, and all of them write to one results port owned by the main Ractor. Only the main Ractor can call `results.receive`. ## Ractor.select `Ractor.select(*ports_or_ractors)` waits on several sources at once: 1. With **ports**, it blocks until one of them has a message and returns `[port, message]`. 2. With **Ractors**, it waits until one of them terminates and returns `[ractor, value]`, the value moved to the caller as with `Ractor#value`. 3. Anything else, or an empty argument list, raises `ArgumentError`. A typical loop removes each finished source until the list is empty, so results are handled in completion order instead of the order the Ractors were started. ## Migrating old code | Ruby 3.x | Ruby 4.0 | |---|---| | `Ractor.yield(obj)` inside the worker | `port << obj` to a port the consumer created and passed in | | `r.take` for the next yielded value | `port.receive` in the Ractor that owns the port | | `r.take` for the final block value | `r.value` | | `Ractor.select(*rs)` over yielding Ractors | `Ractor.select(*ports)`, or over Ractors to wait for termination | The shape of the program changes: instead of pulling values out of a specific Ractor, the consumer owns a mailbox and producers push into it. ## Copy or move Sending never shares an unshareable object: - **Shareable** objects are sent by reference. - **Unshareable** objects are **deep-copied** by default, which is safe but costs time and memory for large payloads. - With **`move: true`**, the object's ownership moves to the receiver. The sender's reference becomes a `Ractor::MovedObject`, and calling any method on it raises `Ractor::MovedError`. Some objects cannot be copied or moved at all; sending a `Thread`, for example, raises `TypeError` because Thread has no allocator.
- A worker Ractor calls `receive` on a port that the main Ractor created. What happens, and how should the design change?It raises `Ractor::Error`, because only the Ractor that created a port may receive from it. Ports are write-anywhere, read-by-owner. The worker should create its own port, or use its default port through `Ractor.receive`, and the main Ractor should send work there.
- Why can `port.send` never provide back-pressure when producers outpace a consumer?A port's queue is unbounded and `send` returns immediately, so a fast producer keeps queueing messages and memory grows. Back-pressure has to be built on top, for example by having workers ask for the next job, or by limiting how many jobs are outstanding before the producer calls `receive` for a result.
saying these in an interview costs you the question
- Calls Ractor.yield in a worker and Ractor#take in the main Ractor on 4.0
- Thinks any Ractor holding a port can receive from it
- Expects Port#send to block until the receiver is ready
- Believes a moved object stays usable in the sending Ractor
- Calls r.send(:method_name) on a Ractor expecting reflection