Ruby 4.0 separates definitions and Ractor replies
Ruby 4.0 introduces experimental definition isolation, dedicated Ractor reply ports, and the ZJIT method compiler. Their designs address different costs of Ruby's dynamic object model.
Ruby's ability to reopen classes creates a problem when independently loaded code shares a process. A library can change definitions that other libraries depend on. Parallel execution adds a related coordination problem when unrelated operations receive replies through the same mailbox. Ruby 4.0, released on December 25, introduces experimental Ruby Box and revises Ractor communication to make those boundaries explicit. It also ships ZJIT, whose compiler must preserve Ruby's dynamic behavior while optimizing methods.
I came to Ruby from Perl and still value its consistent object model and readable collection operations. Those qualities make the language approachable as a program grows. They also make the scope of a modification important. A method call can look ordinary while depending on a definition another library changed. Ruby Box interests me because it addresses that dependency without requiring every existing library to adopt new declarations.
Separation happens when code loads
Satoshi Tagomori's revised namespace on read proposal, Feature #21311, explains the design. Its central choice was to let the caller load existing application code into separate namespaces. Requiring libraries to declare their own package boundaries would demand changes throughout the ecosystem. Controlling separation at load time allows an application to introduce it incrementally.
That distinction explains why Ruby Box is more substantial than putting another module around a class name. The proposal addresses conflicting library names and unexpectedly shared definitions. It also identifies loading multiple gem versions as a possible use, but explicitly requires support from RubyGems, Bundler, or another packaging system. The runtime facility alone does not supply dependency resolution. An application still needs a way to decide which files belong to each dependency graph.
The released interface is Ruby::Box, enabled by setting RUBY_BOX=1 before the process starts. The release describes isolation of monkey patches, global and class variable changes, and loaded Ruby or native libraries. Its suggested applications include isolating tests that patch methods and comparing application responses across dependency updates. These are proposed uses of an experimental facility. The announcement says a higher level package API has not yet been designed.
The distinction between an available runtime mechanism and a finished packaging workflow matters here. Loading a second application definition is only one part of comparing two application versions. Choosing dependencies and deciding which requests to execute remain application responsibilities. The release's response comparison example describes a useful direction without claiming that Ruby 4.0 includes an application deployment manager.
A box preserves the defining context
The Ruby 4.0 Box documentation shows that box.require loads a file and its recursively required files into that box. Constants become accessible through the box object, and methods defined in those files retain their box context when called from outside. A small example follows that documented behavior.
# settings.rb
LIMIT = 10
class Settings
def self.limit
LIMIT
end
end
# main.rb, started with RUBY_BOX=1
LIMIT = 20
box = Ruby::Box.new
box.require_relative("settings")
p LIMIT # 20
p box::LIMIT # 10
p box::Settings.limit # 10
The last call is particularly useful. Calling a method from the main program does not make that method resolve its constants against the main program's definitions. Otherwise, loading code separately would still leave its behavior dependent on who happened to call it. The example provides a direct way to understand the loading boundary without assuming that the entire application has become a separate operating system process.
Built-in classes add a subtle qualification. The documentation says String and box::String are the same object, while changes to their definitions are visible within the relevant box. Merely obtaining box::String from outside does not transfer execution into that box. Code defined there must perform the operation for its scoped method definitions to apply. An explicit wrapper method therefore communicates the intended context better than passing around a built-in class reference.
I like this attempt to preserve Ruby's familiar method calls while making library effects more local. My reservation about choosing Ruby for new systems remains, but controlling the reach of monkey patches addresses a concrete source of complexity. It makes the language's flexibility easier to reason about at the point where application code loads dependencies.
Replies need destinations of their own
Ractor has a different separation problem. Koichi Sasada's Port proposal, Feature #21262, describes a caller asking several worker Ractors for results. Sending each result to the caller's ordinary mailbox mixes replies from different operations. Identifying the sender is insufficient when requests to the same service can complete in different orders.
Application tags can distinguish those replies, but they require cooperation from every consumer of the mailbox. Another library calling Ractor.receive can consume a message before the intended code filters it. This makes an apparently local library operation depend on unrelated code's receiving behavior. Giving each response path its own endpoint removes that shared convention from the application protocol.
A Ractor::Port permits other Ractors to send messages to it, but only its creating Ractor can receive from it. The endpoint associates a destination with a unique identity. The proposal demonstrates passing a port to a worker and receiving its messages through that port. Creating separate ports makes the intended reply destination part of the request itself.
This ownership rule is relevant when designing a library interface. Returning an arbitrary shared queue suggests that any consumer might take its next value. A port identifies the receiving Ractor in advance. A service can accept the destination supplied by its caller without deciding how that caller organizes other pending requests. The caller can keep separate reply paths for independent operations.
Communication and completion have separate methods
Sasada's release-day explanation of the revised API gives a compact example with two ports. Each worker sends to its assigned endpoint, so receiving from the second port first does not consume the first port's result.
first = Ractor::Port.new
second = Ractor::Port.new
Ractor.new(first) { |reply| reply << :hello }
Ractor.new(second) { |reply| reply << :world }
p second.receive # :world
p first.receive # :hello
The API also separates a worker's termination from its ongoing messages. Ractor#join waits for completion, and Ractor#value waits and obtains the final return value. The old Ractor.yield and Ractor#take operations are gone. Existing send and receive operations continue through a default port. Ractor remains experimental, which is relevant to the scale of this incompatible revision.
Sasada explains that implementing channels with dedicated forwarding Ractors added creation and copying costs. Ports avoid needing a Ractor solely for that communication path. The change also simplifies synchronization compared with the previous yield and take model. This gives the API revision an implementation rationale beyond shortening application code.
ZJIT optimizes a larger unit of Ruby code
The May description of ZJIT's architecture explains why Ruby is adding another compiler. ZJIT compiles entire methods and uses a high level intermediate representation based on static single assignment. It obtains historical type information from a profiled interpreter and runs a modular optimizer before lowering code to its lower level representation.
Seeing a whole method gives optimization passes more code to examine together. The authors also want a conventional compiler structure that outside contributors can understand. That is a maintenance argument as well as a performance argument. A compiler's organization determines how easily someone can add an optimization and inspect the assumptions it makes.
Ruby's ability to redefine methods remains part of those assumptions. The May explanation uses integer addition to show why knowing operand types is insufficient. An optimization must also account for changes to the method implementing the operation. Compiling arithmetic into machine instructions requires preserving the behavior that Ruby code is allowed to change.
I find that constraint more interesting than a headline speed comparison. Ruby's readable operators are method operations with dynamic behavior behind them. A compiler that keeps those semantics while exposing its reasoning in a conventional intermediate representation gives contributors a clearer place to work on performance.
Falling back makes speculation usable
The December 24 compiler update describes a major improvement since May. ZJIT can now leave compiled code and resume in the interpreter when a guard fails. Its example calls an addition method with integers and later with strings. The changed input types no longer require the compiler to abort safely, as the early implementation did.
This fallback also explains a remaining limitation. The authors say changes in method call patterns can cause frequent exits to the interpreter. Using those exits to gather more profile information and recompile is future work. Correctly handling a changed assumption and adapting efficiently to it are separate implementation problems.
The update reports support for substantially more Ruby operations and larger methods. It also says ZJIT remains slower than YJIT and advises holding off on production deployment. That assessment is compatible with shipping the compiler in a major release. Distribution gives more people a supported way to exercise the implementation while its optimization coverage develops.
For Ruby 4.0, --zjit explicitly enables the new compiler, and building it requires Rust 1.85.0 or newer. The release makes the experiment available alongside the language's other runtime changes. Ruby Box and Ractor Ports alter how applications organize definitions and messages. ZJIT must optimize the resulting Ruby programs while preserving the dynamic method behavior those applications still use.