From 37325e9f7a4734a74afde521c86c46dbb5d15258 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 22 Aug 2026 18:48:04 +0200 Subject: [PATCH 1/5] Array#flatten: optimize the common `*args.flatten` pattern A common Ruby idiom for variadic methods is to flatten the argument list so that it can be called with either variadic arguments or an Array. e.g. from the sqlite3 gem: ```ruby def bind_params(*bind_vars) bind_vars.flatten.each do |var| # ... ``` However, as soon as `flatten` encounter another array, it has to protect against recursion, which requires allocating an expensive identity Hash. So this pattern has a relatively high cost when called with a single array argument (e.g. `bind_params [1, 2, 3]`). We can specialize for that common case without noticeably impacting other usages of `Array#flatten`: | |compare-ruby|built-ruby| |:-----------------------------|-----------:|---------:| |small_flat_ary.flatten | 7.547M| 7.974M| | | -| 1.06x| |small_flat_ary.flatten! | 6.031M| 6.105M| | | -| 1.01x| |large_flat_ary.flatten | 465.896k| 485.861k| | | -| 1.04x| |large_flat_ary.flatten! | 455.872k| 478.103k| | | -| 1.05x| |small_pairs_ary.flatten | 1.327M| 1.407M| | | -| 1.06x| |small_pairs_ary.flatten! | 1.165M| 1.153M| | | 1.01x| -| |large_pairs_ary.flatten | 96.612k| 96.976k| | | -| 1.00x| |large_pairs_ary.flatten! | 94.108k| 97.289k| | | -| 1.03x| |mostly_flat_ary.flatten | 399.648k| 417.327k| | | -| 1.04x| |mostly_flat_ary.flatten! | 378.315k| 395.946k| | | -| 1.05x| |small_nested_ary.flatten | 2.513M| 7.788M| | | -| 3.10x| |small_nested_ary.flatten! | 2.024M| 5.914M| | | -| 2.92x| |large_nested_ary.flatten | 341.635k| 485.319k| | | -| 1.42x| |large_nested_ary.flatten! | 329.837k| 475.014k| | | -| 1.44x| |small_nested_ary.flatten(1) | 9.497M| 61.350M| | | -| 6.46x| |small_nested_ary.flatten!(1) | 5.136M| 17.668M| | | -| 3.44x| |large_nested_ary.flatten(1) | 1.575M| 60.241M| | | -| 38.26x| |large_nested_ary.flatten!(1) | 1.369M| 16.835M| | | -| 12.30x| --- array.c | 56 ++++++++++++++++++++++++++++++++----- benchmark/array_flatten.yml | 14 ++++++++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/array.c b/array.c index a96c5a0b9727b0..d1453c19fe7f95 100644 --- a/array.c +++ b/array.c @@ -6837,6 +6837,22 @@ flatten(VALUE ary, int level) return result; } +static inline VALUE +single_nested_array(VALUE ary) +{ + // Fast path for the common variadic argument pattern: + // def foo(*args) + // args.flatten! + // ... + if (RARRAY_LEN(ary) == 1) { + VALUE first = RARRAY_AREF(ary, 0); + if (RB_TYPE_P(first, T_ARRAY) && CLASS_OF(first) == rb_cArray) { + return first; + } + } + return 0; +} + /* * call-seq: * flatten!(depth = nil) -> self or nil @@ -6883,11 +6899,24 @@ rb_ary_flatten_bang(int argc, VALUE *argv, VALUE ary) if (!NIL_P(lv)) level = NUM2INT(lv); if (level == 0) return Qnil; - result = flatten(ary, level); - if (result == ary) { - return Qnil; + VALUE child = single_nested_array(ary); + if (child) { + if (level == 1) { + result = child; + } + else { + if (level > 1) level--; + result = flatten(child, level); + } + } + else { + result = flatten(ary, level); + if (result == ary) { + return Qnil; + } } - if (!(mod = ARY_EMBED_P(result))) rb_ary_freeze(result); + + if (!(mod = ARY_EMBED_P(result) && result != child)) rb_ary_freeze(result); rb_ary_replace(ary, result); if (mod) ARY_SET_EMBED_LEN(result, 0); @@ -6940,9 +6969,22 @@ rb_ary_flatten(int argc, VALUE *argv, VALUE ary) if (level == 0) return ary_make_shared_copy(ary); } - result = flatten(ary, level); - if (result == ary) { - result = ary_make_shared_copy(ary); + VALUE child = single_nested_array(ary); + if (child) { + if (level == 1) { + result = child; + } + else { + level--; + result = flatten(child, level); + } + } + else { + result = flatten(ary, level); + } + + if (result == ary || result == child) { + return ary_make_shared_copy(result); } return result; diff --git a/benchmark/array_flatten.yml b/benchmark/array_flatten.yml index 88ef544ba05cd1..5ef656dab4a263 100644 --- a/benchmark/array_flatten.yml +++ b/benchmark/array_flatten.yml @@ -4,16 +4,26 @@ prelude: | small_pairs_ary = [[1, 2]] * 5 large_pairs_ary = [[1, 2]] * 100 mostly_flat_ary = 100.times.to_a.push([101, 102]) + small_nested_ary = [small_flat_ary] + large_nested_ary = [large_flat_ary] benchmark: small_flat_ary.flatten: small_flat_ary.flatten - small_flat_ary.flatten!: small_flat_ary.flatten! + small_flat_ary.flatten!: small_flat_ary.dup.flatten! large_flat_ary.flatten: large_flat_ary.flatten - large_flat_ary.flatten!: large_flat_ary.flatten! + large_flat_ary.flatten!: large_flat_ary.dup.flatten! small_pairs_ary.flatten: small_pairs_ary.flatten small_pairs_ary.flatten!: small_pairs_ary.dup.flatten! large_pairs_ary.flatten: large_pairs_ary.flatten large_pairs_ary.flatten!: large_pairs_ary.dup.flatten! mostly_flat_ary.flatten: mostly_flat_ary.flatten mostly_flat_ary.flatten!: mostly_flat_ary.dup.flatten! + small_nested_ary.flatten: small_nested_ary.flatten + small_nested_ary.flatten!: small_nested_ary.dup.flatten! + large_nested_ary.flatten: large_nested_ary.flatten + large_nested_ary.flatten!: large_nested_ary.dup.flatten! + small_nested_ary.flatten(1): small_nested_ary.flatten(1) + small_nested_ary.flatten!(1): small_nested_ary.dup.flatten!(1) + large_nested_ary.flatten(1): large_nested_ary.flatten(1) + large_nested_ary.flatten!(1): large_nested_ary.dup.flatten!(1) loop_count: 10000 From 9e29520979cf952b449649146568a3fb3df7ad9d Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Fri, 21 Aug 2026 18:44:08 +0000 Subject: [PATCH 2/5] Keep a timer-thread wake batch from naming a freed thread The timer thread delivers expiry and fd wakeups in batches: it collects {thread, serial} pairs under timer_th.waiting_lock, releases the lock (the scheduler lock a wakeup takes must not nest inside it), and then wakes each thread. Unlinking an entry is what releases its thread, so from that moment the thread can be woken by somebody else, exit and be freed while the batch still holds a bare pointer to it. Waking it then dereferences a freed thread, and crashes when the Ractor holding it was torn down: [BUG] Segmentation fault at 0x0000000000000138 timer_thread_check_timeout -> timer_thread_wakeup_thread -> rb_native_mutex_lock(&TH_SCHED(th)->lock_) # th->ractor is NULL The serial captured in the batch does not help, and can even match again: a thread struct reused from the freed one starts counting event serials from zero, so its first timed wait matches serial 1 held in a stale batch entry, and the timer wakes a thread whose wheel entry is still armed: Assertion Failed: thread_sched_wait_running_turn: th->sched.waiting_reason.flags == thread_sched_waiting_none Both reproduce on a loop of Ractors that die while one of their threads sits in a timed receive, in ~15 rounds of 400. They only became reachable when e1bce29aac cut a dying Ractor's teardown from one second to well under a millisecond: the batch window used to be dwarfed by the teardown time. Holding waiting_lock across the wakes would close the window but deadlock: arming a timer takes the scheduler lock and then waiting_lock, so a wake taking the scheduler lock under waiting_lock inverts the order. So mark the threads instead. When the timer thread publishes a batch it sets in_wake_batch on each thread, under waiting_lock; when it has woken them all it clears the marks and broadcasts. A dying thread checks its own mark and waits on the cond until it clears, fencing twice: Once when it leaves the scheduler for good (thread_sched_to_dead, and the coroutine epilogue in thread_start_func_2). This is the fence that matters for a dying Ractor: a stale wakeup reaches the Ractor through TH_SCHED(), and the rb_ractor_t can be collected as soon as the Ractor is unlinked, so the wait must happen while it is still alive. Nothing re-arms the thread after this point, so no later batch can name it. Once more when the rb_thread_t itself is freed (rb_threadptr_sched_free), as a backstop for frees that do not come through a thread's own exit. The wait is bounded by one batch of at most 16 wakeups, and the fence takes only waiting_lock, which the timer thread never holds while it wakes, so the two cannot deadlock. Co-Authored-By: Claude Opus 4.8 --- thread.c | 3 +++ thread_pthread.c | 15 +++++++++++++ thread_pthread.h | 4 ++++ thread_pthread_mn.c | 53 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/thread.c b/thread.c index 78c91de8e7df90..95dffad382c77a 100644 --- a/thread.c +++ b/thread.c @@ -836,6 +836,9 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start) #if defined(USE_MN_THREADS) && USE_MN_THREADS if (th_has_coroutine(th)) { + // wait out any pending wake while th and its Ractor are still alive + rb_thread_wake_fence(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). diff --git a/thread_pthread.c b/thread_pthread.c index 4c5fdf74c9c8aa..e7846c6d2e7476 100644 --- a/thread_pthread.c +++ b/thread_pthread.c @@ -334,6 +334,7 @@ static void ractor_sched_enq(rb_vm_t *vm, rb_ractor_t *r); static void timer_thread_wakeup(void); static void timer_thread_wakeup_locked(rb_vm_t *vm); static void timer_thread_wakeup_force(void); +static void timer_thread_wake_fence(struct rb_thread_struct *th); static bool ractor_sched_timeout_arm(rb_thread_t *th, const rb_hrtime_t *rel); static bool ractor_sched_timeout_disarm(rb_thread_t *th); static void thread_sched_switch(rb_thread_t *cth, rb_thread_t *next_th); @@ -1112,6 +1113,9 @@ thread_sched_to_dead_common(struct rb_thread_sched *sched, rb_thread_t *th) static void thread_sched_to_dead(struct rb_thread_sched *sched, rb_thread_t *th) { + // wait out any pending wake here, while th's Ractor is still alive + timer_thread_wake_fence(th); + thread_sched_lock(sched, th); { thread_sched_to_dead_common(sched, th); @@ -2666,9 +2670,16 @@ thread_sched_reclaim(struct coroutine_context *dead_co) } #endif +void +rb_thread_wake_fence(rb_thread_t *th) +{ + timer_thread_wake_fence(th); +} + void rb_threadptr_sched_free(rb_thread_t *th) { + timer_thread_wake_fence(th); #if USE_MN_THREADS if (th->sched.malloc_stack) { // has dedicated @@ -3181,6 +3192,9 @@ static struct { rb_hrtime_t next_expiry; // never later than the earliest deadline struct ccan_list_head waiting_untimed; pthread_mutex_t waiting_lock; + + // signaled when wake_pending clears on a thread; see timer_thread_wake_fence + rb_nativethread_cond_t wake_pending_cond; #endif #if (HAVE_SYS_EPOLL_H || HAVE_SYS_EVENT_H) && USE_MN_THREADS @@ -3405,6 +3419,7 @@ rb_thread_create_timer_thread(void) timer_th.next_expiry = TIMER_WHEEL_NO_EXPIRY; ccan_list_head_init(&timer_th.waiting_untimed); rb_native_mutex_initialize(&timer_th.waiting_lock); + rb_native_cond_initialize(&timer_th.wake_pending_cond); #endif // open communication channel diff --git a/thread_pthread.h b/thread_pthread.h index 170c9309fa32f1..0d35dcde6c4f60 100644 --- a/thread_pthread.h +++ b/thread_pthread.h @@ -103,6 +103,9 @@ struct rb_thread_sched_item { struct rb_thread_sched_waiting waiting_reason; uint32_t event_serial; + // the timer thread has a wake pending for this thread; under waiting_lock + bool wake_pending; + bool malloc_stack; void *context_stack; size_t context_stack_size; @@ -230,5 +233,6 @@ RUBY_EXTERN native_tls_key_t ruby_current_ec_key; struct rb_ractor_struct; void rb_ractor_sched_wait(struct rb_execution_context_struct *ec, struct rb_ractor_struct *cr, rb_unblock_function_t *ubf, void *ptr); void rb_ractor_sched_wakeup(struct rb_ractor_struct *r, struct rb_thread_struct *th); +void rb_thread_wake_fence(struct rb_thread_struct *th); #endif /* RUBY_THREAD_PTHREAD_H */ diff --git a/thread_pthread_mn.c b/thread_pthread_mn.c index d1bbd362c58039..97674d466d51f2 100644 --- a/thread_pthread_mn.c +++ b/thread_pthread_mn.c @@ -260,6 +260,44 @@ timer_thread_wakeup_thread(rb_thread_t *th, uint32_t event_serial) #define TIMEOUT_WAKE_BATCH 16 +// One thread the timer thread is about to wake, with the serial it was armed at. +struct timer_wake { rb_thread_t *th; uint32_t serial; }; + +// Mark each thread while a wake is pending for it, so a dying thread can wait +// (timer_thread_wake_fence). Set under waiting_lock before the lock is dropped. +static void +timer_wake_pending_set(struct timer_wake *batch, int n) +{ + for (int i = 0; i < n; i++) { + batch[i].th->sched.wake_pending = true; + } +} + +static void +timer_wake_pending_clear(struct timer_wake *batch, int n) +{ + rb_native_mutex_lock(&timer_th.waiting_lock); + for (int i = 0; i < n; i++) { + batch[i].th->sched.wake_pending = false; + } + rb_native_cond_broadcast(&timer_th.wake_pending_cond); + rb_native_mutex_unlock(&timer_th.waiting_lock); +} + +// Wait out a pending wake before a thread is freed: it would touch freed memory, +// or wake a reused thread whose first serial matches the stale entry. +static void +timer_thread_wake_fence(rb_thread_t *th) +{ + if (!TIMER_THREAD_CREATED_P()) return; + + rb_native_mutex_lock(&timer_th.waiting_lock); + while (th->sched.wake_pending) { + rb_native_cond_wait(&timer_th.wake_pending_cond, &timer_th.waiting_lock); + } + rb_native_mutex_unlock(&timer_th.waiting_lock); +} + static void timer_thread_check_timeout(rb_vm_t *vm) { @@ -269,7 +307,7 @@ timer_thread_check_timeout(rb_vm_t *vm) ccan_list_head_init(&expired); - struct timeout_wake { rb_thread_t *th; uint32_t serial; } batch[TIMEOUT_WAKE_BATCH]; + struct timer_wake batch[TIMEOUT_WAKE_BATCH]; bool more = true; while (more) { @@ -294,12 +332,14 @@ timer_thread_check_timeout(rb_vm_t *vm) n++; } more = !ccan_list_empty(&expired); + timer_wake_pending_set(batch, n); } rb_native_mutex_unlock(&timer_th.waiting_lock); for (int i = 0; i < n; i++) { timer_thread_wakeup_thread(batch[i].th, batch[i].serial); } + timer_wake_pending_clear(batch, n); } } @@ -1452,7 +1492,7 @@ event_wait(rb_vm_t *vm) static void timer_thread_wake_fd_waiters(int fd, uint32_t generation, uint32_t wake_flags, int result) { - struct { rb_thread_t *th; uint32_t serial; } batch[FD_WAKE_BATCH]; + struct timer_wake batch[FD_WAKE_BATCH]; if (wake_flags == 0) return; @@ -1493,12 +1533,15 @@ timer_thread_wake_fd_waiters(int fd, uint32_t generation, uint32_t wake_flags, i // they all just woke up). fd_waiters_arm(fd, e, fd_waiters_union(e)); } + + timer_wake_pending_set(batch, n); } rb_native_mutex_unlock(&timer_th.waiting_lock); for (int i = 0; i < n; i++) { timer_thread_wakeup_thread(batch[i].th, batch[i].serial); } + timer_wake_pending_clear(batch, n); if (!more) break; } @@ -1698,6 +1741,12 @@ timer_wheel_timeout(int timeout) return timeout; // no M:N threads, no timed waiters } +static void +timer_thread_wake_fence(rb_thread_t *th) +{ + // no timer wheel, no wake batches +} + static void timer_thread_check_timeout(rb_vm_t *vm) { From 0135fd2995035182f1ae53179a2e7a4c9857fda5 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Fri, 21 Aug 2026 15:25:35 +0000 Subject: [PATCH 3/5] Take a timed sleep on the scheduler condvar, not on a second one A dedicated native thread used to sleep in two stages: native_cond_sleep() parked it on nt->cond.intr inside a blocking region, and on wakeup it went back to the scheduler to wait for its running turn on nt->cond.readyq. The thread scheduler's turn wait takes an absolute deadline since d32793fe8e, so the sleep can happen right there: native_sleep() now parks every dedicated thread in thread_sched_to_waiting_until_wakeup(), with a deadline when it has one, and an M:N thread keeps using the timer wheel. The second condvar, its ubf and native_cond_sleep() go away, and with them the union/struct dance in struct rb_native_thread for platforms whose condvars remember their mutex: the one condvar left always pairs with sched->lock_. Two things keep the handoff as fast as the old path: The turn wait trusts ETIMEDOUT to say the deadline passed, instead of reading the clock on every wakeup; on clocksources where clock_gettime is a real syscall that read was the single biggest cost of a timed wakeup. ubf_waiting() wakes the target even when the running turn is taken, knowing it re-parks at once. The old two-stage sleep did the same thing by its shape, and it is worth doing on purpose: the woken thread's futex wakeup runs on another core in parallel with the running thread, so by the time the turn is handed over it is off the handoff path. Measured on a queue-with-timeout ping-pong, the handoff costs what the two-stage sleep cost; without the early wake it was three times slower. The ubf must also leave alone a thread whose deadline already put it back in the ready queue: waking it a second time would double-enqueue it. Co-Authored-By: Claude Opus 4.8 --- thread.c | 20 +------ thread_pthread.c | 148 +++++++++++++++++++---------------------------- thread_pthread.h | 21 +++---- 3 files changed, 70 insertions(+), 119 deletions(-) diff --git a/thread.c b/thread.c index 95dffad382c77a..85dfaddb44c51e 100644 --- a/thread.c +++ b/thread.c @@ -1509,26 +1509,12 @@ hrtime_update_expire(rb_hrtime_t *timeout, const rb_hrtime_t end) } COMPILER_WARNING_POP +static int sleep_hrtime_until(rb_thread_t *th, rb_hrtime_t end, unsigned int fl); + static int sleep_hrtime(rb_thread_t *th, rb_hrtime_t rel, unsigned int fl) { - enum rb_thread_status prev_status = th->status; - int woke; - rb_hrtime_t end = rb_hrtime_add(rb_hrtime_now(), rel); - - th->status = THREAD_STOPPED; - RUBY_VM_CHECK_INTS_BLOCKING(th->ec); - while (th->status == THREAD_STOPPED) { - native_sleep(th, &rel); - woke = vm_check_ints_blocking(th->ec); - if (woke && !(fl & SLEEP_SPURIOUS_CHECK)) - break; - if (hrtime_update_expire(&rel, end)) - break; - woke = 1; - } - th->status = prev_status; - return woke; + return sleep_hrtime_until(th, rb_hrtime_add(rb_hrtime_now(), rel), fl); } static int diff --git a/thread_pthread.c b/thread_pthread.c index e7846c6d2e7476..44b02032a6fa69 100644 --- a/thread_pthread.c +++ b/thread_pthread.c @@ -808,7 +808,7 @@ thread_sched_wakeup_running_thread(struct rb_thread_sched *sched, rb_thread_t *n if (next_th->nt) { if (th_has_dedicated_nt(next_th)) { RUBY_DEBUG_LOG("pinning th:%u", next_th->serial); - rb_native_cond_signal(&next_th->nt->cond.readyq); + rb_native_cond_signal(&next_th->nt->readyq); } else { // TODO @@ -875,6 +875,8 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b ASSERT_thread_sched_locked(sched, th); VM_ASSERT(th == rb_ec_thread_ptr(rb_current_ec_noinline())); + bool timedout = false; + if (th != sched->running) { // TODO: This optimization should also be made to work for MN_THREADS if (th->has_dedicated_nt && th == sched->runnable_hot_th && (sched->running == NULL || sched->running->has_dedicated_nt)) { @@ -924,10 +926,16 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b thread_sched_set_unlocked(sched, th); { - RUBY_DEBUG_LOG("nt:%d cond:%p", th->nt->serial, &th->nt->cond.readyq); - rb_nativethread_cond_t *cond = &th->nt->cond.readyq; - - if (end) { + RUBY_DEBUG_LOG("nt:%d cond:%p", th->nt->serial, &th->nt->readyq); + rb_nativethread_cond_t *cond = &th->nt->readyq; + + // Once someone has queued this thread the deadline is spent: it + // is waiting for a turn, not for the time, and arming a kernel + // timer for every round of that costs more than the wait. + // Once someone has queued this thread the deadline is spent: it + // is waiting for a turn, not for the time, and arming a kernel + // timer for every round of that costs more than the wait. + if (end && !th->sched.node.is_ready) { rb_hrtime_t abs = *end; if (!condattr_monotonic) { @@ -935,7 +943,7 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b rb_hrtime_t now = rb_hrtime_now(); abs = native_cond_timeout(cond, *end > now ? *end - now : 0); } - native_cond_timedwait(cond, &sched->lock_, &abs); + timedout = native_cond_timedwait(cond, &sched->lock_, &abs) == ETIMEDOUT; } else { rb_native_cond_wait(cond, &sched->lock_); @@ -943,7 +951,7 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b } thread_sched_set_locked(sched, th); - if (end && rb_hrtime_now() >= *end && + if (timedout && sched->running != th && !th->sched.node.is_ready) { // the deadline passed and nobody woke this thread: get back in // line for the running turn, then wait for it without a deadline @@ -1215,11 +1223,21 @@ ubf_waiting(void *ptr) thread_sched_lock(sched, th); { - if (sched->running == th) { - // not sleeping yet. + if (sched->running == th || th->sched.node.is_ready) { + // not sleeping yet, or a deadline already put it back in line } else { thread_sched_to_ready_common(sched, th, true, false); + + // If the turn is taken, th stays parked until the running thread yields. + // For a timed wait, wake it early anyway: it re-parks at once, but its + // wakeup then runs on another core in parallel with the running thread, + // off the handoff path. An untimed wait has no post-wake bookkeeping + // worth pipelining, so it skips the extra futex round. + if (sched->running != th && th->sched.waiting_timed && + th->nt != NULL && th_has_dedicated_nt(th)) { + rb_native_cond_signal(&th->nt->readyq); + } } } thread_sched_unlock(sched, th); @@ -1227,12 +1245,16 @@ ubf_waiting(void *ptr) // running -> waiting // -// This thread will sleep until other thread wakeup the thread. +// This thread will sleep until other thread wakeup the thread. `end` is an +// absolute deadline, NULL to sleep until woken; only a dedicated native thread, +// which parks on its own condvar, can take one. static void -thread_sched_to_waiting_until_wakeup(struct rb_thread_sched *sched, rb_thread_t *th) +thread_sched_to_waiting_until_wakeup(struct rb_thread_sched *sched, rb_thread_t *th, const rb_hrtime_t *end) { RUBY_DEBUG_LOG("th:%u", rb_th_serial(th)); + VM_ASSERT(end == NULL || th_has_dedicated_nt(th)); + RB_VM_SAVE_MACHINE_CONTEXT(th); @@ -1246,9 +1268,11 @@ thread_sched_to_waiting_until_wakeup(struct rb_thread_sched *sched, rb_thread_t } else { bool can_direct_transfer = !th_has_dedicated_nt(th); + th->sched.waiting_timed = (end != NULL); // never true here for M:N (end is NULL) // NOTE: th->status is set before and after this sleep outside of this function in `sleep_forever` thread_sched_wakeup_next_thread(sched, th, can_direct_transfer); - thread_sched_wait_running_turn(sched, th, can_direct_transfer, NULL); + thread_sched_wait_running_turn(sched, th, can_direct_transfer, end); + th->sched.waiting_timed = false; } } thread_sched_unlock(sched, th); @@ -1593,10 +1617,12 @@ rb_ractor_sched_wait(rb_execution_context_t *ec, rb_ractor_t *cr, rb_unblock_fun bool can_direct_transfer = !dedicated; RB_VM_SAVE_MACHINE_CONTEXT(th); th->status = THREAD_STOPPED_FOREVER; + th->sched.waiting_timed = (end_p != NULL); // never true here for M:N (end_p is NULL) RB_INTERNAL_THREAD_HOOK(RUBY_INTERNAL_THREAD_EVENT_SUSPENDED, th); thread_sched_wakeup_next_thread(sched, th, can_direct_transfer); // sleep thread_sched_wait_running_turn(sched, th, can_direct_transfer, end_p); + th->sched.waiting_timed = false; th->status = THREAD_RUNNABLE; // whoever woke this thread took the timeout back first @@ -2141,11 +2167,7 @@ static void native_thread_destroy(struct rb_native_thread *nt) { if (nt) { - rb_native_cond_destroy(&nt->cond.readyq); - - if (&nt->cond.readyq != &nt->cond.intr) { - rb_native_cond_destroy(&nt->cond.intr); - } + rb_native_cond_destroy(&nt->readyq); native_thread_destroy_atfork(nt); } @@ -2451,11 +2473,7 @@ static void native_thread_setup(struct rb_native_thread *nt) { // init cond - rb_native_cond_initialize(&nt->cond.readyq); - - if (&nt->cond.readyq != &nt->cond.intr) { - rb_native_cond_initialize(&nt->cond.intr); - } + rb_native_cond_initialize(&nt->readyq); } static void @@ -2760,64 +2778,6 @@ native_fd_select(int n, rb_fdset_t *readfds, rb_fdset_t *writefds, rb_fdset_t *e return rb_fd_select(n, readfds, writefds, exceptfds, timeout); } -static void -ubf_pthread_cond_signal(void *ptr) -{ - rb_thread_t *th = (rb_thread_t *)ptr; - RUBY_DEBUG_LOG("th:%u on nt:%d", rb_th_serial(th), (int)th->nt->serial); - rb_native_cond_signal(&th->nt->cond.intr); -} - -static void -native_cond_sleep(rb_thread_t *th, rb_hrtime_t *rel) -{ - rb_nativethread_lock_t *lock = &th->interrupt_lock; - rb_nativethread_cond_t *cond = &th->nt->cond.intr; - - /* Solaris cond_timedwait() return EINVAL if an argument is greater than - * current_time + 100,000,000. So cut up to 100,000,000. This is - * considered as a kind of spurious wakeup. The caller to native_sleep - * should care about spurious wakeup. - * - * See also [Bug #1341] [ruby-core:29702] - * http://download.oracle.com/docs/cd/E19683-01/816-0216/6m6ngupgv/index.html - */ - const rb_hrtime_t max = (rb_hrtime_t)100000000 * RB_HRTIME_PER_SEC; - - THREAD_BLOCKING_BEGIN(th); - { - rb_native_mutex_lock(lock); - th->unblock.func = ubf_pthread_cond_signal; - th->unblock.arg = th; - - if (RUBY_VM_INTERRUPTED(th->ec)) { - /* interrupted. return immediate */ - RUBY_DEBUG_LOG("interrupted before sleep th:%u", rb_th_serial(th)); - } - else { - if (!rel) { - rb_native_cond_wait(cond, lock); - } - else { - rb_hrtime_t end; - - if (*rel > max) { - *rel = max; - } - - end = native_cond_timeout(cond, *rel); - native_cond_timedwait(cond, lock, &end); - } - } - th->unblock.func = 0; - - rb_native_mutex_unlock(lock); - } - THREAD_BLOCKING_END(th); - - RUBY_DEBUG_LOG("done th:%u", rb_th_serial(th)); -} - #ifdef USE_UBF_LIST static CCAN_LIST_HEAD(ubf_list_head); static rb_nativethread_lock_t ubf_list_lock = RB_NATIVETHREAD_LOCK_INIT; @@ -3571,16 +3531,28 @@ native_sleep(rb_thread_t *th, rb_hrtime_t *rel) struct rb_thread_sched *sched = TH_SCHED(th); RUBY_DEBUG_LOG("rel:%d", rel ? (int)*rel : 0); - if (rel) { - if (th_has_dedicated_nt(th)) { - native_cond_sleep(th, rel); - } - else { - thread_sched_wait_events(sched, th, -1, thread_sched_waiting_timeout, rel); - } + + if (rel && !th_has_dedicated_nt(th)) { + // an M:N thread has no condvar of its own: the timer thread wakes it + thread_sched_wait_events(sched, th, -1, thread_sched_waiting_timeout, rel); + } + else if (rel) { + /* Solaris cond_timedwait() returns EINVAL if an argument is greater than + * current_time + 100,000,000. So cut up to 100,000,000. This is + * considered as a kind of spurious wakeup. The caller to native_sleep + * should care about spurious wakeup. + * + * See also [Bug #1341] [ruby-core:29702] + * http://download.oracle.com/docs/cd/E19683-01/816-0216/6m6ngupgv/index.html + */ + const rb_hrtime_t max = (rb_hrtime_t)100000000 * RB_HRTIME_PER_SEC; + if (*rel > max) *rel = max; + + rb_hrtime_t end = rb_hrtime_add(rb_hrtime_now(), *rel); + thread_sched_to_waiting_until_wakeup(sched, th, &end); } else { - thread_sched_to_waiting_until_wakeup(sched, th); + thread_sched_to_waiting_until_wakeup(sched, th, NULL); } RUBY_DEBUG_LOG("wakeup"); diff --git a/thread_pthread.h b/thread_pthread.h index 0d35dcde6c4f60..04e3de8845278d 100644 --- a/thread_pthread.h +++ b/thread_pthread.h @@ -106,6 +106,11 @@ struct rb_thread_sched_item { // the timer thread has a wake pending for this thread; under waiting_lock bool wake_pending; + // parked on its own condvar with a deadline; under the sched lock (see + // ubf_waiting). Always false for an M:N thread: its deadline lives on the + // timer wheel, and its early wake comes from the timer thread instead. + bool waiting_timed; + bool malloc_stack; void *context_stack; size_t context_stack_size; @@ -124,20 +129,8 @@ struct rb_native_thread { struct rb_thread_struct *running_thread; - // to control native thread -#if defined(__GLIBC__) || defined(__FreeBSD__) - union -#else - /* - * assume the platform condvars are badly implemented and have a - * "memory" of which mutex they're associated with - */ - struct -#endif - { - rb_nativethread_cond_t intr; /* th->interrupt_lock */ - rb_nativethread_cond_t readyq; /* use sched->lock */ - } cond; + // to control native thread; use sched->lock + rb_nativethread_cond_t readyq; #ifdef USE_SIGALTSTACK void *altstack; From 5b9c84ca175cbd09b861aad2da826c0dfc1f3de5 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sat, 22 Aug 2026 04:39:34 +0000 Subject: [PATCH 4/5] A forked child has one Ractor, but not one objspace rb_gc_single_objspace_p() answered "single" as soon as ruby_single_main_ractor was set, and rb_ractor_atfork() sets it again in the child. The pre-fork Ractors' objspaces are still parked in zombie_objspaces at that point, so the child's local GC skipped pinned_roots_mark and swept live shareable objects: a Ractor wrapper still named by a foreign Ractor::Port, or a cc in a class's cc_table. The next mark then walked freed memory. Ask the rest of the conditions in that case too; one Ractor is not one objspace. Co-Authored-By: Claude Opus 5 (1M context) --- gc.c | 7 +++++-- test/ruby/test_ractor.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/gc.c b/gc.c index f72d2c09af374c..993ccdd5446224 100644 --- a/gc.c +++ b/gc.c @@ -4183,9 +4183,12 @@ rb_gc_obj_foreign_p(VALUE obj) bool rb_gc_single_objspace_p(void) { - if (!rb_gc_impl_multi_objspace_p() || ruby_single_main_ractor) return true; + if (!rb_gc_impl_multi_objspace_p()) return true; rb_vm_t *vm = GET_VM(); - return vm->ractor.cnt == 1 && vm->gc.zombie_objspaces_count == 0 && gc_absorbing_zombie == 0 && + /* One Ractor is not one objspace: a forked child re-enters single-Ractor mode while + * the pre-fork Ractors' objspaces are still parked in zombie_objspaces. */ + return (ruby_single_main_ractor != NULL || vm->ractor.cnt == 1) && + vm->gc.zombie_objspaces_count == 0 && gc_absorbing_zombie == 0 && !gc_absorbed_since_global_gc && (vm->ractor.main_ractor == NULL || vm->ractor.main_ractor->creating_child_objspace == NULL); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 0901757201740d..e435d0856c72bf 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -272,6 +272,21 @@ def test_create_many_ports_with_gc_stress RUBY end + def test_fork_child_gc_pins_shareable_objects + # A forked child re-enters single-Ractor mode while the Ractors it had before the + # fork leave their objspaces behind, so its local GC still has to pin shareable + # objects instead of collecting them. + assert_ractor(<<~'RUBY') + port = Ractor::Port.new + Ractor.new(port) { |p| p << Ractor::Port.new; Ractor.receive } + foreign_port = port.receive # a Port owned by, and allocated in, the other Ractor + pid = fork { 100_000.times { +"x" }; exit!(0) } + _, status = Process.waitpid2(pid) + assert_predicate status, :success? + assert_instance_of Ractor::Port, foreign_port + RUBY + end if Process.respond_to?(:fork) + def test_fork_raise_isolation_error assert_ractor(<<~'RUBY') ractor = Ractor.new do From 2e2b54648c227cca75f9cce8e8cecf1075e96caf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 23 Aug 2026 12:14:43 +1200 Subject: [PATCH 5/5] Normalize empty `IO::Buffer` state. (#18443) --- io_buffer.c | 10 +++------- test/ruby/test_io_buffer.rb | 10 ++++++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/io_buffer.c b/io_buffer.c index 0da1bf5cd8017e..6b8d834f9b28d6 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -191,6 +191,7 @@ io_buffer_zero(struct rb_io_buffer *buffer) { buffer->base = NULL; buffer->size = 0; + buffer->flags = 0; buffer->lock_count = 0; #if defined(_WIN32) buffer->mapping = NULL; @@ -259,13 +260,6 @@ io_buffer_free(struct rb_io_buffer *buffer) // if (RB_TYPE_P(buffer->source, T_STRING)) { // rb_str_unlocktmp(buffer->source); // } - - buffer->base = NULL; - - buffer->size = 0; - buffer->flags = 0; - buffer->lock_count = 0; - buffer->source = Qnil; } #if defined(_WIN32) @@ -277,6 +271,8 @@ io_buffer_free(struct rb_io_buffer *buffer) buffer->mapping = NULL; } #endif + + io_buffer_zero(buffer); } static void diff --git a/test/ruby/test_io_buffer.rb b/test/ruby/test_io_buffer.rb index 2c0135b970a7bd..53c4fd7a2ba6fa 100644 --- a/test/ruby/test_io_buffer.rb +++ b/test/ruby/test_io_buffer.rb @@ -453,6 +453,16 @@ def test_transfer transferred = buffer.transfer assert_equal "Hello World", transferred.get_string assert_predicate buffer, :null? + assert_predicate buffer, :empty? + assert_predicate buffer, :valid? + refute_predicate buffer, :external? + refute_predicate buffer, :internal? + refute_predicate buffer, :mapped? + refute_predicate buffer, :shared? + refute_predicate buffer, :private? + refute_predicate buffer, :readonly? + assert_equal "", buffer.get_string + assert_equal 0, buffer.set_string("") assert_raise IO::Buffer::AccessError do transferred.set_string("Goodbye") end