From: "luke-gru (Luke Gruber) via ruby-core" Date: 2026-10-20T14:28:59+00:00 Subject: [ruby-core:127184] [Ruby Bug#22457] Implicit Ractor shareability for frozen objects is buggy Issue #22457 has been reported by luke-gru (Luke Gruber). ---------------------------------------- Bug #22457: Implicit Ractor shareability for frozen objects is buggy https://bugs.ruby-lang.org/issues/22457 * Author: luke-gru (Luke Gruber) * Status: Open * Assignee: ractor * Target version: 4.1 * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ## Background To share objects between Ractors without passing them explicitly with `Ractor#send`, you can use shareable objects. Certain objects are always shareable (such as nil, true, false, Symbols, etc) but most aren't. To make an objects shareable, you can use `Ractor.make_shareable`. This freezes the object recursively and makes it possible to be shared across Ractors. To make it easier for developers, we also made it possible to just `#freeze` your object, and if your object passes all shareability rules (all its references are also frozen), then it can also be shared. For example: ```ruby ALPHA = [:A, :B, :C].freeze r = Ractor.new do puts ALPHA # this works, it can be accessed end.join ``` On the other hand, this results in an error: ```ruby ALPHA = ('a'..'z').to_a.freeze r = Ractor.new do puts ALPHA # The strings aren't frozen, so we get Ractor::IsolationError during access end.join ``` ## The Issue The way implicit shareability is implemented is that the Ractor that's doing the access first checks if the object has a shareable flag. If it does then it can access it, otherwise it traverses the object to check shareability for all references and sets the shareable flag once it determines the object is shareable (so it doesn't need to traverse again during the next lookup). There are a few issues with this approach: 1) Setting the shareable flag sets a bit in a bitmap on the heap page of the other Ractor. This can race with other bits being set by this Ractor if it's running code or doing GC. 2) Traversing the other Ractor's object graph can mutate the objects internally (such as calling `str_make_independent`). If the owning Ractor is using this object, then it can race (and crash). ## Example For an example of a crash showing issue 2: ```ruby # Repro: a non-owner Ractor reading a class ivar runs the full shareability traversal # on the owner's object graph (rb_ivar_lookup -> rb_ractor_shareable_p -> # rb_obj_traverse -> mark_shareable -> rb_str_make_independent). Several readers do it # at once on the same STR_SHARED strings, racing in str_make_independent_expand: # one clears STR_SHARED and installs a new heap.ptr while another, holding the old # pointer, re-tests the flags and frees it. N_STR = (ENV['N_STR'] || 20_000).to_i N_RACT = (ENV['N_RACT'] || 8).to_i ROUNDS = (ENV['ROUNDS'] || 50).to_i BASE = ("abcdefghij" * 200).freeze # long enough that substrings are STR_SHARED ROUNDS.times do |round| k = Class.new do class << self; attr_accessor :v; end end Object.const_set(:"C#{round}", k) k.v = Array.new(N_STR) { |i| BASE[(i % 100)..].freeze }.freeze start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.05 rs = N_RACT.times.map do Ractor.new(k, start) do |kls, t0| nil while Process.clock_gettime(Process::CLOCK_MONOTONIC) < t0 a = kls.v # <- traverses + promotes the owner's graph a.size + a[0].length end end rs.each(&:value) $stderr.print "." if round % 10 == 0 end $stderr.puts "\nsurvived #{ROUNDS} rounds" ``` ## Possible Fixes 1) We can remove implicit shareability for deeply frozen objects. If an object isn't shareable, access from another Ractor raises `Ractor::IsolationError`. Users must call `Ractor.make_shareable` explicitly. This is a backwards incompatible change. 2) We can do the shareability traversal check and set in the owning Ractor's `#freeze`. This would only need to be done for objects that are directly pointed to by shareable objects like classes/modules. This allows the checking Ractor to simply check the shareable flag on the object's flags (FL_SHAREABLE). For example: ```ruby class C @iv = ["iv".freeze].freeze # During ivar set of a class, if the object is frozen, we do the shareability check graph walk end ``` This means that each time a class/module sets an ivar, a cvar or a constant and the object is frozen, we need to walk all references (until we see a non-shareable object). This slows down these operations, but there may not be many of them and most of them are probably on boot. This also increases the number of shareable objects, because previously frozen objects are now set frozen + shareable. This makes no difference in single-ractor mode, but possibly causes more global GCs in multi-ractor mode. 3) The checking Ractor blocks on read of a non-shareable, the owning Ractor performs the check then unblocks the checking Ractor with the result of the object's shareability. There are complications here because of global GC and the asynchronous nature of the message passing. It could also be slow if this continues to happen after boot. However, the "check shareability on read" semantics are preserved and it doesn't have the problem of making more objects shareable than we have to. ## Code I have a [PR](https://github.com/ruby/ruby/pull/19283) that implements solution 2 and @jhawthorn is working on implementing solution 3. ## Feedback Let us know how you think this implicit shareability check should work or if we should even have it to begin with. -- 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/