In CRuby, why can Ractors run CPU-bound Ruby code in parallel when threads in one Ractor cannot, and what does that isolation forbid?
answer
- one interpreter lock per Ractor
- threads inside one Ractor still take turns
- unshareable objects stay with one Ractor
- globals and class variables: main Ractor only
- Ractor::IsolationError
basics
~20 sCRuby 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 sIn 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
Remember that CRuby threads take turns under one lock, while each Ractor has its own lock and can use its own core.
Explain shareable versus unshareable objects and list what a non-main Ractor may not touch: outer locals, globals, class variables, unshareable constants.
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.
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