Skip to content

Ractors share nothing that is not frozen

Level: 301 · anyone who wants Ruby code to run in parallel and has heard that Ractors are the way past the GVL

One line: A Ractor runs a block on a thread with a lock of its own, so two Ractors run Ruby code at the same time — and to keep that safe, a Ractor can reach only shareable objects (frozen all the way down, or made so by Ractor.make_shareable), every other object is copied on the way in and out, a block that reads an outer local is refused, and an exception inside comes back wrapped in Ractor::RemoteError; Python's multiprocessing draws the same line with pickling, one process further out.

Ractor.new { } looks like Thread.new but its block is isolated. It may use the arguments passed to Ractor.new (row 2) and constants, but not the locals around it — referencing one raises ArgumentError before the ractor even starts (row 6) — and a constant that holds an unfrozen object raises Ractor::IsolationError inside the ractor (row 7). The result of the block comes back through value, which waits for the ractor to finish (rows 1, 2 and 14), and an exception inside arrives as Ractor::RemoteError with the original as its cause (row 8). What can cross the boundary is decided by Ractor.shareable? (row 4): integers, symbols and frozen strings, and containers that are frozen and hold only shareable objects — [1, "a"].freeze fails because the string inside is not frozen. Ractor.make_shareable freezes an object graph all the way down (row 5). Anything else is copied on the way in, which is why the ractor in row 3 saw four elements and the caller's array still has three.

Ruby 4.0 replaced the old take/yield pair with ports. A Ractor::Port is a mailbox: any ractor that holds it may << a message, and the ractor that created it calls receive (row 9); every ractor also has a default port, so r.send(msg) and Ractor.receive inside the block are the simplest pipe (row 10). A message obeys the same rule as an argument — a mutable string is copied, so the receiver's upcase! leaves the sender's string alone (row 10) and the received object is not equal? to the sent one, while a frozen string passes by reference and is (row 11). send(obj, move: true) hands the object over instead of copying: the receiver gets it, and the sender's reference raises Ractor::MovedError when touched (row 12). Procs are the last edge: a plain lambda cannot be copied at all (row 15), and Ractor.shareable_lambda builds one that can. The whole feature is still marked experimental, which is why the program sets Warning[:experimental] = false before the first Ractor.new so that the warning does not reach stderr, and the reason to accept all this is the part no output can show: N ractors run Ruby code on N cores at once, where N threads take turns under the GVL (Not machine-checked here.).

Verified output of ractors_share_nothing_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. Ractor.new { 6 * 7 }.value                           42
 2. Ractor.new(10) { |n| n + 1 }.value                   11
 3. an Array argument is copied: inside size / outside   4 / [1, 2, 3]
 4. shareable? "s".freeze / "s" / 1 / :a / [1, "a"].freeze [true, false, true, true, false]
 5. make_shareable deep-freezes: outer/inner/string/ok?  [true, true, true, true]
 6. a block that reads an outer local                    ArgumentError: can not isolate a Proc because it accesses outer variables (x).
 7. reading an unfrozen constant: the cause              Ractor::IsolationError: can not access non-shareable objects in constant Object::UNFROZEN by non-main Ractor.
 8. an exception inside: value raises                    Ractor::RemoteError: thrown by remote Ractor. cause ArgumentError: inside
 9. 3 ractors send through a Ractor::Port, sorted        [[0, 0], [1, 10], [2, 20]], values [:sent, :sent, :sent]
10. send a String; the ractor upcase!s its copy          "HELLO" / sender still "hello"
11. received equal?(sent): a frozen String / a mutable one true / false
12. send(move: true): receiver has it / sender's copy    "moved" / Ractor::MovedError
13. Ractor.main? outside / inside                        true / false
14. 4 ractors squaring; values collected in order        [0, 1, 4, 9]
15. pass a lambda: plain / Ractor.shareable_lambda       TypeError / 8

Reading the output

Rows 1 and 2 are value: 42 and 11. Row 3 is the copy: the ractor's a << 4 made its array four long, and arr outside is still [1, 2, 3]. Row 4 prints [true, false, true, true, false] — a frozen string, an unfrozen one, an Integer, a Symbol, and a frozen array holding an unfrozen string — and row 5 shows make_shareable turning [1, ["a"]] into something frozen at every level, [true, true, true, true]. Rows 6 to 8 are the three errors and their messages: can not isolate a Proc because it accesses outer variables (x). at creation, can not access non-shareable objects in constant Object::UNFROZEN by non-main Ractor. as the cause of a RemoteError, and thrown by remote Ractor. wrapping the ArgumentError: inside that the block raised.

