Skip to content

Give each Ractor its own WeakMap - #94

Open
ko1 wants to merge 1 commit into
ruby:masterfrom
ko1:weakref-ractor-map
Open

Give each Ractor its own WeakMap#94
ko1 wants to merge 1 commit into
ruby:masterfrom
ko1:weakref-ractor-map

Conversation

@ko1

@ko1 ko1 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes https://bugs.ruby-lang.org/issues/22105.

require "weakref"
Ractor.new { WeakRef.new("asdf") }.join
#=> can not read non-shareable class variable @@__map from non-main Ractors (WeakRef) (Ractor::IsolationError)

Why the map has to be per-Ractor

The shared @@__map cannot simply be made shareable:

  • ObjectSpace::WeakMap cannot be made shareable (Ractor.make_shareable raises Ractor::Error), and it is not thread safe.
  • Even with a shareable WeakMap it would not work, because the values of this map are the referenced objects, which are usually not shareable.

The referent belongs to a Ractor, so the map that holds it has to belong to the same Ractor. One map per Ractor is the granularity that Ractor isolation asks for.

What this does

Each Ractor gets its own ObjectSpace::WeakMap, and each WeakRef keeps that map in @map. Holding it in an ivar does two things:

  1. A lookup is one ivar read rather than a Ractor-local storage lookup. Without it, __getobj__ is about 3x slower (see below).
  2. It makes the WeakRef itself non-shareable and non-movable. That is the behaviour we want, since its entry only exists in the map of the Ractor that created it.

Point 2 also closes an existing hole: today a WeakRef has no instance variables on the weak path, so Ractor.make_shareable(ref) succeeds and produces a "shareable" object whose __getobj__ works in one Ractor and not in another. (Delegator#freeze freezes __getobj__ too, but only shallowly, so Ractor.shareable?(ref) == true while Ractor.shareable?(target) == false is reachable today.) With this change make_shareable is refused.

Ractor.store_if_absent is Ruby 3.4 and later, so 3.0 to 3.3 use Ractor.current[]. Two threads of one Ractor can race there and each build a map, but that only leaves one empty map behind, since every WeakRef keeps the map it registered itself in (checked by forcing the two-map state). Rubies without Ractor keep the single map they have today. The test suite passes on 3.1.6, 3.3.2, 3.4.4 and 4.0.2, and the no-Ractor path was checked on JRuby 10.

Cost

Measured with perf stat -e instructions:u (the machine was shared, so wall clock was not usable; a copy of the unmodified file was measured alongside as a control and agreed within 0.13%). 200k live WeakRefs, ruby master.

WeakRef.new __getobj__ weakref_alive? full GC memory
current 5198 instr 1089 instr 825 instr 125.6 M instr 75.6 B/ref
control (copy of current) +0.1% +0% +0% -0.2% same
this PR +9.4% -8.0% -10.8% +9.6% +0%
  • Memory is unchanged: the @map ivar fits in the embedded object slot.
  • Dereferencing gets faster, because an ivar read is cheaper than a class variable read.
  • WeakRef.new costs 9.4% more instructions: 5.3% is Ractor.store_if_absent itself and 3.8% is the ::WeakRef.__map__ call.
  • Full GC with 200k live WeakRefs costs 9.6% more, because there is one more reference to mark per WeakRef (about 60 instructions each).

For comparison, on the same harness #88 (a WeakMap per WeakRef) costs +213% on WeakRef.new, +230% memory (249 B/ref vs 76) and +51% on full GC.

Note on #88 and Ractor#value

While comparing, I found that ObjectSpace::WeakMap can neither be copied nor moved between Ractors (Ractor#send refuses with can not copy ObjectSpace::WeakMap object.), so a per-instance map does not actually let a WeakRef move between Ractors. Separately, Ractor#value lets a WeakMap through but empties it:

r = Ractor.new { m = ObjectSpace::WeakMap.new; k = Object.new; m[k] = "v"; [m, m.size] }
got, n_inside = r.value
n_inside  #=> 1
got.size  #=> 0

That means Ractor.new { WeakRef.new(obj) }.value returns a WeakRef that looks recycled under #88. That looks like a separate bug on the WeakMap side and I will report it separately.

WeakRef held one process-wide `@@__map`, so creating a WeakRef in a
non-main Ractor raised Ractor::IsolationError:

    require "weakref"
    Ractor.new { WeakRef.new("asdf") }.join
    #=> can not read non-shareable class variable @@__map from
    #   non-main Ractors (WeakRef) (Ractor::IsolationError)

The map cannot simply be made shareable: its values are the referenced
objects, which are usually not shareable, and ObjectSpace::WeakMap is
neither shareable nor thread safe. The map has to follow the Ractor that
owns the referent, so each Ractor now gets its own.

Each WeakRef keeps its Ractor's map in @Map. That makes lookups a single
ivar read instead of a Ractor-local storage lookup, and it makes the
WeakRef itself non-shareable and non-movable, which is what we want: its
entry only exists in the map of the Ractor that created it.

Ractor.store_if_absent is Ruby 3.4 and later, so 3.0 to 3.3 use
Ractor.current[] instead. Two threads of one Ractor can race there and
each build a map, but that only leaves one empty map behind, since every
WeakRef keeps the map it registered itself in. Rubies without Ractor
keep the single map they have today.

Fixes https://bugs.ruby-lang.org/issues/22105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant