Skip to content

[pull] master from ruby:master - #1337

Merged
pull[bot] merged 5 commits into
turkdevops:masterfrom
ruby:master
Aug 23, 2026
Merged

[pull] master from ruby:master#1337
pull[bot] merged 5 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

@pull pull Bot commented Aug 23, 2026

Copy link
Copy Markdown

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 : )

byroot and others added 5 commits August 22, 2026 22:13
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>
@pull pull Bot locked and limited conversation to collaborators Aug 23, 2026
@pull pull Bot added the ⤵️ pull label Aug 23, 2026
@pull
pull Bot merged commit 2e2b546 into turkdevops:master Aug 23, 2026
1 of 3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants