Ractor: the dying thread collects its own objspace post-mortem

The retire GC at notify_exit could not reclaim the terminating
thread's scaffolding -- the Thread and Fiber wrappers, the ThreadGroup,
the Ractor-local stdio, whatever the machine stack still references --
because at that point they were all still rooted.  Those ~11 objects
kept ~2 heap pages alive per dead Ractor, merged into the inheritor to
wait for its next major GC, so both a spawn-and-join loop and a
fire-and-forget loop grew the heap by two pages per Ractor with
nothing reclaiming them.

Instead, run the final collection at the tail of thread_start_func_2,
just before the scheduler handoff.  The thread still holds the GVL, so
the collection runs lock-free like any local GC (a concurrent global
GC waits for its safepoint), and vm_remove_ractor keeps its light
lock-only section.  r->postmortem makes rb_ractor_mark_local_roots
root only what must survive the death -- the join value
(r->sync.legacy) and the rb_gc_register_mark_object pins -- and the
zombie_objspaces entry recorded later measures exactly the pages the
joiner will inherit.  While the Ractor is still in vm->ractor.set,
rb_vm_mark and ractor_mark keep the Ractor object itself and its
default port alive as before; everything they mark is foreign to or
pinned by this collection.

Two of the swept wrappers are the dying thread's own: its Thread (the
rest of the collection resolves GET_EC through ec->thread_ptr) and its
root Fiber (whose struct embeds that ec).  thread_free and fiber_free
recognize "the struct I am standing on" (ec == the current ec), keep
the struct, and leave an in-band mark (th->self = 0; cont.self = 0,
which already means "no wrapper" on the never-wrapped path).  Found as
a 1/30 heap corruption, pinned by ASAN.  The collecting thread
captures those two pointers right after the collection, while its GVL
still excludes any global GC, and frees exactly the captured structs
at its very last step -- after vm_remove_ractor unlinks the Ractor,
anything it did not sweep may already be freed by a concurrent global
GC, so the epilogue reads no shared memory at all.  Those frees would
resolve their objspace through the TLS ec -- which sits inside the
fiber struct being freed and leads to the retired Ractor -- so
rb_ractor_postmortem_free severs the TLS ec first (the MN epilogue
already had) and they fall back to the main objspace.

While a thread-event hook is registered the postmortem flag stays off
and the collection keeps the ordinary retire roots: callbacks may
retain any Ractor's Thread object (rb_internal_thread_event_hook_t),
and such references are invisible to the reduced roots.  The check
takes the hook rwlock, so it happens-after any completed registration;
a hook registered after it gets no event from this thread --
rb_thread_execute_hooks skips a thread whose wrapper is gone
(th->self == 0) -- so it never receives what the collection swept.
On platforms without hook support (win32, none) the check reports
false.

