[pull] master from ruby:master - #1337
Merged
Merged
Conversation
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|
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
e1bce29 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 <noreply@anthropic.com>
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 d32793f, 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )