Feature #22277
openMake `rb_gc_register_address` Ractor-local
Description
Summary¶
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, ox, and 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.
Updated by eightbitraptor (Matt V-H) 3 days ago
- Description updated (diff)