The collection runs after thread_cleanup_func, so the dying thread's
machine stack is already torn down: the root scan skips its machine
context (rb_gc_impl_during_postmortem_p), which holds nothing the
reduced roots do not already cover and faults when scanned on some
platforms (windows-arm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Koichi SasadaandClaude Fable 5 committed 2026-08-11 03:33:40 +09:00
1 parent 207e52a8bf
commit e768a87a17
17 files changed
+195 -10
Notes: git 2026-08-10 18:34:21 +00:00

No files matched your search

+16
View File
@@ -1300,6 +1300,22 @@ fiber_mark(void *ptr)
static void
fiber_free(void *ptr)
{
rb_fiber_t *fiber = ptr;
/* Root fiber of the thread running the final self collection: saved_ec is the ec
* that thread still executes on, so the thread frees the struct itself at its
* last step (rb_ractor_postmortem_free). cont.self == 0 already means "no
* wrapper" (rb_threadptr_root_fiber_release). */
if (&fiber->cont.saved_ec == rb_current_execution_context(false)) {
fiber->cont.self = 0;
return;
}
rb_fiber_free_body(ptr);
}
void
rb_fiber_free_body(void *ptr)
{
rb_fiber_t *fiber = ptr;
RUBY_FREE_ENTER("fiber");
+22 -2
View File
@@ -644,6 +644,7 @@ typedef struct gc_function_map {
bool (*user_gc_disabled_p)(void *objspace_ptr);
bool (*multi_objspace_p)(void);
bool (*during_global_gc_p)(void *objspace_ptr);
bool (*during_postmortem_p)(void *objspace_ptr);
bool (*obj_foreign_p)(void *objspace_ptr, VALUE obj);
bool (*shref_marked_p)(void *objspace_ptr, VALUE obj);
size_t (*heap_page_count)(void *objspace_ptr);
@@ -838,6 +839,7 @@ ruby_modular_gc_init(void)
load_modular_gc_func(user_gc_disabled_p);
load_modular_gc_func(multi_objspace_p);
load_modular_gc_func(during_global_gc_p);
load_modular_gc_func(during_postmortem_p);
load_modular_gc_func(obj_foreign_p);
load_modular_gc_func(shref_marked_p);
load_modular_gc_func(heap_page_count);
@@ -941,6 +943,7 @@ ruby_modular_gc_init(void)
# define rb_gc_impl_user_gc_disabled_p rb_gc_functions.user_gc_disabled_p
# define rb_gc_impl_multi_objspace_p rb_gc_functions.multi_objspace_p
# define rb_gc_impl_during_global_gc_p rb_gc_functions.during_global_gc_p
# define rb_gc_impl_during_postmortem_p rb_gc_functions.during_postmortem_p
# define rb_gc_impl_obj_foreign_p rb_gc_functions.obj_foreign_p
# define rb_gc_impl_shref_marked_p rb_gc_functions.shref_marked_p
# define rb_gc_impl_heap_page_count rb_gc_functions.heap_page_count
@@ -3375,8 +3378,14 @@ rb_gc_mark_roots(void *objspace, const char **categoryp)
rb_sym_global_symbols_mark_and_move();
}
MARK_CHECKPOINT("machine_context");
mark_current_machine_context(ec);
/* The dying thread's final collection of its own objspace runs after its stack
* has been torn down (thread_cleanup_func), so there is no live machine context
* to scan -- the join value and the pins are rooted explicitly. Scanning the
* half-dead stack is not only useless but faults on some platforms. */
if (!rb_gc_impl_during_postmortem_p(objspace)) {
MARK_CHECKPOINT("machine_context");
mark_current_machine_context(ec);
}
MARK_CHECKPOINT("finish");
@@ -4216,6 +4225,17 @@ objspace_absorb_merge(void *dst, void *src)
gc_absorbed_since_global_gc = true;
}
/* The dying thread's last collection of its own objspace, GVL still held; with
* r->postmortem set, rb_ractor_mark_local_roots roots only the join value and the
* registered_marks pins, so the scaffolding nobody needs any more dies here. */
void
rb_gc_objspace_postmortem_self(void)
{
if (!rb_gc_impl_multi_objspace_p()) return;
rb_gc_impl_objspace_retire_gc(rb_gc_get_objspace());
}
void
rb_gc_objspace_absorb_into_current(void **objspace_slot)
{
+14
View File
@@ -605,6 +605,7 @@ typedef struct rb_objspace {
unsigned int during_reference_updating : 1;
unsigned int during_minor_gc : 1;
unsigned int during_incremental_marking : 1;
unsigned int during_postmortem : 1;
unsigned int measure_gc : 1;
} flags;
@@ -7805,12 +7806,25 @@ rb_gc_impl_objspace_retire_gc(void *objspace_ptr)
{
rb_objspace_t *objspace = objspace_ptr;
/* The dying thread's stack is already torn down here, so the root scan must skip
* its machine context (rb_gc_mark_roots). */
objspace->flags.during_postmortem = 1;
gc_rest(objspace);
gc_start_body(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP,
false);
heap_pages_freeable_pages = objspace->empty_pages_count;
heap_pages_free_unused_pages(objspace);
objspace->flags.during_postmortem = 0;
}
bool
rb_gc_impl_during_postmortem_p(void *objspace_ptr)
{
rb_objspace_t *objspace = objspace_ptr;
return objspace->flags.during_postmortem != 0;
}
static void
+3
View File
@@ -137,6 +137,9 @@ GC_IMPL_FN void rb_gc_impl_each_objects_foreign(void *objspace_ptr, int (*callba
* objspace and passes the per-Ractor objspace machinery (retire, absorb, ...) straight through. */
GC_IMPL_FN bool rb_gc_impl_multi_objspace_p(void);
GC_IMPL_FN bool rb_gc_impl_during_global_gc_p(void *objspace_ptr);
/* Whether the current collection is a dying thread's final collection of its own
* objspace, whose torn-down machine context must not be scanned. */
GC_IMPL_FN bool rb_gc_impl_during_postmortem_p(void *objspace_ptr);
/* Whether obj is owned by an objspace other than objspace_ptr. Always false for a single
* objspace impl. */
GC_IMPL_FN bool rb_gc_impl_obj_foreign_p(void *objspace_ptr, VALUE obj);
+7
View File
@@ -1815,6 +1815,13 @@ rb_gc_impl_during_global_gc_p(void *objspace_ptr)
return objspace->world_stopped;
}
bool
rb_gc_impl_during_postmortem_p(void *objspace_ptr)
{
/* mmtk has a single objspace and no per-Ractor retire collection. */
return false;
}
bool
rb_gc_impl_obj_foreign_p(void *objspace_ptr, VALUE obj)
{
+1
View File
@@ -31,4 +31,5 @@ VALUE rb_fiber_inherit_storage(struct rb_execution_context_struct *ec, struct rb
VALUE rb_fiberptr_self(struct rb_fiber_struct *fiber);
unsigned int rb_fiberptr_blocking(struct rb_fiber_struct *fiber);
struct rb_execution_context_struct * rb_fiberptr_get_ec(struct rb_fiber_struct *fiber);
void rb_fiber_free_body(void *fiber); /* cont.c */
#endif /* INTERNAL_CONT_H */
+1
View File
@@ -305,6 +305,7 @@ bool rb_gc_obj_foreign_p(VALUE obj);
void *rb_gc_objspace_alloc(void);
void rb_gc_objspace_retire_gc(void);
void rb_gc_objspace_retire(void **objspace_slot);
void rb_gc_objspace_postmortem_self(void);
void rb_gc_objspace_absorb_into_current(void **objspace_slot);
void rb_gc_objspace_absorb_all_zombies(void);
void rb_gc_objspace_disown(void *objspace);
+3
View File
@@ -55,6 +55,9 @@ void rb_ec_check_ints(struct rb_execution_context_struct *ec);
void rb_thread_free_native_thread(void *th_ptr);
bool rb_thread_event_hooks_registered_p(void); /* thread_pthread.c */
void rb_thread_free_body(void *th); /* vm.c */
RUBY_SYMBOL_EXPORT_BEGIN
void *rb_thread_prevent_fork(void *(*func)(void *), void *data); /* for ext/socket/raddrinfo.c */
+54
View File
@@ -324,6 +324,14 @@ ractor_mark(void *ptr)
void
rb_ractor_mark_local_roots(rb_ractor_t *r)
{
if (r->postmortem) {
/* The final self collection: everything else -- the Thread and Fiber
* wrappers, stdio, stack leftovers -- is what it exists to reclaim. */
rb_ractor_mark_terminated_join_value(r);
rb_gc_mark_vm_stack_values((long)r->registered_marks_cnt, r->registered_marks);
return;
}
rb_gc_mark(r->loc);
rb_gc_mark(r->name);
ractor_mark_unshareable_parts(r);
@@ -621,6 +629,8 @@ vm_remove_ractor(rb_vm_t *vm, rb_ractor_t *cr)
* cnt==1-no-zombie window where a GC skips shareable pinning and collects
* objects (a cc, say) reachable only through this objspace. */
if (cr->objspace) {
/* The final self collection already ran (rb_ractor_postmortem_collect),
* so the entry measures exactly the pages the joiner will inherit. */
rb_gc_objspace_retire(&cr->objspace);
}
vm->ractor.cnt--;
@@ -630,6 +640,50 @@ vm_remove_ractor(rb_vm_t *vm, rb_ractor_t *cr)
RB_VM_UNLOCK();
}
/* The dying thread's final collection of its own objspace, and the capture of what it
* must free itself. Called with the GVL still held: a concurrent global GC waits for
* this thread's safepoint, so the collection is lock-free like any local GC. */
void
rb_ractor_postmortem_collect(rb_thread_t *th, struct rb_ractor_postmortem_frees *pf)
{
rb_ractor_t *const cr = th->ractor;
VM_ASSERT(cr != GET_VM()->ractor.main_ractor);
VM_ASSERT(th->ec != NULL);
struct rb_fiber_struct *const fiber = th->ec->fiber_ptr;
const bool fiber_wrapped = fiber && rb_fiberptr_self(fiber) != 0;
/* With a thread-event hook registered the callbacks may retain any Ractor's Thread
* object (rb_internal_thread_event_hook_t), and such references are invisible to
* the reduced roots: keep the ordinary retire roots, wrappers survive to absorb. */
cr->postmortem = rb_gc_multi_objspace_p() && !rb_thread_event_hooks_registered_p();
rb_gc_objspace_postmortem_self();
/* Only what this collection provably swept (wrapper gone, struct deferred with an
* in-band mark) may be touched after vm_remove_ractor unlinks the Ractor. */
pf->th = (th->self == 0) ? th : NULL;
pf->fiber = (fiber_wrapped && rb_fiberptr_self(fiber) == 0) ? fiber : NULL;
}
void
rb_ractor_postmortem_free(const struct rb_ractor_postmortem_frees *pf)
{
if (pf->fiber == NULL && pf->th == NULL) return;
/* The frees below resolve their objspace through the TLS ec, which sits inside
* pf->fiber and leads to the retired Ractor: cut the resolution off so they fall
* back to the main objspace. (The MN epilogue has already done this.) */
#ifdef RB_THREAD_LOCAL_SPECIFIER
rb_current_ec_set(NULL);
#else
native_tls_set(ruby_current_ec_key, NULL);
#endif
/* the fiber struct embeds the thread's final ec, so free it first */
if (pf->fiber) rb_fiber_free_body(pf->fiber);
if (pf->th) rb_thread_free_body(pf->th);
}
static VALUE
ractor_alloc(VALUE klass)
{
+13
View File
@@ -125,6 +125,10 @@ struct rb_ractor_struct {
struct ccan_list_node vmlr_node;
bool in_terminated_set; /* vmlr_node is on vm->ractor.terminated_set */
/* The final self collection ran (rb_ractor_postmortem_collect): from then on
* rb_ractor_mark_local_roots roots only the join value and the registered_marks
* pins, so the dying thread's own scaffolding can be collected. */
bool postmortem;
// ractor local data
@@ -221,6 +225,15 @@ bool rb_ractor_p(VALUE rv);
void rb_ractor_living_threads_init(rb_ractor_t *r);
void rb_ractor_living_threads_insert(rb_ractor_t *r, rb_thread_t *th);
void rb_ractor_living_threads_remove(rb_ractor_t *r, rb_thread_t *th);
/* The structs the final self collection swept while the dying thread still stood on
* them; that thread frees them at its very last step (rb_ractor_postmortem_free). */
struct rb_ractor_postmortem_frees {
struct rb_thread_struct *th;
struct rb_fiber_struct *fiber;
};
void rb_ractor_postmortem_collect(rb_thread_t *th, struct rb_ractor_postmortem_frees *pf);
void rb_ractor_postmortem_free(const struct rb_ractor_postmortem_frees *pf);
void rb_ractor_cancel_creation(rb_ractor_t *r, rb_thread_t *th);
void rb_ractor_blocking_threads_inc(rb_ractor_t *r, const char *file, int line); // TODO: file, line only for RUBY_DEBUG_LOG
void rb_ractor_blocking_threads_dec(rb_ractor_t *r, const char *file, int line); // TODO: file, line only for RUBY_DEBUG_LOG
-6
View File
@@ -675,12 +675,6 @@ ractor_notify_exit(rb_execution_context_t *ec, rb_ractor_t *cr, VALUE legacy, bo
VM_ASSERT(!UNDEF_P(legacy));
VM_ASSERT(cr->sync.legacy == Qundef);
/* Last local GC before termination, in ordinary execution context: collect here
* what the joiner would otherwise inherit, and return empty pages to the pool. */
if (cr != GET_VM()->ractor.main_ractor) {
rb_gc_objspace_retire_gc();
}
RACTOR_LOCK_SELF(cr);
{
ractor_free_all_ports(cr);
+9
View File
@@ -827,12 +827,20 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start)
thread_cleanup_func(th, FALSE);
VM_ASSERT(th->ec->vm_stack == NULL);
// A dying Ractor collects its own objspace here, before the scheduler handoff
// below, and captures the structs it must free at its last step.
struct rb_ractor_postmortem_frees pf = { NULL, NULL };
if (th->invoke_type == thread_invoke_type_ractor_proc) {
rb_ractor_postmortem_collect(th, &pf);
}
#if defined(USE_MN_THREADS) && USE_MN_THREADS
if (th_has_coroutine(th)) {
// Run the coroutine thread's epilogue here, while th is still valid;
// co_start then only makes the final transfer (see
// coroutine_thread_terminated in thread_pthread_mn.c).
coroutine_thread_terminated(th);
rb_ractor_postmortem_free(&pf);
return 0;
}
#endif
@@ -843,6 +851,7 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start)
// So gvl_release() should be before it.
thread_sched_to_dead(TH_SCHED(th), th);
rb_ractor_living_threads_remove(th->ractor, th);
rb_ractor_postmortem_free(&pf);
}
else {
rb_ractor_living_threads_remove(th->ractor, th);
+6
View File
@@ -331,6 +331,12 @@ rb_thread_malloc_stack_set(rb_thread_t *th, void *stack, size_t stack_size)
// no-op
}
bool
rb_thread_event_hooks_registered_p(void)
{
return false; // hooks are not implemented on this platform
}
#endif /* THREAD_SYSTEM_DEPENDENT_IMPLEMENTATION */
void
+22
View File
@@ -3558,6 +3558,24 @@ struct rb_internal_thread_event_hook {
static pthread_rwlock_t rb_internal_thread_event_hooks_rw_lock = PTHREAD_RWLOCK_INITIALIZER;
/* For the GC: whether any thread-event hook is registered right now. The rwlock
* makes the answer happen-after any completed registration; a hook registered
* after this read gets no event from the asking thread (rb_thread_execute_hooks
* skips a swept thread), so it never sees the objects the caller may collect. */
bool
rb_thread_event_hooks_registered_p(void)
{
int r;
if ((r = pthread_rwlock_rdlock(&rb_internal_thread_event_hooks_rw_lock))) {
rb_bug_errno("pthread_rwlock_rdlock", r);
}
const bool registered = (rb_internal_thread_event_hooks != NULL);
if ((r = pthread_rwlock_unlock(&rb_internal_thread_event_hooks_rw_lock))) {
rb_bug_errno("pthread_rwlock_unlock", r);
}
return registered;
}
#if defined(HAVE_WORKING_FORK)
static void
rb_internal_thread_event_hooks_rw_lock_atfork(void)
@@ -3636,6 +3654,10 @@ static void
rb_thread_execute_hooks(rb_event_flag_t event, rb_thread_t *th)
{
int r;
/* th->self == 0: the dying thread's final collection swept its Thread wrapper, so
* no hook existed then; one registered since has no ordering claim to this event. */
if (th->self == 0) return;
if ((r = pthread_rwlock_rdlock(&rb_internal_thread_event_hooks_rw_lock))) {
rb_bug_errno("pthread_rwlock_rdlock", r);
}
+3 -2
View File
@@ -603,8 +603,9 @@ native_thread_create_shared(rb_thread_t *th)
RUBY_DEBUG_LOG("th:%u vm_stack:%p machine_stack:%p", rb_th_serial(th), vm_stack, machine_stack);
thread_sched_to_ready(TH_SCHED(th), th);
// setup nt
return native_thread_check_and_create_shared(th->vm);
// setup nt. th is runnable now and a Ractor's thread that runs to its end frees
// its own rb_thread_t (rb_ractor_postmortem_free), so th must not be read again.
return native_thread_check_and_create_shared(vm);
}
#else // USE_MN_THREADS
+6
View File
@@ -44,6 +44,12 @@ rb_internal_thread_remove_event_hook(rb_internal_thread_event_hook_t * hook)
return false;
}
bool
rb_thread_event_hooks_registered_p(void)
{
return false; // hooks are not implemented on this platform
}
RBIMPL_ATTR_NORETURN()
static void
w32_error(const char *func)
+15
View File
@@ -3992,6 +3992,21 @@ void rb_threadptr_sched_free(rb_thread_t *th); // thread_*.c
static void
thread_free(void *ptr)
{
rb_thread_t *th = ptr;
/* The final self collection sweeps the wrapper of the very thread running it; the
* struct is still under that thread's feet (GET_EC resolves through it), so the
* thread frees the struct itself at its last step (rb_ractor_postmortem_free). */
if (th->ec != NULL && th->ec == rb_current_execution_context(false)) {
th->self = 0;
return;
}
rb_thread_free_body(ptr);
}
void
rb_thread_free_body(void *ptr)
{
rb_thread_t *th = ptr;
RUBY_FREE_ENTER("thread");