skip to content

In CRuby, why can Ractors run CPU-bound Ruby code in parallel when threads in one Ractor cannot, and what does that isolation forbid?

level: middleimportance: should knowfreq 38%

answer

  1. one interpreter lock per Ractor
  2. threads inside one Ractor still take turns
  3. unshareable objects stay with one Ractor
  4. globals and class variables: main Ractor only
  5. Ractor::IsolationError

basics

~20 s

CRuby gives each Ractor its own interpreter lock, so Ractors run on separate cores while threads inside one Ractor still take turns. The price is isolation: unshareable objects, global variables and class variables cannot be touched across Ractors.

solid answer

~40 s

In CRuby the global VM lock (GVL) is held **per Ractor**. Threads in the same Ractor share that lock and interleave, but threads in different Ractors hold different locks, so CPU-bound Ruby code in two Ractors really runs on two cores. That is only safe because Ractors do not share mutable state: most objects are **unshareable** and belong to the Ractor that created them; other Ractors get copies or moved objects. The language enforces this: a Ractor block cannot capture outer locals (`ArgumentError`), and a non-main Ractor raises `Ractor::IsolationError` when it reads a global variable, touches a class variable, or reads a constant or class-level instance variable holding an unshareable object. Inside one Ractor, several threads still need `Mutex` for their shared state.

go deeper

for a junior

Remember that CRuby threads take turns under one lock, while each Ractor has its own lock and can use its own core.

for a middle

Explain shareable versus unshareable objects and list what a non-main Ractor may not touch: outer locals, globals, class variables, unshareable constants.

for a senior

Judge whether real code can move into a Ractor: audit globals, class-level caches and mutable constants in your gems, and weigh copy costs against the speed-up.

for a principal

Frame the choice between Ractors, processes and threads by workload: CPU-bound pure-Ruby work, library compatibility, memory and the risk of an experimental API.

## The lock is per Ractor CRuby protects its interpreter with a lock, usually called the **GVL** (global VM lock). A thread must hold it to run Ruby code. With Ractors, that lock is held **per Ractor**, not per process: - **Threads in the same Ractor** share one lock, so only one of them runs Ruby code at a time. They interleave; they do not run CPU-bound Ruby in parallel. - **Threads in different Ractors** hold different locks, so they can run Ruby code on different cores simultaneously. - **Every program starts with one Ractor**, the *main Ractor*. A program that never calls `Ractor.new` behaves exactly as before. That is the whole reason Ractors exist: to let a single Ruby process use several cores for CPU-bound Ruby work, such as computing fingerprints for thousands of images, without forking. ## Why parallelism needs isolation Removing the single lock would expose every mutable object to data races. Ractors avoid that by limiting what can be shared: | Kind of object | Across Ractors | |---|---| | **Shareable** (Integers, Symbols, `nil`/`true`/`false`, deeply frozen objects, classes, modules, Ractors) | the same object is visible to all | | **Unshareable** (almost everything else: mutable Strings, Arrays, Hashes, ordinary objects) | only one Ractor owns it; others receive a copy or a moved object | Because an unshareable object only ever has one owning Ractor, there is no way for two Ractors to race on it, and shareable objects are either immutable or internally synchronised. ## What the language forbids in a non-main Ractor To keep that guarantee, Ruby adds rules that only apply when more than one Ractor exists: 1. **Block isolation.** The block given to `Ractor.new` may not refer to outer local variables; `Ractor.new` raises `ArgumentError` if it does. 2. **Global variables.** Only the main Ractor may read or write them; elsewhere Ruby raises `Ractor::IsolationError` ("can not access global variables from non-main Ractors"). A few special globals, `$stdin`, `$stdout` and `$stderr`, are local to each Ractor. 3. **Class variables.** `@@count` is readable and writable only from the main Ractor. 4. **Constants.** A non-main Ractor may read a constant only if it refers to a shareable object; otherwise it gets `Ractor::IsolationError`. 5. **Instance variables of classes and modules.** Classes are shareable, but a non-main Ractor may read a class-level instance variable only if its value is shareable, and may not set one at all. ```ruby $processed = 0 r = Ractor.new do $processed += 1 # not allowed outside the main Ractor end begin r.join rescue Ractor::RemoteError => e e.cause.class # => Ractor::IsolationError end ``` ## Threads still exist inside a Ractor Each Ractor has its own main thread and may start more with `Thread.new`. Those threads share their Ractor's lock and can share that Ractor's unshareable objects, so everything you know about thread safety still applies within one Ractor. Ractors make races impossible **between** Ractors, not between threads inside one. ## What it costs in practice - **Copying.** Unshareable arguments and messages are deep-copied, which can dominate the run time for large payloads. - **Library compatibility.** Code that keeps configuration in globals, class variables, mutable constants or class-level caches fails in a non-main Ractor, and many gems do exactly that. - **Scheduling.** Non-main Ractors always run on the M:N thread scheduler, whose pool of shared native threads is capped by the `RUBY_MAX_CPU` environment variable, 8 by default. - **Maturity.** The Ractor API is still marked experimental, and the first `Ractor.new` in a process warns about it. ## Choosing between the three tools | Workload | Good fit | Why | |---|---|---| | CPU-bound pure Ruby on plain data | Ractors | parallel inside one process, no fork | | I/O-bound (HTTP calls, database queries) | threads | the lock is released while waiting | | CPU-bound code that uses gems with global state | processes | each process has its own objects and lock | The usual verdict: Ractors fit self-contained, CPU-bound work on plain data. For I/O-bound work, threads already overlap well because the lock is released while waiting.

  • A program starts 16 Ractors for CPU-bound hashing on a 16-core machine but sees far less than a 16x speed-up. What could explain it?
    Deep-copying large unshareable arguments and results costs time on both sides, and some VM-wide work such as garbage collection is still coordinated across Ractors. Non-main Ractors also run on the M:N scheduler, whose shared native thread pool is capped by `RUBY_MAX_CPU`, 8 by default. Measure, send small shareable inputs such as frozen paths, and raise that variable when more cores should run Ractor code.
  • Does using Ractors mean you no longer need a Mutex anywhere in the program?
    No. Ractors prevent races between Ractors, because unshareable objects have a single owner. Threads inside one Ractor share its objects and interleave under its lock, so shared mutable state among those threads still needs a `Mutex`, as in ordinary threaded Ruby.

Ractors are separate kitchens, each with one stove key: cooks in one kitchen take turns with its key, but two kitchens cook at once. The price is that no ingredient is ever used by two kitchens at once: you pass a copy through the hatch or hand the original over for good.

saying these in an interview costs you the question

  • Says one GVL still serialises every Ractor in the process
  • Thinks threads inside a single Ractor run Ruby code in parallel
  • Believes a non-main Ractor can update a global variable counter
  • Assumes Ractors are separate OS processes started with fork
  • Claims Ractors make every Mutex in the program unnecessary