[ruby-core:126538] [Ruby Feature#22277] Make `rb_gc_register_address` Ractor-local
Issue #22277 has been reported by eightbitraptor (Matt V-H). ---------------------------------------- Feature #22277: Make `rb_gc_register_address` Ractor-local https://bugs.ruby-lang.org/issues/22277 * Author: eightbitraptor (Matt V-H) * Status: Open * Assignee: eightbitraptor (Matt V-H) * Target version: 4.1 ---------------------------------------- ## Summary [Github PR #18393](https://github.com/ruby/ruby/pull/18393) `rb_gc_register_address` stores its entries in a single VM-wide list. This means that every Ractor local GC walks the list under a VM wide lock, which causes contention between Ractors attempting to GC. The associated PR changes the storage to per-ractor lists, removing the lock. However, due to potential Ractor isolation issues, we've restricted `rb_gc_register_address` and `rb_gc_unregister_address` to the main ractor. We would like to have a discussion about this restriction before going ahead with this change. ## Background Each ractor owns an objspace. A local GC marks only that ractor's roots and sweeps only its own pages. Using a VM-wide list with this design means that: - Every ractor's local GC scans every slot, under a shared lock. - A slot can name an object in another ractor's objspace. - If ractor A registers a slot and the slot's value is an unshareable object owned by ractor B, every ractor's GC marks the object. Storing it there violates the Ractor isolation guarantees, but the object stays alive, so it works. Changing this so that each Ractor maintains it's own list changes this so that only the registering Ractor's GC scans it's own list, which means we don't need to do this under a lock. However this introduces a situation where a slot on Ractor A's list holds an unshareable object owned by Ractor B, then Ractor B's local GC will not scan A's list, and A's local GC won't mark objects in B's objspace (because mark_maybe filters by objspace). This means that B can collect the object while A holds a reference to it, leading to a potential use-after-free. Because `rb_gc_register_address` and `rb_gc_unregister_address` are part of the public C API. It's tough to enforce a contract that restricts a slots value to one that's either shareable or owned by the main Ractor, because we register addresses, and any C extension can manipulate the value at that address at any time. ## Implementation detail The associated PR adds a "registered_addrs" array and a listed flag to each `rb_ractor_t` instance. When an address registered the Ractor in question is added to a VM local registry of Ractors who's lists are not empty, so that any calls to `rb_gc_unregister_address` can find the registering Ractor easily. Each Ractor local GC marks the current ractors list via `rb_ractor_mark_local_roots` and each Global GC marks every Ractors list, including terminated Ractors whose structs haven't been freed yet. The `rb_gc_register_address` and `rb_gc_unregister_address` functions now raise `Ractor::UnsafeError` when run from a non-main Ractor. This is a fairly blunt mechanism for ensuring that we can't end up with dangling pointers, but does restrict the usage of this API in ways that might be problematic. ## Concerns The main concern with this approach is the main Ractor only restriction? This is a semantic change from how the existing API works, and does restrict the functionality somewhat. This will only affect gems with C extensions that register and unregister objects dynamically at runtime. Registration during `Init_` functions still runs on the main Ractor and is unaffected. On 2026-08-25 we ran a codesearch over an index of published gems, looking for gems that both call `rb_ext_ractor_safe(true)` to declare themselves ractor safe and also call `rb_gc_register_address`. We found a total of 14 packages that matched both calls. Of which 6 were forks of other packages also in the list. Of the remaining 8 packages, 5 contained `rb_gc_register_address` calls that were _only_ within `Init_` functions. These would be unaffected by the change. The remaining 3 are [`oj`](https://rubygems.org/gems/oj), [`ox`](https://rubygems.org/gems/ox), and [`stack_trace`](https://rubygems.org/gems/stack_trace). Given the infrequency of this usage in real world Ruby applications. Should we add some documentation around `rb_gc_register_address` to document it's restricted usage, and patch the affected gems upstream? -- https://bugs.ruby-lang.org/
Issue #22277 has been updated by jhawthorn (John Hawthorn). eightbitraptor (Matt V-H) wrote:
The remaining 3 [packages with potential issues] are [`oj`](https://rubygems.org/gems/oj), [`ox`](https://rubygems.org/gems/ox), and [`stack_trace`](https://rubygems.org/gems/stack_trace).
* Stack trace looks fine, that [object is expected to hold a Ractor](https://github.com/meinac/stack_trace/blob/f2d68feea4c4f233d3b9fb966d83175be...), which is shareable and so won't have an issue (also it seems that address is repeatedly registered and could just be at boot? strange) * I don't think `oj`'s usage should be respected. One of the places `rb_gc_register_address` is called is [very obviously thread-unsafe code](https://github.com/ohler55/oj/blob/a1ef2747bfbb508ca7cff413194e676962c41c76/...), despite it (incorrectly) declaring itself Ractor-safe. Most of the rest of the usage is in init functions. * `ox`'s usage is also mostly in init functions. The one exception I see is [registering classes](https://github.com/ohler55/ox/blob/41063b9c33fce81f9e137c577724df173f4f4318/...) (which are shareable). [The one usage I found at runtime clearly at runtime](https://github.com/ohler55/ox/blob/41063b9c33fce81f9e137c577724df173f4f4318/...) shows it registering an address with an unshareable object, inside an rb_protect block, and the address is unregistered after it. That case falls into what would be safe Ractor-local. ---------------------------------------- Feature #22277: Make `rb_gc_register_address` Ractor-local https://bugs.ruby-lang.org/issues/22277#change-118762 * Author: eightbitraptor (Matt V-H) * Status: Open * Assignee: eightbitraptor (Matt V-H) * Target version: 4.1 ---------------------------------------- ## Summary [Github PR #18393](https://github.com/ruby/ruby/pull/18393) `rb_gc_register_address` stores its entries in a single VM-wide list. This means that every Ractor local GC walks the list under a VM wide lock, which causes contention between Ractors attempting to GC. The associated PR changes the storage to per-ractor lists, removing the lock. ## Background Each ractor owns an objspace. A local GC marks only that ractor's roots and sweeps only its own pages. Using a VM-wide list with this design means that: - Every ractor's local GC scans every slot, under a shared lock. - A slot can name an object in another ractor's objspace. - If ractor A registers a slot and the slot's value is an unshareable object owned by ractor B, every ractor's GC marks the object. Storing it there violates the Ractor isolation guarantees, but the object stays alive, so it works. Changing this so that each Ractor maintains it's own list changes this so that only the registering Ractor's GC scans it's own list, which means we don't need to do this under a lock. However this introduces a situation where a slot on Ractor A's list holds an unshareable object owned by Ractor B, then Ractor B's local GC will not scan A's list, and A's local GC won't mark objects in B's objspace (because mark_maybe filters by objspace). This means that B can collect the object while A holds a reference to it, leading to a potential use-after-free. Because `rb_gc_register_address` and `rb_gc_unregister_address` are part of the public C API. It's tough to enforce a contract that restricts a slots value to one that's either shareable or owned by the main Ractor, because we register addresses, and any C extension can manipulate the value at that address at any time. ## Implementation detail The associated PR adds a "registered_addrs" array and a listed flag to each `rb_ractor_t` instance. When an address registered the Ractor in question is added to a VM local registry of Ractors who's lists are not empty, so that any calls to `rb_gc_unregister_address` can find the registering Ractor easily. Each Ractor local GC marks the current ractors list via `rb_ractor_mark_local_roots` and each Global GC marks every Ractors list, including terminated Ractors whose structs haven't been freed yet. The `rb_gc_register_address` and `rb_gc_unregister_address` functions now raise `Ractor::UnsafeError` when run from a non-main Ractor. This is a fairly blunt mechanism for ensuring that we can't end up with dangling pointers, but does restrict the usage of this API in ways that might be problematic. ## Concerns The main concern with this approach is potential use-after-free that can arise from a dangling pointer if the Ractor has an address registered containing a pointer to an object owned by another Ractor. One potential solution to this is to instead restrict the `rb_gc_register_address` API to the main Ractor only? This is a semantic change from how the existing API works, and does restrict the functionality somewhat. This will only affect gems with C extensions that register and unregister objects dynamically at runtime. Registration during `Init_` functions still runs on the main Ractor and is unaffected. On 2026-08-25 we ran a codesearch over an index of published gems, looking for gems that both call `rb_ext_ractor_safe(true)` to declare themselves ractor safe and also call `rb_gc_register_address`. We found a total of 14 packages that matched both calls. Of which 6 were forks of other packages also in the list. Of the remaining 8 packages, 5 contained `rb_gc_register_address` calls that were _only_ within `Init_` functions. These would be unaffected by the change. The remaining 3 are [`oj`](https://rubygems.org/gems/oj), [`ox`](https://rubygems.org/gems/ox), and [`stack_trace`](https://rubygems.org/gems/stack_trace). Given the infrequency of this usage in real world Ruby applications. Is it acceptable to restrict `rb_gc_register_address` to the main Ractor only, document it's restricted usage, and patch the affected gems upstream? We'd like to discuss this, and any potential alternative solutions. -- https://bugs.ruby-lang.org/
Issue #22277 has been updated by Eregon (Benoit Daloze). eightbitraptor (Matt V-H) wrote:
Given the infrequency of this usage in real world Ruby applications. Is it acceptable to restrict `rb_gc_register_address` to the main Ractor only, document it's restricted usage, and patch the affected gems upstream? We'd like to discuss this, and any potential alternative solutions.
I think it might be OK, although it is a semantic change. I'd like to understand this issue better, let me ask a few questions. From the issue title `Make rb_gc_register_address Ractor-local` I got confused about what this is proposing. I think making it Ractor-local, IIUC hold the object at that address alive only for the Ractor that called rb_gc_register_address() is less clear if that's OK semantically (notably because there might be objects from other Ractors stored there). To state it clearly, the current semantics are "keep the object at this address alive for the entire duration of the Ruby interpreter" ([docs](https://docs.ruby-lang.org/capi/en/master/db/df8/include_2ruby_2internal_2gc...)), it would be ideal if we can keep that. To achieve that, isn't there still a true global GC e.g. to reclaim shareable objects? Could that take care of rb_gc_register_address()? Maybe the issue is then the Ractor-local GCs would collect the object behind the pointer? (sorry I don't have time to read more about the Ractor-local GC semantics right now) If not, how about something like a read-write lock so multiple Ractors could do this part of the Ractor-local GC at the same time as long as there are no concurrent rb_gc_register_address()? This seems the best: preserves the semantics and solves (most of) the contention. --- There is a sister API, `rb_gc_register_mark_object(VALUE)` (takes an object instead of a pointer, so simpler), what about that, is that not problematic with Ractor? There is also `rb_global_variable()` but that's literally just calling `rb_gc_register_address()` so the same thing. The name makes it even clearly it should be global and stay alive forever. --- In the context of TruffleRuby it's not possible to fully implement `rb_gc_register_address()`, instead we approximate by saving the addresses for the calls during `Init_` and then reading them only once at the end of each `Init_my_extension`. For `rb_gc_register_address()` called outside `Init_` we approximate by reading the pointer immediately (and only once). So we kind of turn `rb_gc_register_address()` into `rb_gc_register_mark_object()`, but delaying until the end of `Init_` so the order between `rb_gc_register_address()` and the assignment doesn't matter. It works pretty well in practice (no known issue with it). From the point of view of portability, ideally we would only allow `rb_gc_register_address()` during `Init_`, raising an exception if called outside `Init_`, and it would still mean "keep the object at this address alive for the entire duration of the Ruby interpreter". (Or deprecate it and recommend `rb_gc_register_mark_object` instead, but I guess that's too hard compat-wise) ---------------------------------------- Feature #22277: Make `rb_gc_register_address` Ractor-local https://bugs.ruby-lang.org/issues/22277#change-118783 * Author: eightbitraptor (Matt V-H) * Status: Open * Assignee: eightbitraptor (Matt V-H) * Target version: 4.1 ---------------------------------------- ## Summary [Github PR #18393](https://github.com/ruby/ruby/pull/18393) `rb_gc_register_address` stores its entries in a single VM-wide list. This means that every Ractor local GC walks the list under a VM wide lock, which causes contention between Ractors attempting to GC. The associated PR changes the storage to per-ractor lists, removing the lock. ## Background Each ractor owns an objspace. A local GC marks only that ractor's roots and sweeps only its own pages. Using a VM-wide list with this design means that: - Every ractor's local GC scans every slot, under a shared lock. - A slot can name an object in another ractor's objspace. - If ractor A registers a slot and the slot's value is an unshareable object owned by ractor B, every ractor's GC marks the object. Storing it there violates the Ractor isolation guarantees, but the object stays alive, so it works. Changing this so that each Ractor maintains it's own list changes this so that only the registering Ractor's GC scans it's own list, which means we don't need to do this under a lock. However this introduces a situation where a slot on Ractor A's list holds an unshareable object owned by Ractor B, then Ractor B's local GC will not scan A's list, and A's local GC won't mark objects in B's objspace (because mark_maybe filters by objspace). This means that B can collect the object while A holds a reference to it, leading to a potential use-after-free. Because `rb_gc_register_address` and `rb_gc_unregister_address` are part of the public C API. It's tough to enforce a contract that restricts a slots value to one that's either shareable or owned by the main Ractor, because we register addresses, and any C extension can manipulate the value at that address at any time. ## Implementation detail The associated PR adds a "registered_addrs" array and a listed flag to each `rb_ractor_t` instance. When an address registered the Ractor in question is added to a VM local registry of Ractors who's lists are not empty, so that any calls to `rb_gc_unregister_address` can find the registering Ractor easily. Each Ractor local GC marks the current ractors list via `rb_ractor_mark_local_roots` and each Global GC marks every Ractors list, including terminated Ractors whose structs haven't been freed yet. The `rb_gc_register_address` and `rb_gc_unregister_address` functions now raise `Ractor::UnsafeError` when run from a non-main Ractor. This is a fairly blunt mechanism for ensuring that we can't end up with dangling pointers, but does restrict the usage of this API in ways that might be problematic. ## Concerns The main concern with this approach is potential use-after-free that can arise from a dangling pointer if the Ractor has an address registered containing a pointer to an object owned by another Ractor. One potential solution to this is to instead restrict the `rb_gc_register_address` API to the main Ractor only? This is a semantic change from how the existing API works, and does restrict the functionality somewhat. This will only affect gems with C extensions that register and unregister objects dynamically at runtime. Registration during `Init_` functions still runs on the main Ractor and is unaffected. On 2026-08-25 we ran a codesearch over an index of published gems, looking for gems that both call `rb_ext_ractor_safe(true)` to declare themselves ractor safe and also call `rb_gc_register_address`. We found a total of 14 packages that matched both calls. Of which 6 were forks of other packages also in the list. Of the remaining 8 packages, 5 contained `rb_gc_register_address` calls that were _only_ within `Init_` functions. These would be unaffected by the change. The remaining 3 are [`oj`](https://rubygems.org/gems/oj), [`ox`](https://rubygems.org/gems/ox), and [`stack_trace`](https://rubygems.org/gems/stack_trace). Given the infrequency of this usage in real world Ruby applications. Is it acceptable to restrict `rb_gc_register_address` to the main Ractor only, document it's restricted usage, and patch the affected gems upstream? We'd like to discuss this, and any potential alternative solutions. -- https://bugs.ruby-lang.org/
Issue #22277 has been updated by eightbitraptor (Matt V-H). Eregon (Benoit Daloze) wrote in #note-3:
eightbitraptor (Matt V-H) wrote:
Given the infrequency of this usage in real world Ruby applications. Is it acceptable to restrict `rb_gc_register_address` to the main Ractor only, document it's restricted usage, and patch the affected gems upstream? We'd like to discuss this, and any potential alternative solutions.
I think it might be OK, although it is a semantic change.
After discussing with @ko1 we've decided not to restrict this API to the main Ractor, I'll come on to this later.
To achieve that, isn't there still a true global GC e.g. to reclaim shareable objects? Could that take care of rb_gc_register_address()? Maybe the issue is then the Ractor-local GCs would collect the object behind the pointer?
This is definitely an issue.
If not, how about something like a read-write lock so multiple Ractors could do this part of the Ractor-local GC at the same time as long as there are no concurrent rb_gc_register_address()?
The main benefit of making this Ractor local though is to remove the full table walk under the lock. With a read-write lock, we'd still have a global table to scan on every GC.
There is a sister API, `rb_gc_register_mark_object(VALUE)` (takes an object instead of a pointer, so simpler), what about that, is that not problematic with Ractor?
`rb_gc_register_mark_object` appends to a local Ractor array rather than a global list. But these don't get collected. When the Owning ractor is joined they all get absorbed into the parent Ractor's objspace and there's no facility to free them at all.
There is also `rb_global_variable()` but that's literally just calling `rb_gc_register_address()` so the same thing. The name makes it even clearly it should be global and stay alive forever.
In the context of TruffleRuby it's not possible to fully implement `rb_gc_register_address()`, instead we approximate by saving the addresses for the calls during `Init_` and then reading them only once at the end of each `Init_my_extension`. For `rb_gc_register_address()` called outside `Init_` we approximate by reading the pointer immediately (and only once). So we kind of turn `rb_gc_register_address()` into `rb_gc_register_mark_object()`, but delaying until the end of `Init_` so the order between `rb_gc_register_address()` and the assignment doesn't matter. It works pretty well in practice (no known issue with it).
That's good to hear. But `rb_gc_register_address` doesn't fully map to `rb_gc_register_mark_object` because addresses can be unregistered too.
From the point of view of portability, ideally we would only allow `rb_gc_register_address()` during `Init_`, raising an exception if called outside `Init_`, and it would still mean "keep the object at this address alive for the entire duration of the Ruby interpreter".
This is effectively the same semantics as only allowing `rb_gc_register_address` from the main Ractor right? because `Init_` functions are always run from the main Ractor. So after talking with @ko1 about this patch. His preferred path is to keep the registered addresses lists Ractor local, so they can be scanned and collected lock free by the Ractor that owns them, and when a Ractor is joined, the registered addresses are inherited by the owning Ractor's successor (or the main Ractor if a Ractor gets GC'd with no successor). This leaves the semantics of the API the same as they are currently, but does mean that we need to document the requirement that the registering Ractor and the owning Ractor must be the same at all times. I have updated the linked PR to do that now. ---------------------------------------- Feature #22277: Make `rb_gc_register_address` Ractor-local https://bugs.ruby-lang.org/issues/22277#change-118806 * Author: eightbitraptor (Matt V-H) * Status: Open * Assignee: eightbitraptor (Matt V-H) * Target version: 4.1 ---------------------------------------- ## Summary [Github PR #18393](https://github.com/ruby/ruby/pull/18393) `rb_gc_register_address` stores its entries in a single VM-wide list. This means that every Ractor local GC walks the list under a VM wide lock, which causes contention between Ractors attempting to GC. The associated PR changes the storage to per-ractor lists, removing the lock. ## Background Each ractor owns an objspace. A local GC marks only that ractor's roots and sweeps only its own pages. Using a VM-wide list with this design means that: - Every ractor's local GC scans every slot, under a shared lock. - A slot can name an object in another ractor's objspace. - If ractor A registers a slot and the slot's value is an unshareable object owned by ractor B, every ractor's GC marks the object. Storing it there violates the Ractor isolation guarantees, but the object stays alive, so it works. Changing this so that each Ractor maintains it's own list changes this so that only the registering Ractor's GC scans it's own list, which means we don't need to do this under a lock. However this introduces a situation where a slot on Ractor A's list holds an unshareable object owned by Ractor B, then Ractor B's local GC will not scan A's list, and A's local GC won't mark objects in B's objspace (because mark_maybe filters by objspace). This means that B can collect the object while A holds a reference to it, leading to a potential use-after-free. Because `rb_gc_register_address` and `rb_gc_unregister_address` are part of the public C API. It's tough to enforce a contract that restricts a slots value to one that's either shareable or owned by the main Ractor, because we register addresses, and any C extension can manipulate the value at that address at any time. ## Implementation detail The associated PR adds a "registered_addrs" array and a listed flag to each `rb_ractor_t` instance. When an address registered the Ractor in question is added to a VM local registry of Ractors who's lists are not empty, so that any calls to `rb_gc_unregister_address` can find the registering Ractor easily. Each Ractor local GC marks the current ractors list via `rb_ractor_mark_local_roots` and each Global GC marks every Ractors list, including terminated Ractors whose structs haven't been freed yet. The `rb_gc_register_address` and `rb_gc_unregister_address` functions now raise `Ractor::UnsafeError` when run from a non-main Ractor. This is a fairly blunt mechanism for ensuring that we can't end up with dangling pointers, but does restrict the usage of this API in ways that might be problematic. ## Concerns The main concern with this approach is potential use-after-free that can arise from a dangling pointer if the Ractor has an address registered containing a pointer to an object owned by another Ractor. One potential solution to this is to instead restrict the `rb_gc_register_address` API to the main Ractor only? This is a semantic change from how the existing API works, and does restrict the functionality somewhat. This will only affect gems with C extensions that register and unregister objects dynamically at runtime. Registration during `Init_` functions still runs on the main Ractor and is unaffected. On 2026-08-25 we ran a codesearch over an index of published gems, looking for gems that both call `rb_ext_ractor_safe(true)` to declare themselves ractor safe and also call `rb_gc_register_address`. We found a total of 14 packages that matched both calls. Of which 6 were forks of other packages also in the list. Of the remaining 8 packages, 5 contained `rb_gc_register_address` calls that were _only_ within `Init_` functions. These would be unaffected by the change. The remaining 3 are [`oj`](https://rubygems.org/gems/oj), [`ox`](https://rubygems.org/gems/ox), and [`stack_trace`](https://rubygems.org/gems/stack_trace). Given the infrequency of this usage in real world Ruby applications. Is it acceptable to restrict `rb_gc_register_address` to the main Ractor only, document it's restricted usage, and patch the affected gems upstream? We'd like to discuss this, and any potential alternative solutions. -- https://bugs.ruby-lang.org/
participants (3)
-
eightbitraptor (Matt V-H) -
Eregon (Benoit Daloze) -
jhawthorn (John Hawthorn)