A getaddrinfo thread calls getaddrinfo(3) inside rb_thread_prevent_fork,
but on glibc its thread exit takes the resolver configuration lock again
after the thread has released the fork lock. A fork that lands then
leaves the child with that lock held, and every later name lookup in the
child blocks forever. getnameinfo(3) attaches the same resolver state,
even for a name in /etc/hosts, and its thread ran outside
rb_thread_prevent_fork altogether.
Make the lookup threads joinable and have fork join any that may still
be exiting. As a lookup thread finishes, still inside
rb_thread_prevent_fork, it swaps itself into a single slot with an
atomic exchange and joins the thread it displaced, which has usually
exited by then, and rb_thread_acquire_fork_lock joins whatever is left
in the slot. Once lookups settle, at most one finished thread is left
unjoined. The slot holds pthread_t as a pointer, with NULL for empty,
which assumes pthread_t is scalar and never 0.
getnameinfo threads now run within rb_thread_prevent_fork and register
as they exit, as getaddrinfo threads do, and where getnameinfo runs
without a helper thread it is called within rb_thread_prevent_fork too.
So fork also waits for a reverse lookup in progress, as it already did
for a forward one. raddrinfo_pthread_create now always creates joinable
threads, which drops its pthread_detach fallback for platforms without
pthread_attr_setdetachstate.
The slot is kept across fork, so every fork path that keeps running
Ruby code has to take the fork lock. On the BSDs, Process.daemon forks
with daemon(3) and did not take it, so the daemon would inherit the
registered thread, and its next fork would abort in pthread_join with
ESRCH. Take and release the fork lock around daemon(3) as rb_fork_ruby
does. daemon(3) can fail in the child as well as in the parent, so
compare pids to tell which process returned.
[Bug #22406]
rb_ractor_terminate_all() interrupts the other Ractors and waits on a
condvar of its own for them to finish, dropping the VM lock for the wait
and taking it back after. That was rb_vm_cond_timedwait(), whose only
caller it was; rb_vm_cond_wait() had none at all.
Move the wait to rb_ractor_sched_wait_terminate() in thread_sched.c and
give the native thread back for its duration, as a blocking region does.
Waiting natively holds whatever native thread the caller runs on, and an
M:N caller runs on one from the shared pool -- so it would wait, holding
the pool, for Ractors that need the pool to finish. It does not happen
today, the caller being the main Ractor's main thread with a native
thread of its own, but a scheduler-blind wait in the scheduler's way is
worth keeping out of vm_sync.c.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows carried a second, much weaker scheduler: a single Win32 mutex as
the GVL, vm->clock for preemption, its own VM barrier over
vm->ractor.sync.barrier_*, its own Ractor wait/wakeup on a per-Ractor
condvar, no ready queue and no time slice. Keeping two made the rest of
the interpreter branch on which one was compiled in, and left Ractors on
Windows without real scheduling.
Move the scheduler out of thread_pthread.c into thread_sched.c and let
both platforms use it. thread.c now includes thread_sched.c, which
declares the scheduler <-> platform contract, includes THREAD_IMPL_SRC,
and then builds the scheduler on the primitives that file provides.
thread_pthread.c keeps only those primitives: synchronization, thread
local storage, native thread creation and stacks, the communication pipe,
the timer thread backend, the fork lock and the event hooks.
The data structures the scheduler works on move to a new thread_sched.h,
included from both platform headers, so struct rb_thread_sched,
struct rb_native_thread and struct rb_thread_sched_item are now shared.
thread_pthread_mn.c becomes thread_sched_mn.c: it is the M:N scheduler,
not a pthread detail. Most of it -- the coroutine threads, the native
thread stack pool, the timer wheel and the fd -> waiters map -- is
platform independent; only the readiness backend is not, and that section
is now marked as the seam where an epoll/kqueue split (or an IOCP backend,
which is what Windows would need for M:N) would go.
What Windows had to add is small: native_thread_interrupt(),
native_thread_default_max_cpu(), timer_thread_polling(),
timer_thread_wakeup_force(), RB_NATIVE_COND_MONOTONIC_P() and
RB_NATIVE_MUTEX_TRYLOCK_DETECTS_SELF() -- the last because a
CRITICAL_SECTION is recursive, so trylock cannot back a "somebody holds
this" assertion. It sets USE_MN_THREADS to 0 and supplies the stubs
thread_sched_mn.c provides elsewhere, so every thread there is dedicated,
as it already was.
With one scheduler the platform branches go away: all 16 uses of
RUBY_THREAD_PTHREAD_H / RUBY_THREAD_WIN32_H are removed from vm_core.h,
ractor.c, ractor_core.h, ractor_sync.c, vm_sync.c, vm.c and thread.c.
ractor_check_blocking() went with them -- it only ever fed the win32
scheduler's vm->ractor.blocking_cnt -- and so did USE_VM_CLOCK, now that
the common time slice drives preemption on Windows too.
Verified on both platforms. Linux: 11 rounds of make test-all (36236
tests) and btest (2066 tests) with no failures, plus 474 runs of a
scheduler and Ractor stress soak. Windows: no test fails that does not
also fail on the same tree without this change, btest passes, and two
40-minute soaks of 438 and 460 runs are clean. Ractors now run in
parallel there -- 3.6x on four Ractors, where before they could not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since "Ractor: the dying thread collects its own objspace post-mortem"
(e768a87a17), a dying Ractor thread runs rb_ractor_postmortem_free()
after thread_sched_to_dead() (its GVL is gone) and after
rb_ractor_living_threads_remove() -> vm_remove_ractor(), which unlinks
the Ractor, decrements vm->ractor.cnt and signals the terminate waiter.
From that point nothing holds the main Ractor back, but the epilogue
still frees through VM-global state: rb_fiber_free_body() -> cont_free()
takes jit_cont_lock and returns the stack to the shared fiber pool, and
both frees update the main objspace's malloc accounting.
The coroutine epilogue has the same hazard and is covered:
coroutine_thread_terminated() bumps vm->ractor.sched.winding_cnt and
ruby_vm_destruct() spins in rb_thread_sched_wait_winding() until the
reclaim is done. The non-coroutine path -- the ordinary
Ractor.new-spawned dedicated thread -- was not counted, so a main
Ractor that exits while the dying thread is still in the epilogue tears
that state down underneath it.
Count the epilogue in winding_cnt as well. The increment happens before
the Ractor stops being counted, so there is no window where the VM sees
neither the Ractor nor the epilogue. vm->ractor.sched exists only on
the pthread backend, so the count gets the same shape its waiter has:
real work in thread_pthread.c, a no-op in thread_win32.c and
thread_none.c, where rb_thread_sched_wait_winding never waits either.
Reproduced deterministically by delaying the epilogue (50ms) and the
process exit (400ms) on top of e768a87a17: 6/6 runs of
`Ractor.new{ 1 }.value` died with
[BUG] pthread_mutex_lock: Invalid argument (EINVAL)
rb_native_mutex_lock (thread_pthread.c:128)
jit_cont_free (cont.c:1419)
cont_free (cont.c:1185)
rb_ractor_postmortem_free (ractor.c:683)
thread_start_func_2 (thread.c:851)
i.e. the epilogue locking jit_cont_lock after rb_jit_cont_finish()
destroyed it. With this change the same harness is clean 6/6, and
test/ruby/test_ractor.rb, test/objspace/test_ractor.rb, test_thread.rb
and the strscan/digest/io-wait/io-console Ractor tests pass. Without
the injected delay the window is never entered on x86_64 (0 hits in 15
runs of those test files, instrumented), which fits the failures being
confined to the slow emulated runners.
This is the most likely cause of the heap corruption CI has been
reporting since e768a87a17 landed (2026-08-10 18:33 UTC): every failing
case is a child process that runs one Ractor and exits.
Error:
TestIOWaitInRactor#test_ractor [test/io/wait/test_ractor.rb:9]:
pid 68552 exit 0
| free(): corrupted unsorted chunks
1. [2/2] Assertion for "stderr"
| <[]> expected but was
| <["free(): corrupted unsorted chunks"]>.
(also TestStringScannerRactor#test_ractor, TestDigestRactor::*,
TestIOConsoleInRactor#test_ractor -- 16 of the 63 master runs since
2026-08-10, none in the 87 runs before it)
TestObjSpaceRactor#test_copy_finalizer [test/objspace/test_ractor.rb:57]:
==96541==ERROR: AddressSanitizer: heap-use-after-free on address 0x520000000088
WRITE of size 8 at 0x520000000088 thread T5
CI: https://github.com/ruby/ruby/actions/runs/31886577248/job/95016642538
CI: https://github.com/ruby/ruby/actions/runs/31427322788 (ASAN, x86_64)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Redesign the teardown of M:N (coroutine) threads around the natural call
structure nt_start -> co_start -> thread_start_func_2: when the thread body
returns, its epilogue (coroutine_thread_terminated) finishes all rb_thread_t /
rb_ractor_t business while th is still valid, and co_start then makes exactly
one final transfer back to the native thread's own context (nt_start's loop),
where the nt reclaims the dead coroutine's context. The resume itself proves
the final transfer's register save completed, so the reclaim needs no list,
no handshake and no extra locking.
This deletes the zombie-threads machinery (VM-global list, sched.finished
publish, the GC mark/reap pass) and closes its UAF window: publishing
`finished` before the terminal transfer let the GC free the Thread wrapper --
whose coroutine context the transfer still saves into -- mid-teardown.
* struct rb_thread_context bundles a coroutine thread's execution-owned
resources: the coroutine_context (kept first, so th->sched.context points
into the block) plus its machine stack, the stashed final-transfer target
(nt), and a dead flag. co_start's epilogue sets the flag and transfers via
the stashed nt, touching tctx alone -- th may already be collected by then.
The nt loop passes every coroutine it resumes from to
thread_sched_reclaim_from(), which frees dead ones (stack back to the
pool). A live coroutine cannot be resumed elsewhere, die and be freed
while the nt loop still inspects it: a live yield arrives with the sched
lock handed off, and no other nt can schedule that thread again until the
nt loop's own unlock.
* coroutine_thread_terminated (run from thread_start_func_2, where the
upstream non-coroutine teardown also runs, while th is still valid and
listed) does: wake the next thread as upstream's thread_sched_to_dead
does; stash th->nt into tctx and detach th->sched.context; leave the
living set; clear the current ec; and, for a shared-nt non-last thread,
enqueue the Ractor's next runnable thread (not earlier: waking it from
to_dead would let another thread of the same Ractor execute while this
teardown still runs). Everything that reads th happens before the
living-set removal, after which the GC may collect th (and, for a
Ractor's main thread, the Ractor).
* interrupt_lock is now destroyed in thread_free instead of during teardown
(thread_cleanup_func): while th is in its Ractor's living set, anyone --
terminate_all on the Ractor's terminating main thread, Thread#kill /
#raise -- may lock it, and destroying it mid-teardown leaves a window
where a concurrent interrupter locks a destroyed mutex (EINVAL; the
upstream non-coroutine path has the same, smaller, window). At thread_free
time the wrapper is unreachable AND off the living set (listed threads
are marked), so no interrupter can exist. The atfork cleanup reinitializes
the copied lock, which may have been held at the instant of fork.
* The Thread wrapper's dfree only reclaims the context of a thread that
never started; a terminated one cleared th->sched.context in its epilogue.
VM shutdown needs no extra synchronization: past the living-set removal the
epilogue touches only its own context block, the TLS and nt->nt_context,
none of which VM destruct frees (SNT native threads and the machine-stack
pool are intentionally not torn down at exit, as before).
Verified: bootstraptest 2050 PASS; test_thread / test_ractor / test_fiber /
test_gc / test_gc_compact / thread_queue 282 tests 0F0E; Ractor x Thread x
IO teardown churn 150/150 + 60/60 (exercises DNT promotion, terminate_all
racing dying threads, and the double-enqueue fixed in the previous commit);
fork with live Ractors + child GC 20/20; ASAN clean including unreferenced
Thread/Ractor collection (the old zombie window) and mixed stress;
RGENGC_CHECK_MODE=2 btest 2050 + churn; TSan warning profile identical to
master (the new functions appear in no race report); YJIT
move-under-GC.stress; kill/join races; Thread object retention across
Ractor death; exit-while-winding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
We allocate the stack of the main thread using malloc, but we never set
malloc_stack to true and context_stack. If we fork, the main thread may
no longer be the main thread anymore so it reports memory being leaked
in RUBY_FREE_AT_EXIT.
This commit allows the main thread to free its own VM stack at shutdown.
We never freed any resources of rb_native_thread at fork because it would
cause it to hang. This is because it called rb_native_cond_destroy for
condition variables. We can't call rb_native_cond_destroy here because
according to the specs of pthread_cond_destroy:
Attempting to destroy a condition variable upon which other threads
are currently blocked results in undefined behavior.
Specifically, glibc's pthread_cond_destroy waits on all the other listeners.
Since after forking all the threads are dead, the condition variable's
listeners will never wake up, so it will hang forever.
This commit changes it to only free the memory and none of the condition
variables.
This commit adds an environment variable `RUBY_THREAD_TIMESLICE` for
specifying the default thread quantum in milliseconds. You can adjust
this variable to tune throughput, which is especially useful on
multithreaded systems that are mixing CPU bound work and IO bound work.
The default quantum remains 100ms.
[Feature #20861]
Co-Authored-By: John Hawthorn <john@hawthorn.email>
[Feature #20590]
For better of for worse, fork(2) remain the primary provider of
parallelism in Ruby programs. Even though it's frowned uppon in
many circles, and a lot of literature will simply state that only
async-signal safe APIs are safe to use after `fork()`, in practice
most APIs work well as long as you are careful about not forking
while another thread is holding a pthread mutex.
One of the APIs that is known cause fork safety issues is `getaddrinfo`.
If you fork while another thread is inside `getaddrinfo`, a mutex
may be left locked in the child, with no way to unlock it.
I think we could reduce the impact of these problem by preventing
in for the most notorious and common cases, by locking around
`fork(2)` and known unsafe APIs with a read-write lock.
This commit changes how stack extents are calculated for both the main
thread and other threads. Ruby uses the address of a local variable as
part of the calculation for machine stack extents:
* pthreads uses it as a lower-bound on the start of the stack, because
glibc (and maybe other libcs) can store its own data on the stack
before calling into user code on thread creation.
* win32 uses it as an argument to VirtualQuery, which gets the extent of
the memory mapping which contains the variable
However, the local being used for this is actually too low (too close to
the leaf function call) in both the main thread case and the new thread
case.
In the main thread case, we have the `INIT_STACK` macro, which is used
for pthreads to set the `native_main_thread->stack_start` value. This
value is correctly captured at the very top level of the program (in
main.c). However, this is _not_ what's used to set the execution context
machine stack (`th->ec->machine_stack.stack_start`); that gets set as
part of a call to `ruby_thread_init_stack` in `Init_BareVM`, using the
address of a local variable allocated _inside_ `Init_BareVM`. This is
too low; we need to use a local allocated closer to the top of the
program.
In the new thread case, the lolcal is allocated inside
`native_thread_init_stack`, which is, again, too low.
In both cases, this means that we might have VALUEs lying outside the
bounds of `th->ec->machine.stack_{start,end}`, which won't be marked
correctly by the GC machinery.
To fix this,
* In the main thread case: We already have `INIT_STACK` at the right
level, so just pass that local var to `ruby_thread_init_stack`.
* In the new thread case: Allocate the local one level above the call to
`native_thread_init_stack` in `call_thread_start_func2`.
[Bug #20001]
fix
The implementation of `native_thread_init_stack` for the various
threading models can use the address of a local variable as part of the
calculation of the machine stack extents:
* pthreads uses it as a lower-bound on the start of the stack, because
glibc (and maybe other libcs) can store its own data on the stack
before calling into user code on thread creation.
* win32 uses it as an argument to VirtualQuery, which gets the extent of
the memory mapping which contains the variable
However, the local being used for this is actually allocated _inside_
the `native_thread_init_stack` frame; that means the caller might
allocate a VALUE on the stack that actually lies outside the bounds
stored in machine.stack_{start,end}.
A local variable from one level above the topmost frame that stores
VALUEs on the stack must be drilled down into the call to
`native_thread_init_stack` to be used in the calculation. This probably
doesn't _really_ matter for the win32 case (they'll be in the same
memory mapping so VirtualQuery should return the same thing), but
definitely could matter for the pthreads case.
[Bug #20001]
With M:N thread scheduler, the native thread (NT) related resources
should be freed when the NT is no longer needed. So the calling
`native_thread_destroy()` at the end of `is will be freed when
`thread_cleanup_func()` (at the end of Ruby thread) is not correct
timing. Call it when the corresponding Ruby thread is collected.
This patch introduce M:N thread scheduler for Ractor system.
In general, M:N thread scheduler employs N native threads (OS threads)
to manage M user-level threads (Ruby threads in this case).
On the Ruby interpreter, 1 native thread is provided for 1 Ractor
and all Ruby threads are managed by the native thread.
From Ruby 1.9, the interpreter uses 1:1 thread scheduler which means
1 Ruby thread has 1 native thread. M:N scheduler change this strategy.
Because of compatibility issue (and stableness issue of the implementation)
main Ractor doesn't use M:N scheduler on default. On the other words,
threads on the main Ractor will be managed with 1:1 thread scheduler.
There are additional settings by environment variables:
`RUBY_MN_THREADS=1` enables M:N thread scheduler on the main ractor.
Note that non-main ractors use the M:N scheduler without this
configuration. With this configuration, single ractor applications
run threads on M:1 thread scheduler (green threads, user-level threads).
`RUBY_MAX_CPU=n` specifies maximum number of native threads for
M:N scheduler (default: 8).
This patch will be reverted soon if non-easy issues are found.
[Bug #19842]
```
1) Failure:
TestThreadInstrumentation#test_thread_instrumentation [/tmp/ruby/src/trunk-repeat20-asserts/test/-ext-/thread/test_instrumentation_api.rb:33]:
Call counters[4]: [3, 4, 4, 4, 0].
Expected 0 to be > 0.
```
We fire the EXIT hook after the call to `thread_sched_to_dead` which
mean another thread might be running before the `EXIT` hook have been
executed.
fiber machine stack is placed outside of C stack allocated by wasm-ld,
so highest stack address recorded by `rb_wasm_record_stack_base` is
invalid when running on non-main fiber.
Therefore, we should scan `stack_{start,end}` which always point a valid
stack range in any context.
[Feature #18339]
After experimenting with the initial version of the API I figured there is a need
for an exit event to cleanup instrumentation data. e.g. if you record data in a
{thread_id -> data} table, you need to free associated data when a thread goes away.
`NON_SCALAR_THREAD_ID` shows `pthread_t` is non-scalar (non-pointer)
and only s390x is known platform. However, the supporting code is
very complex and it is only used for deubg print information.
So this patch removes the support of `NON_SCALAR_THREAD_ID`
and make the code simple.
`rb_thread_t` contained `native_thread_data_t` to represent
thread implementation dependent data. This patch separates
them and rename it `rb_native_thread` and point it from
`rb_thraed_t`.
Now, 1 Ruby thread (`rb_thread_t`) has 1 native thread (`rb_native_thread`).
Now GVL is not process *Global* so this patch try to use
another words.
* `rb_global_vm_lock_t` -> `struct rb_thread_sched`
* `gvl->owner` -> `sched->running`
* `gvl->waitq` -> `sched->readyq`
* `rb_gvl_init` -> `rb_thread_sched_init`
* `gvl_destroy` -> `rb_thread_sched_destroy`
* `gvl_acquire` -> `thread_sched_to_running` # waiting -> ready -> running
* `gvl_release` -> `thread_sched_to_waiting` # running -> waiting
* `gvl_yield` -> `thread_sched_yield`
* `GVL_UNLOCK_BEGIN` -> `THREAD_BLOCKING_BEGIN`
* `GVL_UNLOCK_END` -> `THREAD_BLOCKING_END`
* removed
* `rb_ractor_gvl`
* `rb_vm_gvl_destroy` (not used)
There are GVL functions such as `rb_thread_call_without_gvl()` yet
but I don't have good name to replace them. Maybe GVL stands for
"Greate Valuable Lock" or something like that.