Ractor.new: use Ractor.shareable_proc semantics for the block [Feature #22444]

Previously Ractor.new isolated the given block Proc in place
(rb_proc_isolate_bang) when the ractor's thread was set up. This
mutated the caller's Proc, and rejected every outer variable access.

Now Ractor.new duplicates the block and makes the copy shareable exactly
as Ractor.shareable_proc does, before any ractor resources are
allocated. The block may read outer variables that are never
reassigned and refer to shareable objects; writes, reassigned
variables, unshareable values and yield still raise
Ractor::IsolationError. The original Proc is left unchanged (not frozen,
not shareable), and instance variables on it no longer prevent use, as
the copy does not carry them.

Procs without an iseq (e.g. method procs) keep using rb_proc_isolate.
Tests and docs are updated accordingly.
This commit is contained in:
John Hawthorn 2026-10-13 08:18:17 +00:00
parent 70c8791f42
commit 1f3e810694
Notes: git 2026-11-04 15:14:08 +00:00
8 changed files with 158 additions and 83 deletions

View File

@ -157,6 +157,11 @@ Note: We're only listing outstanding class updates.
* Ractor
* `Ractor.new` now isolates its block the way `Ractor.shareable_proc`
does: the block may read outer local variables that are never reassigned
and refer to shareable objects, instead of raising
`Ractor::IsolationError` for any outer variable. [[Feature #22444]]
* `Ractor::Port#receive`, `Ractor.receive` and `Ractor.select` accept a
`timeout:` keyword argument (in seconds) and return `nil` once it passes.
`timeout: 0` never blocks: it takes a message if one is already queued
@ -753,6 +758,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable.
[Feature #22309]: https://bugs.ruby-lang.org/issues/22309
[Feature #22400]: https://bugs.ruby-lang.org/issues/22400
[Feature #22441]: https://bugs.ruby-lang.org/issues/22441
[Feature #22444]: https://bugs.ruby-lang.org/issues/22444
[PR #17201]: https://github.com/ruby/ruby/pull/17201
[GH-psych #805]: https://github.com/ruby/psych/pull/805
[prism-v1.8.0]: https://github.com/ruby/prism/releases/tag/v1.8.0

View File

@ -100,7 +100,7 @@ File.write(<%= result.dump %>, __bmdv_ractors.shift)
private
# id is to prevent:
# can not isolate a Proc because it accesses outer variables (__bmdv_i)
# cannot make a shareable Proc because the outer variable '__bmdv_i' may be reassigned.
def while_loop(content, times, id:)
if !times.is_a?(Integer) || times <= 0
raise ArgumentError.new("Unexpected times: #{times.inspect}")

View File

@ -826,18 +826,73 @@ assert_equal 'true', %q{
end
}
# given block Proc will be isolated, so can not access outer variables.
# given block Proc is copied like Ractor.shareable_proc: it can read outer variables
# which are shareable and never reassigned
assert_equal '[42, "ok"]', %q{
a = 42
b = "ok".freeze
r = Ractor.new do
[a, b]
end
r.value
}
# ... through nested blocks and with arguments
assert_equal '[4, 8]', %q{
a = 1
r = Ractor.new(2) do |x|
v = [1].map { |y| a + x + y }.first
[v, v * 2]
end
r.value
}
# an outer variable which is reassigned can not be accessed
assert_equal 'Ractor::IsolationError', %q{
begin
a = true
r = Ractor.new do
a
end
a = false
rescue => e
e.class
end
}
# an outer variable holding an unshareable object can not be accessed
assert_equal 'Ractor::IsolationError', %q{
begin
a = []
r = Ractor.new do
a
end
rescue => e
e.class
end
}
# an outer variable can not be written
assert_equal 'Ractor::IsolationError', %q{
begin
a = 1
Ractor.new do
a = 2
end
rescue => e
e.class
end
}
# Ractor.new does not mutate the given Proc
assert_equal '[false, false, 1, 2]', %q{
pr = Proc.new { 1 }
r1 = Ractor.new(&pr)
r2 = Ractor.new(&pr)
pr.instance_variable_set(:@foo, Object.new)
[Ractor.shareable?(pr), pr.frozen?, r1.value, r2.value + 1]
}
# eval with outer locals in a Ractor raises SyntaxError
# [Bug #21522]
assert_equal 'SyntaxError', %q{
@ -1033,29 +1088,21 @@ assert_equal "can't modify instance variables of a shareable Ractor", %q{
end
}
# a Proc which already has ivars can not be isolated
assert_equal 'can not isolate a Proc because it has instance variables', %q{
pr = Proc.new{}
# a Proc which already has ivars is fine: the ractor gets a copy without them
assert_equal 'ok', %q{
pr = Proc.new{ :ok }
pr.instance_variable_set(:@iv, Object.new)
begin
Ractor.new(&pr)
rescue Ractor::IsolationError => e
e.message
end
Ractor.new(&pr).value.to_s
}
# freezing the Proc first does not skip the check
assert_equal 'can not isolate a Proc because it has instance variables', %q{
pr = Proc.new{}
# ... also when it is frozen
assert_equal 'ok', %q{
pr = Proc.new{ :ok }
pr.instance_variable_set(:@iv, Object.new)
pr.freeze
begin
Ractor.new(&pr)
rescue Ractor::IsolationError => e
e.message
end
Ractor.new(&pr).value.to_s
}
# an ivar set by Proc#refined is internal, so it does not block isolation
@ -1086,23 +1133,19 @@ assert_equal 'ok', %q{
end.value.to_s
}
# a Proc made shareable by Ractor.new can not be given ivars afterwards
assert_equal "can't modify instance variables of a shareable Proc", %q{
# Ractor.new does not make the given Proc shareable, so it can still be given ivars
assert_equal "false", %q{
HAX = -> { }
Ractor.new(&HAX).join
begin
HAX.instance_variable_set(:@foo, Object.new)
rescue Ractor::IsolationError => e
e.message
end
HAX.instance_variable_set(:@foo, Object.new)
Ractor.shareable?(HAX).to_s
}
# freezing a shareable object from another Ractor can not expose an ivar, since
# none could be set after it became shareable
assert_equal 'nil', %q{
HAX = -> { }
Ractor.new(&HAX).join
HAX = Ractor.shareable_lambda { }
r = Ractor.new do
HAX.freeze
@ -3198,35 +3241,6 @@ assert_equal 'ok', %q{
r.value
}
# A Ractor creation that fails (IsolationError) after the child objspace exists must clean up
# the creator's cover for it; otherwise a later global GC enumerates the dead child's objspace
# twice and reads the freed shell.
assert_equal 'ok', %q{
x = 42 # capturing an outer local makes Ractor.new raise IsolationError
worker = Ractor.new { loop { break if Ractor.receive == :quit } }
begin
Ractor.new { x }
raise "isolation error did not fire"
rescue Ractor::IsolationError
end
10.times { GC.start; 500.times { Object.new } }
worker.send(:quit)
worker.value
100.times do |i|
begin
Ractor.new { x }
raise "isolation error did not fire"
rescue Ractor::IsolationError
end
if (i % 20).zero?
Ractor.new { :ok }.value
GC.start
end
end
GC.start
:ok
}
# Moving a CoW shared-root string (a frozen root with an unshareable ivar) must not steal the
# root's buffer; that would leave the remaining sharers reading freed memory.
assert_equal 'ok', %q{

View File

@ -89,15 +89,15 @@ r.name #=> 'my-first-ractor'
### Block isolation
The Ractor executes `expr` in the given block.
The given block will be isolated from its outer scope. To prevent sharing objects between ractors, outer variables, `self` and other information is isolated from the block.
The given block will be isolated from its outer scope. To prevent sharing unshareable objects between ractors, `self` and other information is isolated from the block, and the block may only read outer variables which are never reassigned and refer to shareable objects.
This isolation occurs at Ractor creation time (when `Ractor.new` is called). If the given block is not able to be isolated because of outer variables or `self`, an error will be raised.
```ruby
begin
a = true
a = []
r = Ractor.new do
a #=> Ractor::IsolationError because this block accesses outer variable `a`.
a #=> Ractor::IsolationError because this block accesses outer variable `a`, which refers to an unshareable object.
end
r.join # wait for ractor to finish
rescue Ractor::IsolationError

View File

@ -915,9 +915,28 @@ rb_ractor_main_setup(rb_vm_t *vm, rb_ractor_t *r, rb_thread_t *th)
RB_GC_GUARD(rv);
}
// Same semantics as Ractor.shareable_proc: the block is duplicated (the caller's
// Proc is never mutated), may read outer variables which are never reassigned and
// whose values are shareable, and `self` is replaced by the new Ractor when run.
static VALUE
ractor_isolated_block(VALUE block)
{
if (vm_proc_iseq(block)) {
return rb_proc_ractor_make_shareable(rb_proc_dup(block), Qnil);
}
else {
// Symbol procs, method procs, etc.: they have no outer variables.
return rb_proc_isolate(block);
}
}
static VALUE
ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block)
{
/* Make the shareable copy before anything is allocated: it may raise
* Ractor::IsolationError, and the original block is left untouched. */
block = ractor_isolated_block(block);
VALUE rv = ractor_alloc(self);
rb_ractor_t *r = RACTOR_PTR(rv);
ractor_init(r, name, loc);

View File

@ -9,16 +9,16 @@
# concerns such as data-races and race-conditions are not possible. The other benefit is parallelism.
#
# To achieve this, object sharing is limited across ractors.
# Unlike in threads, ractors can't access all the objects available in other ractors. For example, objects normally
# available through variables in the outer scope are prohibited from being used across ractors.
# Unlike in threads, ractors can't access all the objects available in other ractors. For example, unshareable
# objects available through variables in the outer scope are prohibited from being used across ractors.
#
# a = 1
# a = []
# r = Ractor.new {puts "I am in Ractor! a=#{a}"}
# # fails immediately with
# # Ractor::IsolationError (can not isolate a Proc because it accesses outer variables (a).)
# # Ractor::IsolationError (cannot make a shareable Proc because it can refer unshareable object [] from variable 'a')
#
# The object must be explicitly shared:
# a = 1
# a = []
# r = Ractor.new(a) { |a1| puts "I am in Ractor! a=#{a1}"}
#
# On CRuby (the default implementation), the Global Virtual Machine Lock (GVL) is held per ractor, so
@ -199,7 +199,8 @@ class Ractor
#
# Creates a new \Ractor with args and a block.
#
# The given block (Proc) is isolated (can't access any outer variables). +self+
# The given block (Proc) is isolated (can only read outer variables which are
# never reassigned and refer to shareable objects). +self+
# inside the block will refer to the current \Ractor.
#
# r = Ractor.new { puts "Hi, I am #{self.inspect}" }

View File

@ -813,22 +813,56 @@ class TestRactor < Test::Unit::TestCase
RUBY
end
def test_ractor_new_raises_isolation_error_if_outer_variables_are_accessed
assert_raise(Ractor::IsolationError) do
def test_ractor_new_allows_reading_shareable_outer_variables
assert_ractor(<<~'RUBY')
channel = Ractor::Port.new
Ractor.new(channel) do
r = Ractor.new(channel) do
inbound_work = Ractor::Port.new
channel << inbound_work
end
end
assert_kind_of Ractor::Port, channel.receive
r.join
RUBY
end
def test_ractor_new_raises_isolation_error_if_unshareable_outer_variables_are_accessed
assert_ractor(<<~'RUBY')
assert_raise_with_message(Ractor::IsolationError, /unshareable object \[\]/) do
list = []
Ractor.new { list }
end
RUBY
end
def test_ractor_new_raises_isolation_error_if_outer_variables_are_reassigned
assert_ractor(<<~'RUBY')
assert_raise_with_message(Ractor::IsolationError, /'n' may be reassigned/) do
n = 1
Ractor.new { n }
n = 2
end
RUBY
end
def test_ractor_new_does_not_modify_the_block
assert_ractor(<<~'RUBY')
pr = proc { 42 }
assert_equal 42, Ractor.new(&pr).value
assert_not_predicate pr, :frozen?
assert_not_operator Ractor, :shareable?, pr
assert_equal 42, Ractor.new(&pr).value
RUBY
end
def test_ractor_new_raises_isolation_error_if_proc_uses_yield
assert_raise(Ractor::IsolationError) do
Ractor.new do
yield
assert_ractor(<<~'RUBY')
def ractor_with_yield
Ractor.new do
yield
end
end
end
assert_raise(Ractor::IsolationError) { ractor_with_yield }
RUBY
end
def test_ractor_does_not_inherit_fiber_storage
@ -919,19 +953,20 @@ class TestRactor < Test::Unit::TestCase
RUBY
end
# A Ractor.new that fails with IsolationError (stillborn) must still clean up the
# half-created objspace (regression guard for double enumeration / use-after-free).
# A Ractor.new that fails with an uncopyable argument (stillborn) must still clean
# up the half-created objspace (regression guard for double enumeration /
# use-after-free).
def test_stillborn_ractor_gc
assert_ractor(<<~'RUBY', timeout: 60)
x = 42 # capturing an outer local makes Ractor.new raise IsolationError
x = Thread.current # an uncopyable argument makes Ractor.new raise Ractor::Error
worker = Ractor.new { loop { break if Ractor.receive == :quit } }
assert_raise(Ractor::IsolationError) { Ractor.new { x } }
assert_raise(Ractor::Error) { Ractor.new(x) { } }
10.times { GC.start; 500.times { Object.new } }
GC.verify_internal_consistency
worker.send(:quit)
worker.value
100.times do |i|
assert_raise(Ractor::IsolationError) { Ractor.new { x } }
assert_raise(Ractor::Error) { Ractor.new(x) { } }
if (i % 20).zero?
Ractor.new { :ok }.value
GC.start
@ -947,7 +982,7 @@ class TestRactor < Test::Unit::TestCase
# built by another thread of the same Ractor: a stillborn child frees that objspace.
def test_stillborn_ractor_with_free_off_gvl
assert_ractor(<<~'RUBY', require: '-test-/gvl/call_without_gvl', timeout: 60)
x = 42 # capturing an outer local makes Ractor.new raise IsolationError
x = Thread.current # an uncopyable argument makes Ractor.new raise Ractor::Error
stop = false
ready = Queue.new
freers = 4.times.map do
@ -959,8 +994,8 @@ class TestRactor < Test::Unit::TestCase
freers.size.times { ready.pop } # all of them are churning before we start
2_000.times do
begin
Ractor.new { x }
rescue Ractor::IsolationError
Ractor.new(x) { }
rescue Ractor::Error
end
end
stop = true

View File

@ -942,7 +942,7 @@ thread_create_core(VALUE thval, struct thread_create_params *params)
th->ec->ractor_id = rb_ractor_id(th->ractor);
th->ec->serial = th->ractor->next_ec_serial++;
th->ractor->threads.main = th;
th->invoke_arg.proc.proc = rb_proc_isolate_bang(params->proc, Qnil);
th->invoke_arg.proc.proc = params->proc; // already a shareable copy (see ractor_create)
th->invoke_arg.proc.args = INT2FIX(RARRAY_LENINT(params->args));
th->invoke_arg.proc.kw_splat = rb_keyword_given_p();
break;
@ -1197,7 +1197,7 @@ rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc)
VALUE thval = create_ractor_alloc_thread(r, cr, ec);
/* Creation can still fail before vm_insert_ractor (an IsolationError, say), and a
/* Creation can still fail before vm_insert_ractor (NoMemoryError, say), and a
* left-over cover would enumerate the dead child's objspace twice and dangle after
* the merge: on failure hand the objspace to zombie_objspaces under the VM lock,
* drop the cover, NULL r->objspace. */