skip to content

In Ruby 4.0, how do Ractors exchange messages with Ractor::Port and Ractor.select now that Ractor.yield and Ractor#take are gone?

level: seniorimportance: nice to knowfreq 18%

answer

  1. only the creator can receive
  2. send never blocks
  3. every Ractor has a default port
  4. select returns [port, message]
  5. move: true, then Ractor::MovedError

basics

~20 s

A 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 s

Ruby 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 lines
ruby
port = 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

for a junior

Recall that ports are mailboxes: anyone sends, only the owner receives, and Ractor.receive reads the current Ractor's own default port.

for a middle

Explain the default port shortcuts, what Ractor.select returns for ports versus Ractors, and when send copies, references or moves a message.

for a senior

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.

for a principal

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