From: "Eregon (Benoit Daloze) via ruby-core" Date: 2026-11-09T16:11:42+00:00 Subject: [ruby-core:127383] [Ruby Feature#22523] `define_method` should make its body shareable when possible, using `Ractor.shareable_proc` semantics Issue #22523 has been reported by Eregon (Benoit Daloze). ---------------------------------------- Feature #22523: `define_method` should make its body shareable when possible, using `Ractor.shareable_proc` semantics https://bugs.ruby-lang.org/issues/22523 * Author: Eregon (Benoit Daloze) * Status: Open ---------------------------------------- #22444 made `Ractor.new` use the `Ractor.shareable_proc` semantics from #21557 for its block, and there was some interest in doing the same for `define_method` ([#22444#note-5](https://bugs.ruby-lang.org/issues/22444#note-5)). That was in fact already proposed in the `define_method` section of #21557. ## Proposal `Module#define_method` and `Kernel#define_singleton_method` try to make the given block/Proc shareable, as if by `Ractor.shareable_proc(self: nil)`. If that is not possible, the block/Proc is used as before: ```ruby def define_method(name, &body) body = Ractor.shareable_proc(self: nil, &body) rescue body Primitive.define_method(name, &body) end ``` `self` of the body does not matter, because `define_method` replaces it with the receiver on every call anyway. ## Motivation ### Ractor compatibility for free Today, methods defined by `define_method` cannot be called from a non-main Ractor (`defined with an un-shareable Proc in a different Ractor (RuntimeError)`), unless the block is explicitly made shareable. That is verbose, and libraries need workarounds like this one in `ostruct`: ```ruby if defined?(::Ractor) getter_proc = nil.instance_eval{ Proc.new { @table[name] } } setter_proc = nil.instance_eval{ Proc.new {|x| @table[name] = x} } ::Ractor.make_shareable(getter_proc) ::Ractor.make_shareable(setter_proc) else getter_proc = Proc.new { @table[name] } setter_proc = Proc.new {|x| @table[name] = x} end define_singleton_method!(name, &getter_proc) define_singleton_method!("#{name}=", &setter_proc) ``` With this proposal, that becomes: ```ruby define_singleton_method!(name) { @table[name] } define_singleton_method!("#{name}=") { |x| @table[name] = x } ``` Most `define_method` blocks only read outer variables which are assigned once and hold Symbols, frozen Strings, Integers, Classes, etc. All of those methods become callable from any Ractor with no code change. This makes it much easier for existing libraries to become Ractor-compatible. ### Performance: captured variables can be folded at definition time Once the body is shareable, its captured variables are immutable: they are never reassigned (checked statically), their values are shareable, and they are copied when the method is defined. So for a given method entry they are constants. This allows JITs to fold reads of outer variables at definition time, making a `define_method` body as fast as the equivalent `def`: ```ruby class C x = 42 def def_m = 42 define_method(:dm_nocapture) { 42 } define_method(:dm_capture) { x } # with this proposal, a JIT can compile it like def_m end ``` Time per call (macOS arm64, call threshold 1, loop of 20M calls): | | `def_m` | `dm_nocapture` | `dm_capture` | |-------|--------:|---------------:|-------------:| | YJIT | 1.0 ns | 1.0 ns | 2.7 ns | | ZJIT | 1.0 ns | 0.9 ns | 2.3 ns | The only difference between `dm_capture` and `def_m`/`dm_nocapture` is that the captured variable is read through the environment. Neither JIT folds it yet: these numbers are the same with and without the patch. That folding is only valid if the variable cannot change, which is what this proposal guarantees. Without it, the JIT has to keep reading the variable through the environment, since the variable could be reassigned at any time. Another possibility is to do the folding in the bytecode: when the method is defined, `getlocal` instructions reading captured variables could be replaced by `putobject `. Both JITs already treat `putobject` operands as known constants, so they would benefit without JIT-specific changes. The interpreter would too, although there the read itself is cheap (one extra hop through the environment). The bigger gain would be from optimizations which can then use these constants. A trade-off is that the iseq of a block is shared by all Procs created from it (`Ractor.shareable_proc` copies the environment, not the iseq), so this needs a copy of the iseq per method, as `Proc#refined` already does. That copy could be made lazily, only for methods which are called often. ## Compatibility `define_method` never raises because of this: if the body cannot be made shareable (it reads a reassigned or unshareable outer variable, writes an outer variable, uses `yield`, ...), the method is defined exactly like before. This relies on the static analysis of `Ractor.shareable_proc` rejecting every block whose semantics would change. While implementing this I found two cases where it did not, filed as separate bugs and to be fixed first: 1. Outer variables assigned in a loop (`while`/`until`/`for`, `retry`, `redo`) were not considered reassigned: #22521, https://github.com/ruby/ruby/pull/19802. Without that fix, `define_method` would change the behavior of existing code like: ```ruby class C for name in %i[a b] define_method(name) { name } end end [C.new.a, C.new.b] # => [:b, :b], it must not become [:a, :b] ``` 2. `block_given?`, `iterator?`, `defined?(yield)` and `super` without an explicit block use the block of the enclosing method, like `yield`, but were not rejected: #22522, https://github.com/ruby/ruby/pull/19803. Without that fix, `define_method` would change the behavior of existing code like: ```ruby class D def foo = D.module_eval { define_method(:m) { block_given? } } end D.new.foo {} D.new.m # => true, it must not become false ``` With both fixes, such bodies are not made shareable and the methods behave exactly as before. Remaining differences, for methods whose body gets made shareable: * As discussed in #21557 (section "eval and binding"), `eval`/`binding` reassigning a captured variable cannot be detected statically. Similarly, `binding`/`eval` *inside* the method body (e.g. `binding.irb`) do not see the captured variables, as for any shareable Proc. * `$~` and `$_` in the method body are no longer shared with the frame which called `define_method` (which was more of a surprising behavior than a feature). Instead, as for any shareable Proc, they are stored per Fiber, in the slot also used by the root frame of the Fiber/Thread. So a call made directly in a `Thread.new { }`/`Fiber.new { }` block body can overwrite that block's `$~`. This should be fixed for shareable Procs in general, e.g. with a separate per-Fiber slot. * `define_method` itself is slightly slower, because it checks whether the body can be shared: about +30 ns per `define_method` when it falls back, and about +125 ns when it makes the body shareable (the environment is copied). This is a one-time cost per definition; calls are not slower. ## Implementation PR: https://github.com/ruby/ruby/pull/19801, on top of https://github.com/ruby/ruby/pull/19802 and https://github.com/ruby/ruby/pull/19803. With `module_function`, the singleton method now uses the same body as the instance method, so both are shareable when possible. `make btest`, `make test-all` and the relevant ruby/spec specs pass. The only btests/tests which needed changes were ones checking that a `define_method` body capturing nothing (`{}`, `{ self }`) cannot be called from another Ractor. They now capture an unshareable object, so they keep testing that error. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/