skip to content

In Ruby 4.0, how do you start a Ractor with input, wait for it to finish, and read its block's return value?

level: juniorimportance: nice to knowfreq 22%

answer

  1. arguments travel like messages
  2. block cannot see outer locals
  3. join returns the Ractor itself
  4. value hands over the block's result
  5. failures arrive as Ractor::RemoteError

basics

~10 s

Call Ractor.new(input) { |x| ... }; the arguments reach the isolated block like messages. r.join waits and returns the Ractor, r.value waits and returns the block's last value, and a crash surfaces as Ractor::RemoteError.

solid answer

~40 s

`Ractor.new(*args, name: nil) { |*args| ... }` starts a Ractor whose block is **isolated**: it cannot read the caller's local variables, so input goes in as arguments, which are passed like messages (shareable objects by reference, the rest deep-copied). `self` inside the block is the Ractor. In Ruby 4.0 you wait with `r.join`, which returns the Ractor, or `r.value`, which waits and returns the block's last expression; that value is moved to the caller, so only one Ractor can ever take it. If the block raised, `join` and `value` raise `Ractor::RemoteError` whose `cause` is the original exception. The first `Ractor.new` in a process prints the warning that the Ractor API is experimental.

code

ruby · 11 lines
ruby
r = Ractor.new(21) { |n| n * 2 }

r.join            # => r (waits, returns the Ractor)
r.value           # => 42

bad = Ractor.new { raise "boom" }
begin
  bad.value
rescue Ractor::RemoteError => e
  e.cause.message # => "boom"
end

go deeper

for a junior

Recall the three calls: Ractor.new with arguments, join to wait, value to get the result. Know that the block cannot touch outside local variables.

for a middle

Explain that arguments are sent like messages, copied unless shareable, and that value moves the result to one receiver. Show how Ractor::RemoteError wraps the real exception in cause.

for a senior

Point out the 4.0 API churn: take and yield are gone, the experimental warning still prints, so wrap Ractor use behind a small internal interface you can change later.

for a principal

Weigh whether an experimental API whose shape changed in the last release belongs on a production critical path, versus processes or a job queue with a stable interface.

## What a Ractor is A **Ractor** is Ruby's unit of parallel execution: a separate bundle of threads with its own interpreter lock, so on CRuby several Ractors can run Ruby code on different CPU cores at the same time. Objects are not freely shared between Ractors, which is why the API for starting one and getting data in and out looks different from `Thread`. ## Starting one with input `Ractor.new(*args, name: nil) { |*args| ... }` creates and starts a Ractor. Three rules shape how you write the block: - **The block is isolated.** It may not refer to local variables of the surrounding scope. `a = 1; Ractor.new { a + 1 }` raises `ArgumentError` ("can not isolate a Proc because it accesses outer variables (a).") at the `Ractor.new` call, before anything runs. - **Input goes in as arguments.** Whatever you pass to `Ractor.new` becomes the block's parameters, transferred with the same rules as a message: a shareable object (an Integer, a Symbol, a deeply frozen String) is passed by reference; anything else is deep-copied, so the Ractor works on its own copy. - **`self` is the Ractor**, not the object that called `Ractor.new`. - **`name:`** is optional and only shows up in `inspect` and `Ractor#name`, which helps when debugging. ```ruby paths = Dir["photos/*.jpg"] workers = paths.map do |path| Ractor.new(path, name: File.basename(path)) do |file| bytes = File.binread(file) [file, bytes.each_byte.reduce(0) { |h, b| (h * 31 + b) & 0xffff_ffff }] end end fingerprints = workers.map(&:value).to_h ``` Each image is hashed in its own Ractor, so the CPU-bound loops can occupy several cores. ## Waiting: join and value Ruby 4.0 added `Ractor#join` and `Ractor#value`, modelled on the `Thread` methods of the same names: | Call | Blocks until | Returns | If the block raised | |---|---|---|---| | `r.join` | the Ractor terminates | `r` itself | raises `Ractor::RemoteError` | | `r.value` | the Ractor terminates | the block's last value | raises `Ractor::RemoteError` | Two details interviewers probe: 1. **The value is moved, not copied.** The termination value is handed to the Ractor that asks for it, and only that one Ractor may take it; a second Ractor calling `r.value` on the same Ractor gets `Ractor::Error`. 2. **Errors are wrapped.** An exception that escapes the block does not propagate as itself. `join` or `value` raises `Ractor::RemoteError`, whose `cause` is the original exception and whose `ractor` attribute is the Ractor it came from. ```ruby r = Ractor.new { Integer("not a number") } begin r.value rescue Ractor::RemoteError => e e.cause.class # => ArgumentError e.ractor == r # => true end ``` ## What replaced the old API Before 4.0 you read a Ractor's result with `Ractor#take`, and a Ractor could hand out intermediate values with `Ractor.yield`. Ruby 4.0 removed both, together with `close_incoming` and `close_outgoing`. The replacement is `join`/`value` for the final result and `Ractor::Port` for anything streamed while the Ractor runs. Code or blog posts that still call `take` fail with `NoMethodError` on 4.0. ## Still experimental The first `Ractor.new` in a process prints a warning that the Ractor API is experimental and may change in future versions of Ruby. The 4.0 release notes describe a large stability and performance effort that brings Ractors "closer to leaving experimental status", but they have not left it. The removal of `take` and `yield` in the same release is a good illustration of why that warning matters: the API is still moving. ## One Ractor per item, or a pool? Starting one Ractor per image, as above, is the simplest correct shape, and creating a Ractor costs only slightly more than creating a thread. With thousands of files, though, you pay that cost thousands of times and run far more Ractors than you have cores. The usual next step is a fixed set of worker Ractors fed through ports, with `value` or `join` used once at shutdown. Either way, keep what crosses the boundary small: a path String in, a path and an Integer out. ## Practical checklist - Pass everything the block needs as arguments; never close over locals. - Prefer returning a small result (a String and an Integer) over a big object graph. - Use `value` for the result and rescue `Ractor::RemoteError`, then look at `cause`. - Wait on every Ractor you start, so crashes are not silently lost.

  • Why does `a = 5; Ractor.new { a * 2 }` fail, and when does it fail?
    The Ractor block is isolated from its surrounding scope, so it may not capture local variables. Ruby checks this when `Ractor.new` is called and raises `ArgumentError` saying it can not isolate a Proc because it accesses outer variables. The fix is `Ractor.new(a) { |n| n * 2 }`, which sends `a` in as an argument.
  • Two different Ractors both call `value` on the same finished Ractor. What happens?
    The termination value is moved to the first Ractor that takes it, which becomes its only recipient. The second caller gets `Ractor::Error`. If several consumers need a result, have one of them receive it and pass it on as a message.
  • You wrap `r.value` in `rescue ZeroDivisionError`, yet a division by zero inside the Ractor is not rescued. Why?
    `value` does not re-raise the original exception in the caller. It raises `Ractor::RemoteError`, a subclass of `Ractor::Error`, and stores the original `ZeroDivisionError` in `cause`, so a rescue clause for the original class never matches. Rescue `Ractor::RemoteError` and branch on `e.cause`.

saying these in an interview costs you the question

  • Reads a Ractor's result with Ractor#take on Ruby 4.0
  • Thinks the Ractor block can use the caller's local variables
  • Expects the original exception class from value instead of Ractor::RemoteError
  • Believes Ractor#join returns the block's value
  • Says Ractors left experimental status in Ruby 4.0