Bug #22457
openImplicit Ractor shareability for frozen objects is buggy
Description
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:
On the other hand, this results in an error:
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:
-
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.
-
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:
# 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¶
-
We can remove implicit shareability for deeply frozen objects. If an object isn't shareable, access from another Ractor raises
Ractor::IsolationError. Users must callRactor.make_shareableexplicitly. This is a backwards incompatible change. -
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:
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.
- 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 that implements solution 2 and @jhawthorn (John Hawthorn) 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.
Updated by luke-gru (Luke Gruber) about 4 hours ago
- Related to Bug #22419: [BUG] "rb_objspace_reachable_objects_from_local: set is owned by another Ractor" when a non-main Ractor reads a class instance variable since f0439cdfa7 added