Feature #22226
openRactor: class/module ownership -- restrict modification to the Ractor that created it
Description
Abstract¶
Every class/module records the Ractor that created it as its owner. Only the owner Ractor
can modify it. Reading is unchanged and allowed from any Ractor.
This replaces several ad-hoc "main Ractor only" rules with a single rule keyed on the creator.
It relaxes the rules for classes a Ractor creates itself, and tightens them for classes
created by somebody else.
No Ruby-level API is added: ownership is recorded internally and is not exposed.
1. Motivation¶
1.1 The current rules ask the wrong question¶
Today's restrictions ask "are you the main Ractor?". They never ask "did you create this class?".
The result is inconsistent in both directions.
A non-main Ractor may freely redefine any class in the process:
class C; end
Ractor.new { C.define_method(:m) { 1 }; :done }.value #=> :done (works today)
Ractor.new { class String; def foo = 1; end; :done }.value #=> :done (works today)
Ractor.new { def f = 42; f() }.value #=> 42 (works today; defines Object#f)
Ractor.new { Object.send(:remove_const, :FOO); :done }.value #=> :done (works today)
...but it may not set an instance variable on a class it created itself:
Ractor.new {
k = Class.new # nobody else can even name this class yet
k.instance_variable_set(:@iv, 1)
}.value
#=> Ractor::IsolationError:
# can not set instance variables of classes/modules by non-main Ractors
...nor give it an unshareable constant:
Ractor.new {
k = Class.new
k.const_set(:X, "str".dup)
}.value
#=> Ractor::IsolationError:
# can not set constants with non-shareable objects by non-main Ractors
The first group is unrestricted although it affects the whole process.
The second group is forbidden although it affects nothing but the Ractor doing it.
The rules are exactly inverted with respect to who is actually affected.
1.2 A Ractor's classes can be redefined under it by another Ractor¶
Because any Ractor may modify any class, the code a Ractor runs can be changed by somebody else
at any moment:
Ractor.new { class String; def upcase = :patched; end; :done }.value
"abc".upcase #=> :patched # evaluated in the *main* Ractor
FOO = 1
Ractor.new { Object.send(:remove_const, :FOO); :removed }.value
FOO #=> NameError: uninitialized constant FOO
Both of these run fine today. There is no way for a Ractor to rely on the classes it uses.
1.3 Class internals have no single writer¶
m_tbl, const_tbl and the class fields can be written by any Ractor today. This means the
implementation cannot assume a single writer for any class, which stands in the way of
synchronization and caching work (and it is also how the cvar_overtaken() bug below arose:
a read path physically deletes a table entry, potentially of another Ractor's class).
2. Proposal¶
A class/module records its creator Ractor as its owner. Only the owner may:
- define/remove/undef methods,
alias, change method visibility include/prependinto it, andModule#refinetargeting it- define/remove constants, register
autoload - set instance variables on the class/module object
Non-owner Ractors get Ractor::IsolationError.
Reading is completely unchanged: method calls, instantiation, subclassing, constant reads
and instance-variable reads all work from any Ractor as before.
In exchange, the previous "main Ractor only" rules for class instance variables and unshareable
constant values become "owner Ractor only". A Ractor gets full use of the classes it creates.
Since all classes/modules created at boot or by main-Ractor code (including everything loaded by
require) are owned by the main Ractor, this is equivalent to today's rules for programs that do
not create classes inside non-main Ractors.
3. Examples¶
3.1 Newly allowed: a Ractor can fully use the classes it creates¶
Instance variables on a class the Ractor created:
Ractor.new {
k = Class.new
k.instance_variable_set(:@iv, 1) # was: IsolationError, even for a shareable value!
k.instance_variable_set(:@iv, "str".dup) # was: IsolationError
k.instance_variable_get(:@iv) #=> "str"
}.value
Unshareable values in constants of a class the Ractor created. This was the main Ractor's
privilege before; now every Ractor has it for its own classes:
Ractor.new {
k = Class.new
k::BUF = "buffer".dup # unshareable value; was: IsolationError
k::BUF << "!" # ...and the owner can freely use it
k::BUF #=> "buffer!"
}.value
Ractor.new {
k = Class.new
k.const_set(:LIST, [1, 2, 3]) # an unfrozen Array is unshareable too
k::LIST << 4
k::LIST #=> [1, 2, 3, 4]
}.value
The value stays private to the owner -- reading it from another Ractor raises, which is exactly
the rule that applied to the main Ractor before:
port = Ractor::Port.new
r = Ractor.new(port) { |pt| k = Class.new; k::BUF = "buffer".dup; pt << k; Ractor.receive }
k = port.receive
k::BUF
#=> Ractor::IsolationError: can not access non-shareable objects in constant
# #<Class:0x...>::BUF of a class/module created by another Ractor.
A shareable value in the same position is readable from anywhere, as before:
r = Ractor.new(port) { |pt| k = Class.new; k::N = 42; pt << k; Ractor.receive }
k = port.receive
k::N #=> 42
Classes defined under a module the Ractor created itself work fully:
Ractor.new {
mod = Module.new
mod.const_set(:Foo, Class.new { def m = :ok })
mod::Foo.new.m #=> :ok
}.value
3.2 Newly prohibited: modifying a class created by another Ractor¶
class C; end
module M; end
Ractor.new { def f = 42 }.value
#=> can not modify Object because it is created by another Ractor
# (top-level `def` defines a private method on Object)
Ractor.new { class String; def foo = 1; end }.value
#=> can not modify String because it is created by another Ractor
Ractor.new { C.define_method(:m) { 1 } }.value
Ractor.new { C.send(:alias_method, :a, :inspect) }.value
Ractor.new { C.send(:private, :inspect) }.value
Ractor.new { C.send(:undef_method, :inspect) }.value
Ractor.new { C.include(M) }.value
Ractor.new { C.prepend(M) }.value
Ractor.new { Module.new { refine(C) { def z = 1 } } }.value
Ractor.new { C.autoload(:Zz, "zz") }.value
#=> can not modify C because it is created by another Ractor
Ractor.new { C.const_set(:X, 1) }.value # note: even a *shareable* value now raises
#=> can not set constants of classes/modules created by another Ractor
Ractor.new { class TopCls; end }.value # a top-level class name...
Ractor.new { module TopMod; end }.value # ...and a top-level module name
#=> can not set constants of classes/modules created by another Ractor
# (both write a constant into Object)
Ractor.new { Object.send(:remove_const, :FOO) }.value
#=> can not modify Object because it is created by another Ractor
Ractor.new { C.instance_variable_set(:@iv, 1) }.value
#=> can not set instance variables of classes/modules created by another Ractor
Patterns that stop working:
# a registry in Class#inherited that writes into the superclass
Base = Class.new { def self.inherited(sub) = const_set(:"Sub#{sub.object_id}", sub) }
Ractor.new { Class.new(Base) }.value
#=> can not set constants of classes/modules created by another Ractor
# lazy definition on first touch
M2 = Module.new { def self.const_missing(n) = const_set(n, Class.new) }
Ractor.new { M2::Foo }.value
#=> can not set constants of classes/modules created by another Ractor
Both work if the definition happens before the Ractor is spawned (eager loading).
3.3 Unchanged¶
Ractor.new { "abc".upcase }.value #=> "ABC"
Ractor.new { C.new }.value # instantiation
Ractor.new { Class.new(String) { def m = :ok }.new.m }.value #=> :ok (subclassing is creation)
Ractor.new { require "time"; Time.now.respond_to?(:xmlschema) }.value #=> true
require from a non-main Ractor keeps working because Ractor#require performs the load on the
main Ractor, so the library's classes are defined by (and owned by) the main Ractor.
def on an object a Ractor created is also unaffected -- an ordinary object's singleton class is
created by, and owned by, the Ractor that triggers it:
3.4 Summary table¶
| operation from a non-main Ractor | today | proposal |
|---|---|---|
| define a method on a foreign class | OK | IsolationError |
top-level def |
OK | IsolationError |
include/prepend/refine a foreign class |
OK | IsolationError |
| set a shareable constant on a foreign class | OK | IsolationError |
| set an unshareable constant on a foreign class | IsolationError | IsolationError |
remove_const / autoload on a foreign class |
OK | IsolationError |
| set an ivar on a foreign class | IsolationError | IsolationError |
| define a method on its own class | OK | OK |
| set a shareable constant on its own class | OK | OK |
| set an unshareable constant on its own class | IsolationError | OK |
| set an ivar on its own class | IsolationError | OK |
| read / call / instantiate / subclass any class | OK | OK |
4. How the proposal answers the motivation¶
- 1.1 (rules ask the wrong question) -- the rules now ask "did you create this?" instead of
"are you main?". Every restriction is on the class of somebody else, and every relaxation is on
your own class. The two inverted cases in 1.1 both flip to the right side.
The old "main Ractor only" rules become the special case "boot-time classes are owned by the
main Ractor", so they are no longer separate rules. - 1.2 (classes changed under you) -- no Ractor can modify a class it did not create, so the
classes a Ractor uses cannot be redefined by another Ractor. - 1.3 (no single writer) -- every class/module now has exactly one Ractor which can write its
method table, constant table and fields.
5. What this does not give¶
Ownership bounds who may write, not what readers may see. Reads stay unsynchronized and a
class modification is not atomic, so a non-owner Ractor can still observe a class in the middle
of being modified by its owner: after the first def of a class ... end body but before the
second, or while include/prepend is rewiring the ancestor chain.
The guarantee is a single writer per class/module, not a consistent view for readers.
This proposal is not, and does not claim to be, full isolation of class state.
6. Details¶
-
Singleton classes / metaclasses are owned by the owner of the object they are attached to,
not by the Ractor that happened to trigger their lazy creation.def C.foois allowed exactly
for the owner ofC: -
Terminated owner: a class whose owner Ractor has finished becomes permanently read-only for
everybody, including the main Ractor.K = Ractor.new { Class.new { def x = :made_in_ractor } }.value K.new.x #=> :made_in_ractor (reading is fine) K.define_method(:y) { 1 } #=> IsolationError (nobody owns it any more)There is intentionally no API to look up or transfer ownership.
-
Class variables are not covered by the relaxation. They are shared across the whole
inheritance chain and the class they are physically stored in can migrate over time
(cvar_overtaken), so no single owner can be defined for them. Writes still require the main
Ractor, and additionally must not cross the ownership boundary (the class actually written into
must be owned), so class fields keep a single writer. -
Copying a foreign class:
Class#dup/cloneof a class created by another Ractor produces a
copy owned by the copying Ractor. It raises if the source's constants or instance variables
refer to unshareable objects (they would leak across Ractors); copying classes holding only
shareable values works. This gives a mutation-free alternative to monkey-patching: -
Top-level class and module names:
class Foo; end/module Foo; endat the top level of a
non-main Ractor writes a constant intoObject, so both are prohibited. Define them under a
module the Ractor creates itself instead
(mod = Module.new; mod.const_set(:Foo, Class.new)). Integration withRuby::Boxis future work. -
Bug fix included:
cvar_overtaken()physically deleted a duplicated cvar entry from the
frontclass, and that path is reachable from read operations (rb_cvar_find) -- i.e. a
cross-Ractor write triggered by a read. The clean-up is now skipped unless the current Ractor
ownsfront.
7. Incompatibility¶
Only code that modifies classes from a non-main Ractor is affected. Concretely, the following
raise Ractor::IsolationError where they used to work:
- method definition /
alias/ visibility change on a foreign class, including top-leveldef include/prependinto a foreign class,refinetargeting one- setting a shareable constant on a foreign class,
remove_const,autoloadregistration Class#inheritedandconst_missing/method_missinghooks which mutate a foreign class
Migration is usually "define it before spawning the Ractor" (eager loading), or "create the class
inside the Ractor that uses it", or "dup the foreign class and modify the copy".
Programs which never define classes inside non-main Ractors are unaffected.
8. Open questions / future work¶
freezeof a foreign class is still allowed. It is a behavior-changing write and should
probably be owner-only too.Module#set_temporary_nameis not checked yet.- Singleton class ownership is not transferred by
Ractor#send(move: true). - Integration with
Ruby::Box, so that non-main Ractors can define top-level class/module names. - Should the error message for a class owned by the main Ractor say "owned by the main Ractor"
rather than "created by another Ractor"? The current wording is confusing for top-leveldef,
where the user never mentionedObject.
9. Implementation¶
https://github.com/ruby/ruby/pull/17913
The owner is stored in rb_classext_t::owner_ractor as the Ractor object; 0 means the main
Ractor, so single-Ractor programs get no additional GC edges. It is marked from the classext and
updated on compaction, so the comparison never sees a dangling or reused reference. Method-table
checks funnel through rb_class_modify_check(); constant/ivar/cvar checks replace the previous
rb_ractor_main_p() checks in variable.c. The class-ivar fast paths in vm_insnhelper.c switch
from "main Ractor" to "owner Ractor", which keeps them valid because the owner is the only writer
and its threads are serialized by the per-Ractor lock.
make btest (2053 tests), make test-all (35797 tests, 0 failures / 0 errors) and make test-spec
(32628 examples, 0 failures / 0 errors / 0 tagged) all pass. Three tests which relied on
cross-Ractor method definition were rewritten so that the owner Ractor performs the definition.
Notes¶
- The description above was written by Claude Code based on my explanation.
- The primary motivation is to eliminate the special cases for the main Ractor. The concept of an "owner Ractor" for classes and modules provides a general solution to such cases.
- Another possible approach would be to prohibit class and module mutations from non-main Ractors, since most classes and modules are defined by the main Ractor during program loading. However, this would also prohibit
Class.newin non-main Ractors, even though dynamically creating classes is a common programming pattern in Ruby.
Updated by ko1 (Koichi Sasada) about 16 hours ago
- Description updated (diff)
Updated by rkh (Konstantin Haase) about 10 hours ago
How will autoload work with this?
My understanding would be that autoload will simply not work from ractors, even on classes they own, except via Ruby::Box. Is that correct?
Autoloads currently run on the main ractor, so ownership would be lost, and there is no straightforward way to dynamically communicate a custom namespace if autoloads were to run on a non-main ractor.
Updated by ko1 (Koichi Sasada) about 9 hours ago
ยท Edited
How will autoload work with this?
autoload and require work on the main Ractor.
https://bugs.ruby-lang.org/issues/20627