Enable incremental marking when the process has > 1 objspace

Previously, major GC incremental marking was disabled (even on the main Ractor)
when more than 1 Ractor was running. By changing the WB implementation, we now
have it back.
This commit is contained in:
Luke Gruber authored and Luke Gruber committed 2026-09-24 13:54:45 -04:00
1 parent 4133daa130
commit 24be314b45
11 files changed
+780 -54
Notes: git 2026-09-24 17:55:16 +00:00

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
#include "ruby.h"
#include "internal/gc.h"
/* GC.disable continues the cycle in progress (rb_gc_disable calls rb_gc_impl_gc_rest), so it
* cannot guard a window that has to stay mid-mark. These expose the other variant: it suppresses
* collection for the calling Ractor's objspace alone and leaves an incremental cycle running.
* Used by the ractor-local incremental marking tests. */
static VALUE
gc_local_disable_no_rest(VALUE self)
{
return rb_gc_local_disable_no_rest();
}
static VALUE
gc_local_enable(VALUE self)
{
return rb_gc_local_enable();
}
void
Init_disable(void)
{
rb_ext_ractor_safe(true);
VALUE mBug = rb_define_module("Bug");
VALUE mGC = rb_define_module_under(mBug, "GC");
rb_define_singleton_method(mGC, "local_disable_no_rest", gc_local_disable_no_rest, 0);
rb_define_singleton_method(mGC, "local_enable", gc_local_enable, 0);
}
+3
View File
@@ -0,0 +1,3 @@
# frozen_string_literal: false
require_relative "../../auto_ext.rb"
auto_ext(inc: true)
+3
View File
@@ -0,0 +1,3 @@
# frozen_string_literal: false
require_relative "../../auto_ext.rb"
auto_ext(inc: true)
+78
View File
@@ -0,0 +1,78 @@
#include "ruby.h"
#include "internal/gc.h"
struct wb_box {
VALUE child;
};
static void
wb_box_mark(void *ptr)
{
struct wb_box *box = ptr;
rb_gc_mark(box->child);
}
static size_t
wb_box_memsize(const void *ptr)
{
return ptr ? sizeof(struct wb_box) : 0;
}
static const rb_data_type_t wb_box_type = {
"Bug::GC::WriteBarrier::Box",
{wb_box_mark, RUBY_TYPED_DEFAULT_FREE, wb_box_memsize,},
NULL, NULL,
RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_FROZEN_SHAREABLE,
};
static VALUE
wb_box_alloc(VALUE klass)
{
struct wb_box *box;
VALUE obj = TypedData_Make_Struct(klass, struct wb_box, &wb_box_type, box);
box->child = Qnil;
return obj;
}
/* No rb_check_frozen: the point of this box is to keep storing after
* Ractor.make_shareable froze it, so a test can build a shareable -> unshareable edge
* across objspaces. */
static VALUE
wb_box_store(VALUE self, VALUE child)
{
struct wb_box *box;
TypedData_Get_Struct(self, struct wb_box, &wb_box_type, box);
RB_OBJ_WRITE(self, &box->child, child);
return child;
}
static VALUE
wb_box_child(VALUE self)
{
struct wb_box *box;
TypedData_Get_Struct(self, struct wb_box, &wb_box_type, box);
return box->child;
}
static VALUE
wb_remember(VALUE self, VALUE obj)
{
rb_gc_writebarrier_remember(obj);
return obj;
}
void
Init_writebarrier(void)
{
rb_ext_ractor_safe(true);
VALUE mBug = rb_define_module("Bug");
VALUE mGC = rb_define_module_under(mBug, "GC");
VALUE mWB = rb_define_module_under(mGC, "WriteBarrier");
rb_define_singleton_method(mWB, "remember", wb_remember, 1);
VALUE cBox = rb_define_class_under(mWB, "Box", rb_cObject);
rb_define_alloc_func(cBox, wb_box_alloc);
rb_define_method(cBox, "store", wb_box_store, 1);
rb_define_method(cBox, "child", wb_box_child, 0);
}
+27 -2
View File
@@ -235,6 +235,13 @@ rb_gc_event_hook(VALUE obj, rb_event_flag_t event)
#endif
}
/* Nonzero once the cleanup walk has stashed an objspace, which is the only case where a
* thread needs one other than its own Ractor's. One unsynchronised load gates it,
* keeping the per-reference marking path and the malloc paths clear of the lookups
* below. It is never unwound, so a stale zero only costs a resolution that predates the
* stash. */
static rb_atomic_t gc_objspace_override_count;
/* VM destruct's free-at-exit walk can free the thread and Ractor structs first, so
* resolving through the current Ractor would use freed memory; return the objspace
* stashed before the walk started. */
@@ -243,11 +250,19 @@ void
rb_gc_stash_cleanup_objspace(void)
{
GET_VM()->gc.cleanup_objspace = rb_gc_get_objspace();
/* Never unwound, as the VM is going away: from here every resolution takes the slow
* path, which is where the stashed objspace is returned from. */
RUBY_ATOMIC_INC(gc_objspace_override_count);
}
static inline void *
gc_current_objspace_of(rb_ractor_t *const cr)
/* Out of line to keep the rare arms out of RB_GC_MARK_OR_TRAVERSE's inline expansions. */
NOINLINE(static void *gc_current_objspace_slow(rb_ractor_t *const cr));
static void *
gc_current_objspace_slow(rb_ractor_t *const cr)
{
/* Checked before anything reads cr or the EC: the cleanup walk is the case where
* both may already be freed (see rb_gc_stash_cleanup_objspace). */
if (RB_UNLIKELY(ruby_vm_during_cleanup) && GET_VM()->gc.cleanup_objspace) {
return GET_VM()->gc.cleanup_objspace;
}
@@ -261,6 +276,16 @@ gc_current_objspace_of(rb_ractor_t *const cr)
return cr->objspace;
}
static inline void *
gc_current_objspace_of(rb_ractor_t *const cr)
{
if (RB_LIKELY(cr != NULL && gc_objspace_override_count == 0)) {
RUBY_ASSERT(cr->objspace != NULL);
return cr->objspace;
}
return gc_current_objspace_slow(cr);
}
void *
rb_gc_get_objspace(void)
{
+120 -47
View File
@@ -4242,17 +4242,32 @@ gc_finalize_deferred_register(rb_objspace_t *objspace)
static int pop_mark_stack(mark_stack_t *stack, VALUE *data);
/* Throw away an unfinished incremental mark and leave the objspace in gc_mode_none. The
* mark bits the partial mark set stay behind, so the caller must clear them before the
* heap is collected again. */
static void
gc_abort_incremental_marking(rb_objspace_t *objspace)
{
GC_ASSERT(is_incremental_marking(objspace));
VALUE obj;
while (pop_mark_stack(&objspace->mark_stack, &obj));
/* gc_grey records the weak references it greys for gc_marks_finish to resolve; this
* cycle never reaches it, and the entries would outlive their objects. */
rb_darray_clear(objspace->weak_references);
objspace->flags.during_incremental_marking = FALSE;
gc_mode_set(objspace, gc_mode_none);
}
static void
gc_abort(void *objspace_ptr)
{
rb_objspace_t *objspace = objspace_ptr;
if (is_incremental_marking(objspace)) {
/* Remove all objects from the mark stack. */
VALUE obj;
while (pop_mark_stack(&objspace->mark_stack, &obj));
objspace->flags.during_incremental_marking = FALSE;
gc_abort_incremental_marking(objspace);
}
if (is_lazy_sweeping(objspace)) {
@@ -6879,6 +6894,10 @@ check_color_i(const VALUE child, void *ptr)
struct verify_internal_consistency_struct *data = (struct verify_internal_consistency_struct *)ptr;
const VALUE parent = data->parent;
/* This cycle never marks a foreign child (gc_skip_foreign_object_p) and the write
* barrier is a no-op across objspaces, so its colour says nothing here. */
if (GET_HEAP_OBJSPACE(child) != data->objspace) return;
if (!RVALUE_WB_UNPROTECTED(data->objspace, parent) && RVALUE_WHITE_P(data->objspace, child)) {
fprintf(stderr, "verify_internal_consistency_reachable_i: WB miss (B->W) - %s -> %s\n",
rb_obj_info(parent), rb_obj_info(child));
@@ -7524,7 +7543,13 @@ gc_marks_finish(rb_objspace_t *objspace)
* neither (another objspace may hold them). Running after the full walk makes the
* pin count a retention metric: an upper bound on the garbage only a global GC can
* reclaim. A global GC's exact mark does not pin. (The allrefs comparison of
* RGENGC_CHECK_MODE >= 4 does not model these pins; it reports false positives.) */
* RGENGC_CHECK_MODE >= 4 does not model these pins; it reports false positives.)
*
* Running it here rather than with the other roots is also what lets an incremental
* mark run while other objspaces exist. The write barrier bails out on a
* cross-objspace edge, so a store made between two mark steps leaves nothing behind
* but a shareable or shref bit; scanning those bitmaps after the last step picks up
* every bit set during the cycle, which a scan at gc_marks_start would miss. */
objspace->last_cycle_pinned = 0;
if (!rb_gc_single_objspace_p() && !objspace->flags.during_global_gc) {
objspace->last_cycle_pinned = 1;
@@ -8215,6 +8240,7 @@ gc_bitmaps_clear(rb_objspace_t *objspace, rb_heap_t *heap, bool clear_shref)
NOINLINE(static void gc_writebarrier_generational(VALUE a, VALUE b, rb_objspace_t *objspace));
/* Precondition: `a` and `b` live in `objspace`. */
static void
gc_writebarrier_generational(VALUE a, VALUE b, rb_objspace_t *objspace)
{
@@ -8251,6 +8277,7 @@ gc_mark_from(rb_objspace_t *objspace, VALUE obj, VALUE parent)
NOINLINE(static void gc_writebarrier_incremental(VALUE a, VALUE b, rb_objspace_t *objspace));
/* Precondition: `a` and `b` live in `objspace`. */
static void
gc_writebarrier_incremental(VALUE a, VALUE b, rb_objspace_t *objspace)
{
@@ -8303,13 +8330,15 @@ rb_gc_impl_writebarrier(void *objspace_ptr, VALUE a, VALUE b)
}
}
retry:
if (!is_incremental_marking(objspace)) {
/* The generational barrier covers old->young edges within one objspace only; a
* foreign a or b has age bits another objspace mutates, unsafe to read, so check
* locality first when multi-Ractor (a foreign a is shareable and the shref above
* already keeps b alive). With a single Ractor nothing is foreign. */
if ((rb_gc_multi_ractor_p() &&
/* The generational barrier covers old->young edges within one objspace only.
* NOTE: we shouldn't even check the age of `a` or `b` if they are in another
* objspace, so check locality first. The test is rb_gc_ever_multi_ractor_p, not
* rb_gc_multi_ractor_p: a foreign objspace exists before the process is
* multi-Ractor (rb_gc_objspace_alloc runs while the creator is still the only
* Ractor) and outlives the return to one (a fork parks the others in
* zombie_objspaces). */
if ((rb_gc_ever_multi_ractor_p() &&
(GET_HEAP_OBJSPACE(a) != objspace || GET_HEAP_OBJSPACE(b) != objspace)) ||
!RVALUE_OLD_P(objspace, a) || RVALUE_OLD_P(objspace, b)) {
// do nothing
@@ -8319,17 +8348,15 @@ rb_gc_impl_writebarrier(void *objspace_ptr, VALUE a, VALUE b)
}
}
else {
/* Slow path, no lock: incremental marking only runs while the process has a single
* objspace, so the owning Ractor's GVL already serializes this barrier against its
* own GC. */
if (is_incremental_marking(objspace)) {
gc_writebarrier_incremental(a, b, objspace);
// Shareable objects from different object spaces are kept alive by shareable bits
if (rb_gc_ever_multi_ractor_p() &&
(GET_HEAP_OBJSPACE(a) != objspace || GET_HEAP_OBJSPACE(b) != objspace)) {
// do nothing
}
else {
goto retry;
gc_writebarrier_incremental(a, b, objspace);
}
}
return;
}
void
@@ -8338,6 +8365,7 @@ rb_gc_impl_obj_became_shareable(void *objspace_ptr, VALUE obj)
/* An object becomes shareable on its owner thread, so this page update is
* single-writer. */
struct heap_page *page = GET_HEAP_PAGE(obj);
if (_MARKED_IN_BITMAP(page->shareable_bits, page, obj)) return;
gc_page_add_shareable(page, obj);
@@ -8409,23 +8437,26 @@ rb_gc_impl_active_gc_name(void)
return "default";
}
/* NOTE: `obj` doesn't necessarily live in `objspace_ptr`, as `objspace_ptr` is just that
* of the current Ractor. */
void
rb_gc_impl_writebarrier_remember(void *objspace_ptr, VALUE obj)
{
rb_objspace_t *objspace = objspace_ptr;
gc_report(1, objspace, "rb_gc_writebarrier_remember: %s\n", rb_obj_info(obj));
/* No lock, for the same reason as rb_gc_impl_writebarrier: remembering is an atomic
* bitmap set, and the incremental branch only runs with a single objspace, where the
* Ractor's GVL serializes it against its own GC. */
if (is_incremental_marking(objspace)) {
if (RVALUE_BLACK_P(objspace, obj)) {
gc_grey(objspace, obj);
// Shareable objects from other object spaces don't need to be put on the remembered set
// and are only collected during global GC, so not while incremental marking.
if (RB_LIKELY(!rb_gc_ever_multi_ractor_p() || GET_HEAP_OBJSPACE(obj) == objspace)) {
gc_report(1, objspace, "rb_gc_writebarrier_remember: %s\n", rb_obj_info(obj));
if (is_incremental_marking(objspace)) {
if (RVALUE_BLACK_P(objspace, obj)) {
gc_grey(objspace, obj);
}
}
else if (RVALUE_OLD_P(objspace, obj)) {
rgengc_remember(objspace, obj);
}
}
else if (RVALUE_OLD_P(objspace, obj)) {
rgengc_remember(objspace, obj);
}
}
@@ -8783,11 +8814,7 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global)
if (objspace->flags.dont_incremental ||
reason & GPR_FLAG_IMMEDIATE_MARK ||
ruby_gc_stressful ||
/* No incremental marking while multiple objspaces exist: between steps another
* Ractor can create and share objects behind this objspace's already-scanned
* roots. */
!rb_gc_single_objspace_p()) {
ruby_gc_stressful) {
objspace->flags.during_incremental_marking = FALSE;
}
else {
@@ -9486,8 +9513,6 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a
gc_event_hook(driver, RUBY_INTERNAL_EVENT_GC_START);
gc_prof_timer_start(driver);
GC_ASSERT(is_mark_stack_empty(&driver->mark_stack));
gc_global_snapshot_objspaces();
/* Mark every objspace as in a global GC before step 3 settles the lazy sweeps: the
@@ -9515,9 +9540,14 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a
* thread runs every objspace's phases. */
for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) {
rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i];
/* No objspace can be mid-incremental-mark here: that only runs single-objspace
* and vm_insert_ractor0 settles it on the transition. Clearing flags in step 5
* under a live gray stack would break the owner's GC state machine. */
/* The barrier can stop a Ractor between the steps of its own incremental mark, and
* nothing else finishes another objspace's mark. Drop the partial mark rather
* than clear its flags in step 5 under a live gray stack: the owner would resume
* with a mark stack into a heap this GC has since re-marked and swept. Nothing is
* lost, the unified mark below redoes the work. */
if (is_incremental_marking(objspace)) {
gc_abort_incremental_marking(objspace);
}
GC_ASSERT(!is_incremental_marking(objspace));
GC_ASSERT(is_mark_stack_empty(&objspace->mark_stack));
rb_gc_initialize_vm_context(&objspace->vm_context);
@@ -9538,6 +9568,7 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a
objspace->rgengc.uncollectible_wb_unprotected_objects = 0;
objspace->rgengc.old_objects = 0;
objspace->rgengc.last_major_gc = objspace->profile.count;
objspace->rgengc.need_major_gc = GPR_FLAG_NONE;
objspace->marked_slots = 0;
for (int h = 0; h < HEAP_COUNT; h++) {
rb_heap_t *heap = &heaps[h];
@@ -9771,6 +9802,38 @@ absorb_finalizer_i(st_data_t key, st_data_t val, st_data_t data)
return ST_CONTINUE;
}
static void
gc_make_mid_mark_objspace_absorbable(rb_objspace_t *src)
{
rb_objspace_t *objspace = src;
for (int h = 0; h < HEAP_COUNT; h++) {
struct heap_page *page = NULL;
ccan_list_for_each(&heaps[h].pages, page, page_node) {
short stride = page->slot_size;
uintptr_t p = (uintptr_t)page->start;
uintptr_t pend = p + page->total_slots * stride;
for (; p < pend; p += stride) {
VALUE vp = (VALUE)p;
asan_unpoisoning_object(vp) {
switch (RB_BUILTIN_TYPE(vp)) {
case T_NONE:
case T_ZOMBIE:
break;
default:
RVALUE_AGE_RESET(vp);
break;
}
}
}
}
gc_bitmaps_clear(objspace, &heaps[h], false);
}
objspace->rgengc.old_objects = 0;
objspace->rgengc.uncollectible_wb_unprotected_objects = 0;
objspace->marked_slots = 0;
}
/* Merge a dead Ractor's objspace into dst under the VM lock. src has no owner thread and
* dst is the calling thread's own objspace (join/value) or main with everyone stopped
* (global GC), so single-writer holds throughout. Pages move whole (their bits describe
@@ -9788,16 +9851,21 @@ objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src)
/* Settle dst first: adding pages under a walking lazy-sweep cursor, or into a
* half-marked incremental heap, would sweep the merged pages with src's stale mark
* bits and free live objects. (Normally settled already: vm_insert_ractor0's settle
* means no objspace is incremental while a zombie waits to be absorbed.) */
* bits and free live objects. */
gc_rest(dst);
/* Settle src: no lazy sweep and no in-progress allocation page. */
/* Settle src: no incremental marking, lazy sweep or in-progress allocation page. */
{
rb_objspace_t *objspace = src;
during_gc = TRUE;
gc_sweep_rest(objspace);
during_gc = FALSE;
/* A zombie parked by fork (rb_ractor_terminate_atfork retires the objspace
* without the retire GC) can be mid-incremental-mark, and that mark cannot be
* finished. Its owner's threads did not survive the fork, so mark_roots would
* scan the absorbing Ractor's roots instead of src's. Drop the cycle. */
if (is_incremental_marking(objspace)) {
gc_abort_incremental_marking(objspace);
gc_make_mid_mark_objspace_absorbable(objspace);
}
gc_rest(src); // if mid-sweep
heap_alloc_state_clear(objspace);
/* gc_sweep_finish leaves swept pages "pooled" for a coming incremental mark; src
* never runs one (it is about to be merged), so return them to its free list now,
@@ -9958,7 +10026,9 @@ objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src)
dst->shareable_objects += src->shareable_objects;
/* Merged pages carry src's mark/age state, so dst rebuilds its view at the next
* collection. */
* collection. Note that this can be worked around by calling `GC.config(rgengc_allow_full_mark: false)`,
* so the absorbed heap should be in a state where a minor GC would also work correctly.
*/
dst->rgengc.need_major_gc |= GPR_FLAG_MAJOR_BY_FORCE;
/* src's outstanding malloc pressure moves with the xmalloc'd buffers. Later frees are
@@ -10040,6 +10110,9 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i
* collector that reclaims shareable and cross-objspace garbage. It stops the world,
* so auto_compact is honoured here too (mirroring full mark x autocompact locally). */
if (!rb_gc_single_objspace_p() && (reason & GPR_FLAG_FULL_MARK)) {
/* A mid-cycle driver is settled by gc_start_global itself: it aborts the partial
* mark and finishes the lazy sweep, so the dead slots are T_NONE before the
* unified conservative root scan. */
gc_start_global(objspace, reason, compact || ruby_enable_autocompact, false);
}
else {
+3 -1
View File
@@ -273,6 +273,9 @@ void rb_gc_mark_values(long n, const VALUE *values);
void rb_gc_mark_vm_stack_values(long n, const VALUE *values);
void rb_gc_update_values(long n, VALUE *values);
void rb_gc_mark_set_no_pin(st_table *);
/* Exercised by the bundled -test-/gc/writebarrier extension, so it must be visible
* outside the ruby binary. */
void rb_gc_writebarrier_remember(VALUE obj);
void rb_gc_update_set_refs(st_table *);
#if USE_MODULAR_GC
@@ -303,7 +306,6 @@ rb_obj_atomic_write(
RBIMPL_CAST(rb_obj_atomic_write((VALUE)(old), (VALUE *)(slot), (VALUE)(young), __FILE__, __LINE__))
int rb_ec_stack_check(struct rb_execution_context_struct *ec);
void rb_gc_writebarrier_remember(VALUE obj);
void rb_gc_obj_became_shareable(VALUE obj);
bool rb_gc_multi_objspace_p(void);
bool rb_gc_obj_foreign_p(VALUE obj);
+3 -3
View File
@@ -548,9 +548,9 @@ vm_insert_ractor0(rb_vm_t *vm, rb_ractor_t *r, bool single_ractor_mode)
RUBY_DEBUG_LOG("r:%"PRI_SERIALT_PREFIX"u ractor.cnt:%u++", r->pub.id, vm->ractor.cnt);
VM_ASSERT(single_ractor_mode || RB_VM_LOCKED_P());
/* Incremental marking only runs in a single-objspace world, and nothing later can
* finish another objspace's partial mark, so end any cycle in progress before a
* second Ractor becomes visible. */
/* End main's cycle before a second Ractor becomes visible: the collection was
* planned for a single-objspace world (a local GC is a whole-world GC there and may
* free shareable objects), so it must not straddle the transition. */
if (vm->ractor.cnt == 1) {
rb_gc_rest();
}
+211
View File
@@ -0,0 +1,211 @@
require 'timeout'
# Shared by the ractor-local incremental marking tests in test_ractor.rb, which run
# inside assert_ractor subprocesses and load this through require_relative:.
#
# A few hundred rounds is typical; this bound means need_major_by stopped working. Each
# round runs a minor GC, so keep the bound low enough that it trips (and reports what the
# counters were doing) well before the test's own timeout fires.
MAX_MAJOR_SETUP_ROUNDS = 2_000
# Draining a freshly started major measures ~5_000 allocations.
MAX_DRAIN_ALLOCATIONS = 2_000_000
RACTOR_WAIT_TIMEOUT = 30
MARK_HEAP_ROUNDS = 5
MARK_HEAP_OBJECTS_PER_ROUND = 20_000
MARK_STEP_MIN_SLOTS = 100
# Leave this Ractor's objspace in the middle of an incremental major mark and return the
# GC state, so the caller can assert it really got there.
def start_local_incremental_major
EnvUtil.without_gc do
_retained = request_local_major_gc
GC.start(full_mark: false, immediate_mark: false)
GC.latest_gc_info(:state)
end
end
# Build a working set worth marking incrementally, for a caller that is about to call
# start_local_incremental_major. The caller has to hold on to the return value, it is only
# worth marking for as long as it stays reachable.
def retain_for_incremental_mark(rounds: MARK_HEAP_ROUNDS, per_round: MARK_HEAP_OBJECTS_PER_ROUND)
EnvUtil.without_gc do
Array.new(rounds) do
kept = []
(2 * per_round).times { |i| object = Object.new; kept << object if i.even? }
# Sweeping each round separately is what leaves the partially filled pages behind.
# One sweep at the end would find the whole set laid out in one contiguous run.
GC.start(full_mark: false)
kept
end
end
end
# Allocate until this objspace asks for a major GC, leaving nothing in progress. Returns
# the allocated objects so a caller can keep them alive across what it does next.
def request_local_major_gc
objects = []
rounds = 0
until GC.latest_gc_info(:need_major_by)
if (rounds += 1) > MAX_MAJOR_SETUP_ROUNDS
raise "no major GC requested after #{rounds} rounds " \
"(old_objects=#{GC.stat(:old_objects)}/#{GC.stat(:old_objects_limit)}, " \
"oldmalloc=#{GC.stat(:oldmalloc_increase_bytes)}/#{GC.stat(:oldmalloc_increase_bytes_limit)})"
end
objects.append(100.times.map { '*' })
GC.start(full_mark: false)
end
objects
end
# Drive the cycle in progress to completion with allocation. Returns the first state
# observed after :marking, the final one, and how many incremental mark steps the drain
# saw.
def drain_incremental_cycle
first_change = nil
mark_steps = 0
marked = GC.stat(:heap_marked_slots)
allocations = 0
loop do
if (allocations += 1) > MAX_DRAIN_ALLOCATIONS
raise "incremental cycle stuck in #{GC.latest_gc_info(:state)} after #{allocations} " \
"allocations (free_slots=#{GC.stat(:heap_free_slots)}, " \
"marked_slots=#{GC.stat(:heap_marked_slots)})"
end
Object.new
state = GC.latest_gc_info(:state)
marked, previously_marked = GC.stat(:heap_marked_slots), marked
mark_steps += 1 if marked - previously_marked > MARK_STEP_MIN_SLOTS
first_change ||= state unless state == :marking
return [first_change, state, mark_steps] if state == :none
end
end
def finish_incremental_major
_first_change, final, _mark_steps = drain_incremental_cycle
final
end
# Push the mark in progress through at least `steps` incremental steps without finishing
# it, so a caller can hand off a mark that is provably in flight and has begun draining
# its roots. Raises rather than returning if the cycle ends first: a caller asking for
# this wanted a mid-mark state, and silently getting a finished one would leave whatever
# it does next testing nothing.
def advance_incremental_mark(steps: 2)
marked = GC.stat(:heap_marked_slots)
seen = 0
allocations = 0
while seen < steps
if (allocations += 1) > MAX_DRAIN_ALLOCATIONS
raise "only #{seen} of #{steps} mark steps after #{allocations} allocations " \
"(marked_slots=#{GC.stat(:heap_marked_slots)})"
end
Object.new
state = GC.latest_gc_info(:state)
unless state == :marking
raise "cycle reached #{state} after #{seen} of #{steps} mark steps: the working " \
"set was too small to keep the mark going"
end
marked, previously_marked = GC.stat(:heap_marked_slots), marked
seen += 1 if marked - previously_marked > MARK_STEP_MIN_SLOTS
end
GC.latest_gc_info(:state)
end
# Hold off collecting this Ractor's objspace for the duration of the block without
# ending the cycle in progress (which is what GC.disable would do). Incremental marking
# still advances on page exhaustion, what this rules out is a collection started behind
# the caller's back by the allocation or malloc accounting inside the block.
def without_local_gc
already_disabled = Bug::GC.local_disable_no_rest
begin
yield
ensure
Bug::GC.local_enable unless already_disabled
end
end
# Non-blocking "did the peer die while we waited" check. Ractor.select re-raises the
# peer's exception as Ractor::RemoteError, which is the answer we want to report.
def peer_death(peer)
result = Ractor.select(peer, timeout: 0.01)
result && "terminated early with #{result.last.inspect}"
rescue Ractor::RemoteError, Ractor::Error => e
"terminated early: #{(e.cause || e).message}"
end
# Bounded Ractor.receive. None of these tests send nil, so a nil return is the timeout.
# Waiting happens in slices with a liveness check between them: a peer that died gets
# reported in well under a second, while a peer that is merely slow (these handshakes
# wait on a full incremental-major setup) still gets the whole timeout.
def receive_from(peer = nil, timeout: RACTOR_WAIT_TIMEOUT)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
slice = peer ? 0.25 : timeout
loop do
message = Ractor.receive(timeout: slice)
return message unless message.nil?
if peer and (death = peer_death(peer))
raise "#{peer.inspect} #{death}"
end
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
raise "no message received within #{timeout}s"
end
# Bounded Ractor#value. Ractor.select reports termination and re-raises the peer's
# exception as Ractor::RemoteError, so a dead peer explains itself instead of hanging.
def value_of(ractor, timeout: RACTOR_WAIT_TIMEOUT)
result = Ractor.select(ractor, timeout: timeout)
raise "#{ractor.inspect} did not terminate within #{timeout}s" if result.nil?
result.last
end
# Bound an operation whose own blocking behaviour is under test
def with_wait_bound(what, timeout: RACTOR_WAIT_TIMEOUT)
Timeout.timeout(timeout, nil, "#{what} did not finish within #{timeout}s") { yield }
end
def wait_until_single_ractor(timeout: RACTOR_WAIT_TIMEOUT)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
while Ractor.count != 1
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
raise "#{Ractor.count} Ractors still alive after #{timeout}s"
end
Thread.pass
end
end
# fork, and leave the child through exit! on every path. The child inherits
# assert_separately's at_exit, which writes a result token into the pipe the parent test
# is parsing, so an exception escaping the block would post a second <error> block and
# garble the report. Put the failure on stderr instead, which assert_separately already
# treats as a failure, and leave without running at_exit.
def fork_child
fork do
begin
yield
exit!(0)
rescue SystemExit => e
exit!(e.status)
rescue Exception => e
STDERR.puts(e.full_message(highlight: false))
exit!(1)
end
end
end
# Bounded Process.wait2
def wait_for_pid(pid, timeout: RACTOR_WAIT_TIMEOUT)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
if (waited = Process.waitpid2(pid, Process::WNOHANG))
return waited.last
end
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
Process.kill(:KILL, pid)
Process.waitpid(pid)
raise "forked child did not exit within #{timeout}s"
end
sleep 0.05
end
end
+301
View File
@@ -1238,4 +1238,305 @@ class TestRactor < Test::Unit::TestCase
assert_equal true, Ractor.new { own = Object.new; own.singleton_class.attached_object.equal?(own) }.value
RUBY
end
# The ractor-local GC tests below run through assert_separately rather than
# assert_ractor: -W0 silences the experimental-Ractor warning on its own, so the
# child does not need assert_ractor's warm-up Ractor, whose objspace would linger
# as a zombie and perturb what these tests measure.
RACTOR_GC_HELPER = File.expand_path('ractor_gc_helper.rb', __dir__)
def test_incremental_marking_in_main_and_non_main_ractors
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r-test-/gc/disable -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
_blocker = Ractor.new { Ractor.receive }
assert_equal :marking, start_local_incremental_major
# Creating a Ractor that is not the first one must leave the creator's mark running:
# vm_insert_ractor0 rests only on the 1 -> 2 transition, which _blocker consumed.
# (test_first_ractor_new_settles_creator_mid_mark covers the other side.) The local
# disable rules out a collection started behind the assertion's back by the malloc
# accounting of Ractor creation; unlike GC.disable it leaves the cycle running.
slots_before = GC.stat(:total_allocated_objects)
state_after_new = without_local_gc do
Ractor.new { Ractor.receive }
GC.latest_gc_info(:state)
end
creator_slots = GC.stat(:total_allocated_objects) - slots_before
assert_equal :marking, state_after_new
# Incremental marking still advances on page exhaustion inside that window, so the
# assertion also needs Ractor creation to cost its creator far less than draining a
# mark (~5_000 slots; measured 13). Fail on the premise if that ever changes.
assert_operator creator_slots, :<, 500
GC.start
assert_equal :none, GC.latest_gc_info(:state)
GC.verify_internal_consistency
entered, first_change, final, mark_steps, retained = value_of(Ractor.new do
working_set = retain_for_incremental_mark
child_entered = start_local_incremental_major
child_first_change, child_final, child_mark_steps = drain_incremental_cycle
[child_entered, child_first_change, child_final, child_mark_steps, working_set.sum(&:size)]
end)
assert_equal :marking, entered
assert_includes [:sweeping, :none], first_change
assert_equal :none, final
# The states above are reached whether or not the mark was incremental: a fresh Ractor
# objspace holds so little that its major marks everything in one step. The working set
# is what gives the mark enough to do to be worth stepping, so assert it really was
# stepped, and that the Ractor still held the whole set while that happened.
assert_equal MARK_HEAP_ROUNDS * MARK_HEAP_OBJECTS_PER_ROUND, retained
assert_operator mark_steps, :>=, 2, 'non-main Ractor major mark was not incremental'
RUBY
end
def test_global_gc_supersedes_ractor_local_incremental_mark
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
child = Ractor.new do
# Two WeakMaps, one of which goes unreachable, so the aborted mark has weak
# references to drop: marking a WeakMap queues it into objspace->weak_references,
# and that is the darray gc_abort_incremental_marking clears. Ruby cannot say
# whether the mark reached either map before the global GC cut it short, so this
# is best-effort coverage; the assertion below only demands that the map still
# reachable afterwards resolves its key.
weak_map = ObjectSpace::WeakMap.new
key = Object.new
weak_map[key] = :live
doomed = ObjectSpace::WeakMap.new
doomed[key] = 1
# A working set large enough that the mark has real work left, and a couple of
# steps spent on it, so what the global GC below interrupts is provably a mark in
# flight rather than one that already finished on its own.
working_set = retain_for_incremental_mark
start_local_incremental_major
Ractor.main << advance_incremental_mark
doomed = nil
receive_from
after_global = GC.latest_gc_info(:state)
alive = weak_map[key]
GC.verify_internal_consistency
GC.start(full_mark: false)
again = start_local_incremental_major
[after_global, alive, again, working_set.sum(&:size)]
end
assert_equal :marking, receive_from(child)
GC.start
child << :continue
after_global, alive, again, retained = value_of(child)
assert_equal :none, after_global
assert_equal :live, alive
assert_equal :marking, again
assert_equal MARK_HEAP_ROUNDS * MARK_HEAP_OBJECTS_PER_ROUND, retained
RUBY
end
def test_global_gc_clears_pending_major_request
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
_blocker = Ractor.new { Ractor.receive }
EnvUtil.without_gc { request_local_major_gc }
assert_not_nil GC.latest_gc_info(:need_major_by)
# gc_start_global clears every objspace's need_major_gc in its step 5, and nothing
# in this test can put a flag back: the only writer of GPR_FLAG_MAJOR_BY_FORCE is
# objspace_absorb, which needs a join or an orphaned zombie objspace to merge
# (_blocker never terminates and no other Ractor is created), and the sweep's own
# GPR_FLAG_MAJOR_BY_NOFREE is ruled out by the last_major_gc step 5 just set.
GC.start
assert_nil GC.latest_gc_info(:need_major_by)
assert_equal :none, GC.latest_gc_info(:state)
GC.verify_internal_consistency
RUBY
end
def test_fork_absorbs_mid_mark_zombie
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
omit 'fork is not supported' unless Process.respond_to?(:fork)
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
r = Ractor.new do
Ractor.main << start_local_incremental_major
Ractor.receive
end
assert_equal :marking, receive_from(r)
pid = fork_child do
# Only main survives the fork, so rb_ractor_terminate_atfork has already retired
# r and set its legacy value to nil (ractor_sync_terminate_atfork). The join
# must reach the absorb at once rather than wait for a thread that is not there;
# checking the value pins that down, since an error raised before
# rb_gc_objspace_absorb_into_current would leave the merge below untested.
value = with_wait_bound('value of a Ractor that did not survive the fork') { r.value }
raise "Ractor#value after fork returned #{value.inspect}, not nil" unless value.nil?
GC.verify_internal_consistency
# r's pages were merged carrying the mark bits of a mark that never finished, so
# drive the merged heap with minors only: a major would re-derive those bits and
# hide a stale one. gc_start_body honours the config over the absorb's own
# need_major_gc |= GPR_FLAG_MAJOR_BY_FORCE, which is what makes that possible.
GC.config(rgengc_allow_full_mark: false)
counts = [GC.stat(:count), GC.stat(:major_gc_count)]
400_000.times { Object.new }
raise "no collection ran after absorb" unless GC.stat(:count) > counts[0]
raise "a major collection ran with full marks disabled" unless GC.stat(:major_gc_count) == counts[1]
GC.verify_internal_consistency
end
assert_predicate wait_for_pid(pid), :success?
RUBY
end
def test_first_ractor_new_settles_creator_mid_mark
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
wait_until_single_ractor
assert_equal :marking, start_local_incremental_major
Ractor.new { Ractor.receive }
assert_equal :none, GC.latest_gc_info(:state)
assert_equal :marking, start_local_incremental_major
GC.verify_internal_consistency
RUBY
end
def test_ractor_join_absorbs_zombie_while_joiner_mid_mark
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
zombie = Ractor.new { :done }
assert_equal :marking, start_local_incremental_major
# The join is the operation under test (it absorbs the zombie's objspace into a
# mid-mark joiner), so it is bounded in place rather than through Ractor.select,
# which would reach the value through that same path.
assert_equal :done, with_wait_bound('zombie join') { zombie.value }
assert_equal :none, GC.latest_gc_info(:state)
GC.verify_internal_consistency
assert_equal :marking, start_local_incremental_major
RUBY
end
def test_incremental_major_with_zombie_objspace
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
zombie = Ractor.new { 1000.times { Object.new } }
with_wait_bound('zombie join') { zombie.join }
wait_until_single_ractor
assert_equal :marking, start_local_incremental_major
assert_equal :none, finish_incremental_major
GC.verify_internal_consistency
assert_equal :marking, start_local_incremental_major
RUBY
end
def test_concurrent_incremental_marks_with_shareable_exchange
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
worker = Ractor.new do
results = []
3.times do
entered = start_local_incremental_major
Ractor.main << :ready
inbound = receive_from
Ractor.main << Ractor.make_shareable([inbound])
count_before = GC.stat(:count)
results << [entered, finish_incremental_major, GC.stat(:count) - count_before]
end
GC.verify_internal_consistency
results
end
main_results = []
3.times do
entered = start_local_incremental_major
assert_equal :ready, receive_from(worker)
worker << Ractor.make_shareable([:payload])
receive_from(worker)
count_before = GC.stat(:count)
main_results << [entered, finish_incremental_major, GC.stat(:count) - count_before]
end
(value_of(worker) + main_results).each do |entered, final, extra_cycles|
assert_equal :marking, entered
assert_equal :none, final
# finish_incremental_major drives the cycle it was handed to :none and stops there.
# A second cycle means the drain outran the handshake and the pair is no longer
# exchanging shareables across two marks that overlap, which is the whole premise.
assert_equal 0, extra_cycles, 'the drain started another cycle: the marks did not overlap'
end
GC.verify_internal_consistency
RUBY
end
def test_writebarrier_foreign_objects_during_incremental_mark
omit 'incremental marking gate lives in the default GC' unless GC.config[:implementation] == 'default'
assert_separately(%W[-W0 -r-test-/gc/writebarrier -r#{RACTOR_GC_HELPER}], <<~'RUBY', timeout: 120)
box = Bug::GC::WriteBarrier::Box.new
Ractor.make_shareable(box)
worker = Ractor.new(box) do |box|
results = []
2.times do
entered = start_local_incremental_major
Ractor.main << :ready
inbound = receive_from
retained = Array.new(200) { [] }
200.times { |i| retained[i] << inbound }
retained = nil
# Local parent holding a foreign shareable child: re-greying it mid-mark must
# re-traverse it without touching the foreign child's GC state.
holder = [inbound]
Bug::GC::WriteBarrier.remember(holder)
mine = Object.new
box.store(mine)
mine_id = mine.object_id
mine = nil
# box is foreign to this Ractor, so this must be a no-op rather than a crash.
Bug::GC::WriteBarrier.remember(box)
Ractor.main << Ractor.make_shareable([:reply, mine_id])
results << [entered, finish_incremental_major, box.child.object_id == mine_id, holder[0]]
end
GC.verify_internal_consistency
results
end
main_retained = Array.new(200) { [] }
2.times do
entered = start_local_incremental_major
assert_equal :ready, receive_from(worker)
worker << Ractor.make_shareable([:payload])
reply = receive_from(worker)
200.times { |i| main_retained[i] << reply }
# Local parents now hold a foreign shareable child; the barrier's multi-Ractor
# bail-out leaves that edge to the shareable bits, so re-greying the parents
# mid-mark must re-traverse them safely.
Bug::GC::WriteBarrier.remember(main_retained)
# reply is foreign to the main Ractor, so this must be a no-op.
Bug::GC::WriteBarrier.remember(reply)
assert_equal :marking, entered
assert_equal :none, finish_incremental_major
assert_equal :reply, reply.first
end
value_of(worker).each do |entered, final, child_kept, held|
assert_equal :marking, entered
assert_equal :none, final
# mine was stored into a shareable box mid-mark from the objspace that owns it.
# The barrier bails out on that edge, so nothing greys mine; it survives the
# worker's mark only because the store set a shref bit that pinned_roots_mark
# picks up in gc_marks_finish.
assert child_kept, 'an unshareable stored into a shareable mid-mark was collected'
assert_equal [:payload], held
end
GC.verify_internal_consistency
RUBY
end
end
+1 -1
View File
@@ -2314,8 +2314,8 @@ unsafe extern "C" {
buff_size: usize,
obj: VALUE,
) -> *const ::std::os::raw::c_char;
pub fn rb_ec_stack_check(ec: *mut rb_execution_context_struct) -> ::std::os::raw::c_int;
pub fn rb_gc_writebarrier_remember(obj: VALUE);
pub fn rb_ec_stack_check(ec: *mut rb_execution_context_struct) -> ::std::os::raw::c_int;
pub fn rb_id_table_lookup(
tbl: *mut rb_id_table,
id: ID,