Row 9 is the port: three ractors each pushed a pair, the main ractor received three and sorted them, and every worker's value is :sent. Rows 10 to 12 are the three ways a message travels: copied ("HELLO" inside, "hello" outside; equal? false), shared because frozen (equal? true), and moved ("moved" arrives, and the sender's moved.size raises Ractor::MovedError). Row 13 says Ractor.main? is true here and false inside; row 14 collects four squares in creation order by calling value on each in turn — the ractors may have finished in any order, but map(&:value) fixes the order the page sees. Row 15 prints TypeError for the plain lambda — a Proc cannot be copied — and 8 for the shareable one.

Compared with Python

Python has no Ractor. What it has is multiprocessing, which draws the same line one level further out: a child process shares nothing with the parent, and every argument, message and result crosses as a pickle — a copy. The twin therefore prints the same shape. A value comes back through a Future (rows 1 and 2), a list argument is copied so the child's append is invisible to the parent (row 3), a module-level list is likewise the child's own copy (row 7), a child's exception arrives through the future — exception() holds it and result() raises it (row 8) — and processes report through a Queue (row 9) or a Pipe (row 10), with the parent sorting what arrived. Row 11 is where the two differ in kind: Ruby passes a frozen object by reference, but nothing crosses a Python pipe by reference, not even a tuple — is is False both times. Row 12 has no move, row 13 asks parent_process(), and row 14 is pool.map.

Ractor.shareable? has no counterpart because there is nothing to share; the question becomes "can it be pickled?" (rows 4, 6 and 15): strings, numbers and lists yes, a lambda or a nested function no — which is why every function the twin sends is defined at module level, and why the file needs the if __name__ == "__main__": guard: the twin chooses the spawn start method, under which a child starts a fresh interpreter and imports this file. Python 3.14 also ships concurrent.interpreters, a sub-interpreter per "worker" inside one process with its own GIL, which is the closest thing to a Ractor — it is not in 3.12, so it is not measured here (Not machine-checked here.).

Verified output of ractors_share_nothing_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. pool.submit(six_times_seven).result()                42
 2. pool.submit(plus_one, 10).result()                   11
 3. a list argument is copied: inside len / outside      4 / [1, 2, 3]
 4. picklable? "s" / 1 / [1, "a"] / a lambda             [True, True, True, False]
 5. pickle round trip: == / is / inner is                [True, False, False]
 6. a nested function that reads an outer local          picklable? False: it never leaves this process
 7. a module-level list appended to in the child / here  ['a', 'b'] / ['a']
 8. an exception inside: exception() / result() raises   ValueError: inside / ValueError: inside
 9. 3 Processes send through a Queue, sorted; exitcodes  [(0, 0), (1, 10), (2, 20)], [0, 0, 0]
10. send a list; the child upper()s its copy             ['HELLO'] / sender still ['hello']
11. echoed object is the sent one: a tuple / a list      False / False
12. send with move?                                      no move semantics; the sender still has 'moved'
13. parent_process() is None: outside / inside           True / False
14. pool.map(square, range(4))                           [0, 1, 4, 9]
15. pass a lambda / a module-level def                   picklable? False / 8

Try it

  1. Give four ractors four short texts and one Ractor::Port; have each send back [name, word_count]. Receive four results, sort them by name, print the counts and the total — and print every ractor's value to show they all finished.
  2. Send the same Hash to a ractor twice, once frozen with Ractor.make_shareable and once not. Compare equal? on what comes back, then time a loop of a thousand sends of a large array both ways with Process.clock_gettime and explain what the copy costs (the timing is yours to see; it is not something this library records).
Solution to 1

ractors_share_nothing_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: four ractors count the words of four texts and report through one
# Ractor::Port. The port hands the results over in whatever order the
# ractors finish, so they are sorted by name before printing.
Warning[:experimental] = false

texts = {
  "a" => "the cat sat on the mat",
  "b" => "a ractor shares nothing that is not frozen",
  "c" => "join every ractor before you print",
  "d" => "one two three",
}

port = Ractor::Port.new
ractors = texts.map do |name, text|
  Ractor.new(port, name, text) do |pt, n, t|
    pt << [n, t.split.size]
    :done
  end
end

counts = Array.new(ractors.size) { port.receive }.sort
counts.each { |name, n| puts "text #{name}: #{n} words" }
puts "total: #{counts.sum { |_, n| n }} words"
puts "every ractor's value: #{ractors.map(&:value).uniq.inspect}"

Verified output of ractors_share_nothing_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

text a: 6 words
text b: 8 words
text c: 6 words
text d: 3 words
total: 23 words
every ractor's value: [:done]

See also