Give each Ractor its own WeakMap - #94
Open
ko1 wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes https://bugs.ruby-lang.org/issues/22105.
Why the map has to be per-Ractor
The shared
@@__mapcannot simply be made shareable:ObjectSpace::WeakMapcannot be made shareable (Ractor.make_shareableraisesRactor::Error), and it is not thread safe.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 eachWeakRefkeeps that map in@map. Holding it in an ivar does two things:__getobj__is about 3x slower (see below).WeakRefitself 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
WeakRefhas no instance variables on the weak path, soRactor.make_shareable(ref)succeeds and produces a "shareable" object whose__getobj__works in one Ractor and not in another. (Delegator#freezefreezes__getobj__too, but only shallowly, soRactor.shareable?(ref) == truewhileRactor.shareable?(target) == falseis reachable today.) With this changemake_shareableis refused.Ractor.store_if_absentis Ruby 3.4 and later, so 3.0 to 3.3 useRactor.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 withoutRactorkeep 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-Ractorpath 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?@mapivar fits in the embedded object slot.WeakRef.newcosts 9.4% more instructions: 5.3% isRactor.store_if_absentitself and 3.8% is the::WeakRef.__map__call.For comparison, on the same harness #88 (a
WeakMapperWeakRef) costs +213% onWeakRef.new, +230% memory (249 B/ref vs 76) and +51% on full GC.Note on #88 and
Ractor#valueWhile comparing, I found that
ObjectSpace::WeakMapcan neither be copied nor moved between Ractors (Ractor#sendrefuses withcan not copy ObjectSpace::WeakMap object.), so a per-instance map does not actually let aWeakRefmove between Ractors. Separately,Ractor#valuelets aWeakMapthrough but empties it:That means
Ractor.new { WeakRef.new(obj) }.valuereturns a WeakRef that looks recycled under #88. That looks like a separate bug on theWeakMapside and I will report it separately.