Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0a87db4
Release empty heap pages under one pool lock
eightbitraptor Sep 1, 2026
ac44788
Use ERRORCHECK to assert the page pool mutex
eightbitraptor Sep 3, 2026
ada2be0
Take the pool lock once for non-mmap batch page release
eightbitraptor Sep 7, 2026
b6830f5
[ruby/rubygems] Reject Bundler redirects that downgrade https to http
hsbt Sep 7, 2026
db84d03
[Bug #19383] Fix Time#zone encoding when TZ is set on Windows
hsbt Sep 7, 2026
3f2e039
[DOC] Tweaks for File::stat and File::lstat
BurdetteLamar Sep 7, 2026
b6ed8bb
[Bug #21697] Keep revision.h in a source tree without VCS
hsbt Sep 3, 2026
3c2856d
Bump taiki-e/install-action
dependabot[bot] Sep 8, 2026
c0fdf81
Check struct size when a user class relabels a loaded struct
hsbt Sep 7, 2026
21bc49c
[ruby/net-http] Require chunked to be the final transfer coding
hsbt Sep 7, 2026
4eb03b3
[ruby/net-http] Let Transfer-Encoding override Content-Length
hsbt Sep 7, 2026
4d764e6
Wait for terminating Ractors through the scheduler
ko1 Sep 7, 2026
ce6f200
RUBY_MN_THREADS=2: run the main thread as an M:N thread too
ko1 Sep 7, 2026
c5777a3
RUBY_MN_THREADS=-1: do not use the M:N scheduler at all
ko1 Sep 7, 2026
09f163d
NEWS: RUBY_MN_THREADS
ko1 Sep 7, 2026
eb9df91
Fix boundary check at broken widechar string
nobu Apr 15, 2026
4cf8d4f
Fix `lines` at broken wchar string
nobu Apr 15, 2026
ac9cc92
Fix boundary check in rindex searching multibyte string
nobu Apr 15, 2026
2cae75a
Fix the remaining rindex boundary checks
hsbt Sep 8, 2026
be00aa8
Do not look in front of a receiver shorter than one character in chomp
hsbt Sep 8, 2026
8762275
Fix max_output of the UTF-16 and UTF-32 encoders
hsbt Sep 7, 2026
720d9e1
Fix max_output of the CP50220 encoder
hsbt Sep 7, 2026
4d3263c
[ruby/rubygems] Normalize absolute symlink targets before checking th…
hsbt Sep 8, 2026
c9764e8
Avoid quadratic parse time on and/or chains (#18638)
makenowjust Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/zjit-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
rustup install ${{ matrix.rust_version }} --profile minimal
rustup default ${{ matrix.rust_version }}

- uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2.87.5
- uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6
with:
tool: nextest@0.9
if: ${{ matrix.test_task == 'zjit-check' }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zjit-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ jobs:
ruby-version: '3.1'
bundler: none

- uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2.87.5
- uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6
with:
tool: nextest@0.9
if: ${{ matrix.test_task == 'zjit-check' }}
Expand Down
46 changes: 46 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,52 @@ A lot of work has gone into making Ractors more stable, performant, and usable.
* `ObjectSpace.define_finalizer` on another Ractor's object raises
`Ractor::IsolationError`.

### M:N thread scheduler

* The scheduler scales with the number of waiters and of Ractors, where it
used to walk a list or take one lock for all of them:

* A timed wait sits in a hierarchical timer wheel rather than on a list
sorted by deadline, which was inserted into by a linear scan.
* An fd stays armed in the backend between waits, instead of being added
before each wait and removed after each wake.
* The io-wait bookkeeping is sharded by fd, rather than serialized on one
lock across every fd.
* A timed wait on an fd rides the scheduler instead of going to a blocking
region, which cost a native thread handoff per wait.
* A context switch, and leaving or rejoining the shared pool, no longer take
the scheduler's global lock.

* The `RUBY_MN_THREADS` environment variable now runs from no M:N scheduling
at all to all of it. `-1` is new: a Ractor's threads have been M:N since
the scheduler was added, with no way to turn that off. `0` and `1` are
unchanged.

| | main thread | the main Ractor's other threads | a Ractor's threads |
|---|---|---|---|
| `-1` | 1:1 | 1:1 | 1:1 |
| `0` or unset | 1:1 | 1:1 | M:N |
| `1` | 1:1 | M:N | M:N |
| `2` | M:N | M:N | M:N |

* `RUBY_MN_THREADS=2` is new. The main thread is resumed like any other M:N
thread rather than woken on a native thread of its own, which costs an order
of magnitude more. It pays off when the main thread drives the work, and
does nothing for one that only starts other threads and waits.

The main thread is then no longer bound to one OS thread, which is what the
M:N scheduler already meant for every other thread:

* A C extension that keeps state per OS thread has to call
`rb_thread_lock_native_thread()`.
* What must run on the process's initial thread does not work at all,
pinning included: macOS AppKit and CFRunLoop, and hosts that embed Ruby
and return into their own main loop.

* The OS thread name is no longer set from the Ruby thread for M:N threads:
one native thread runs many of them over its life. `Thread#name=` was
already skipped for the same reason.

## JIT

[Bug #18947]: https://bugs.ruby-lang.org/issues/18947
Expand Down
5 changes: 3 additions & 2 deletions common.mk
Original file line number Diff line number Diff line change
Expand Up @@ -1520,9 +1520,10 @@ after-update:: extract-extlibs
after-update:: extract-gems
after-update:: update-default-gemspecs

# Do not remove or empty revision.h itself, whose content file2lastrev.rb
# keeps when the source tree has no VCS.
update-src::
$(Q) $(RM) $(REVISION_H) revision.h "$(srcdir)/$(REVISION_H)" "$(srcdir)/revision.h"
$(Q) exit > "$(srcdir)/revision.h"
$(Q) $(RM) $(REVISION_H) "$(srcdir)/$(REVISION_H)"

# $(REVISION_H) can have been made already in this run, as a prerequisite
# of the included dependency file, and make does not make it twice.
Expand Down
2 changes: 1 addition & 1 deletion enc/trans/iso2022.trans
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ rb_cp50220_encoder = {
TRANSCODE_TABLE_INFO,
1, /* input_unit_length */
3, /* max_input */
5, /* max_output */
9, /* max_output */
asciicompat_encoder, /* asciicompat_type */
3, iso2022jp_init, iso2022jp_init, /* state_size, state_init, state_fini */
NULL, NULL, NULL, fun_so_cp50220_encoder,
Expand Down
4 changes: 2 additions & 2 deletions enc/trans/utf_16_32.trans
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ rb_to_UTF_16 = {
TRANSCODE_TABLE_INFO,
1, /* input_unit_length */
4, /* max_input */
4, /* max_output */
6, /* max_output */
asciicompat_encoder, /* asciicompat_type */
1, state_init, NULL, /* state_size, state_init, state_fini */
NULL, NULL, NULL, fun_so_to_utf_16
Expand All @@ -533,7 +533,7 @@ rb_to_UTF_32 = {
TRANSCODE_TABLE_INFO,
1, /* input_unit_length */
4, /* max_input */
4, /* max_output */
8, /* max_output */
asciicompat_encoder, /* asciicompat_type */
1, state_init, NULL, /* state_size, state_init, state_fini */
NULL, NULL, NULL, fun_so_to_utf_32
Expand Down
52 changes: 34 additions & 18 deletions file.c
Original file line number Diff line number Diff line change
Expand Up @@ -1505,12 +1505,28 @@ rb_stat(VALUE file, struct stat *st)
}

/*
* :markup: markdown
*
* call-seq:
* File.stat(filepath) -> stat
* File.stat(path) -> file_stat
*
* Returns a File::Stat object for the file at +filepath+ (see File::Stat):
* Returns a new File::Stat object for the entry at `path`.
* Follows [symbolic links](file/symbolic_links.md);
* therefore if the entry is a symbolic link,
* the returned object contains information for the target entry, not the symbolic link:
*
* File.stat('t.txt').class # => File::Stat
* ```ruby
* filepath = 'README.md'
* linkpath = 'foo'
* File.symlink(filepath, linkpath)
* # Method File.stat follows the symlink, so the birthtimes are the same.
* File.stat(filepath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* File.stat(linkpath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* # Method File.lstat does not follow the symlink, so the birthtimes are different.
* File.lstat(filepath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* File.lstat(linkpath).birthtime # => 2026-09-04 10:29:45.884317953 -0500
* File.unlink(linkpath) # Clean up.
* ```
*
*/

Expand Down Expand Up @@ -1579,24 +1595,24 @@ lstat_without_gvl(const char *path, struct stat *st)
* :markup: markdown
*
* call-seq:
* File.lstat(path) -> new_stat
* File.lstat(path) -> file_stat
*
* Returns a File::Stat object for the entry at `path`;
* does not follow symbolic links,
* and therefore returns the stat object for `path`,
* regardless of whether it is a symbolic link:
* Returns a new File::Stat object for the entry at `path`.
* Does not follow [symbolic links](file/symbolic_links.md);
* therefore the returned object contains information for the entry at `path`,
* regardless of whether is a symbolic link:
*
* ```ruby
* File.write('t.tmp', '')
* sleep(1)
* File.symlink('t.tmp', 'link')
* file = File.new('link', 'r')
* # Method stat: follows link to 't.tmp'.
* file.stat.ctime # => 2026-06-13 15:05:16.996527996 -0500
* # Method lstat; does not follow link.
* file.lstat.ctime # => 2026-06-13 15:05:17.997527947 -0500
* File.delete('t.tmp')
* File.delete('link')
* filepath = 'README.md'
* linkpath = 'foo'
* File.symlink(filepath, linkpath)
* # Method File.stat follows the symlink, so the birthtimes are the same.
* File.stat(filepath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* File.stat(linkpath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* # Method File.lstat does not follow the symlink, so the birthtimes are different.
* File.lstat(filepath).birthtime # => 2026-09-01 09:09:28.378987388 -0500
* File.lstat(linkpath).birthtime # => 2026-09-04 10:29:45.884317953 -0500
* File.unlink(linkpath) # Clean up.
* ```
*
*/
Expand Down
126 changes: 105 additions & 21 deletions gc/default/default.c
Original file line number Diff line number Diff line change
Expand Up @@ -852,14 +852,35 @@ static void objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src);

static struct heap_page_body *page_pool_acquire(struct page_arena **arena_out);
static void page_pool_release(struct heap_page_body *body, struct page_arena *arena);
#ifdef HAVE_MMAP
static void page_pool_release_locked(struct heap_page_body *body, struct page_arena *arena);
#endif
static void page_pool_reclaim(rb_global_objspace_t *g);

#if RGENGC_CHECK_MODE && !defined(_WIN32) && !defined(__wasi__) && defined(HAVE_PTHREAD_H)
# define PAGE_POOL_LOCK_ERRORCHECK 1
#endif

static void
page_pool_lock_initialize(rb_nativethread_lock_t *lock)
{
#ifdef PAGE_POOL_LOCK_ERRORCHECK
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
pthread_mutex_init(lock, &attr);
pthread_mutexattr_destroy(&attr);
#else
rb_native_mutex_initialize(lock);
#endif
}

static void
global_objspace_init(void)
{
if (global_objspace == NULL) {
rb_global_objspace_t *g = &rb_global_objspace_instance;
rb_native_mutex_initialize(&g->page_pool.lock);
page_pool_lock_initialize(&g->page_pool.lock);
g->page_pool.hot_list = NULL;
g->page_pool.hot_count = 0;
g->page_pool.arenas = NULL;
Expand Down Expand Up @@ -2224,6 +2245,12 @@ heap_page_body_free(struct heap_page_body *page_body, struct page_arena *arena)
page_pool_release(page_body, arena);
}

#ifdef PAGE_POOL_LOCK_ERRORCHECK
# define ASSERT_PAGE_POOL_LOCKED(g) GC_ASSERT(pthread_mutex_lock(&(g)->page_pool.lock) == EDEADLK)
#else
# define ASSERT_PAGE_POOL_LOCKED(g) ((void)0)
#endif

/* Insert into page_index. Writers serialize on page_pool.lock; lomem and himem are a
* monotonically growing over-approximation used for a quick reject. */
static void
Expand Down Expand Up @@ -2259,12 +2286,13 @@ global_page_index_insert(struct heap_page *page)
}

static void
global_page_index_remove(const struct heap_page *page)
global_page_index_remove_locked(const struct heap_page *page)
{
rb_global_objspace_t *g = global_objspace;
uintptr_t body = (uintptr_t)page->body;

rb_native_mutex_lock(&g->page_pool.lock);
ASSERT_PAGE_POOL_LOCKED(g);

size_t lo = 0, hi = g->page_index.n_pages;
while (lo < hi) {
size_t mid = (lo + hi) / 2;
Expand All @@ -2275,6 +2303,15 @@ global_page_index_remove(const struct heap_page *page)
memmove(&g->page_index.pages[lo], &g->page_index.pages[lo + 1],
(g->page_index.n_pages - lo - 1) * sizeof(struct heap_page *));
g->page_index.n_pages--;
}

static void
global_page_index_remove(const struct heap_page *page)
{
rb_global_objspace_t *g = global_objspace;

rb_native_mutex_lock(&g->page_pool.lock);
global_page_index_remove_locked(page);
rb_native_mutex_unlock(&g->page_pool.lock);
}

Expand All @@ -2287,6 +2324,37 @@ heap_page_free(rb_objspace_t *objspace, struct heap_page *page)
free(page);
}

static void
heap_pages_free_batch(rb_objspace_t *objspace, struct heap_page *pages)
{
rb_global_objspace_t *g = global_objspace;

rb_native_mutex_lock(&g->page_pool.lock);
for (struct heap_page *page = pages; page != NULL; page = page->free_next) {
global_page_index_remove_locked(page);
if (HEAP_PAGE_ALLOC_USE_MMAP) {
#ifdef HAVE_MMAP
page_pool_release_locked(page->body, page->arena);
#endif
}
}
rb_native_mutex_unlock(&g->page_pool.lock);

if (!HEAP_PAGE_ALLOC_USE_MMAP) {
/* gc_aligned_free does not need the pool lock. */
for (struct heap_page *page = pages; page != NULL; page = page->free_next) {
heap_page_body_free(page->body, page->arena);
}
}

while (pages != NULL) {
struct heap_page *next = pages->free_next;
objspace->heap_pages.freed_pages++;
free(pages);
pages = next;
}
}

static void
heap_pages_free_unused_pages(rb_objspace_t *objspace)
{
Expand All @@ -2296,11 +2364,13 @@ heap_pages_free_unused_pages(rb_objspace_t *objspace)
objspace->empty_pages_count = 0;

size_t i, j;
struct heap_page *to_free = NULL;
for (i = j = 0; i < rb_darray_size(objspace->heap_pages.sorted); i++) {
struct heap_page *page = rb_darray_get(objspace->heap_pages.sorted, i);

if (heap_page_in_global_empty_pages_pool(objspace, page) && heap_pages_freeable_pages > 0) {
heap_page_free(objspace, page);
page->free_next = to_free;
to_free = page;
heap_pages_freeable_pages--;
}
else {
Expand Down Expand Up @@ -2336,6 +2406,8 @@ heap_pages_free_unused_pages(rb_objspace_t *objspace)
heap_pages_lomem = 0;
heap_pages_himem = 0;
}

heap_pages_free_batch(objspace, to_free);
}
}

Expand Down Expand Up @@ -2529,6 +2601,33 @@ page_pool_acquire(struct page_arena **arena_out)
return body;
}

#ifdef HAVE_MMAP
static void
page_pool_release_locked(struct heap_page_body *body, struct page_arena *arena)
{
rb_global_objspace_t *g = global_objspace;

ASSERT_PAGE_POOL_LOCKED(g);

/* A body in the empty-pages pool stays fully poisoned (see gc_sweep_page), so
* unpoison the scratch area (link + arena tag) before writing. */
asan_unpoison_memory_region(body, PAGE_POOL_SCRATCH_SIZE, false);
arena->free_count++;
PAGE_POOL_BODY_ARENA(body) = arena;
if (g->page_pool.hot_count < PAGE_POOL_HOT_MAX) {
*(uintptr_t *)body = (uintptr_t)g->page_pool.hot_list;
g->page_pool.hot_list = body;
g->page_pool.hot_count++;
}
else {
*(uintptr_t *)body = (uintptr_t)arena->cold_freelist;
arena->cold_freelist = body;
arena->cold_count++;
}
asan_poison_memory_region(body, HEAP_PAGE_SIZE);
}
#endif

static void
page_pool_release(struct heap_page_body *body, struct page_arena *arena)
{
Expand All @@ -2537,22 +2636,7 @@ page_pool_release(struct heap_page_body *body, struct page_arena *arena)
rb_global_objspace_t *g = global_objspace;

rb_native_mutex_lock(&g->page_pool.lock);
/* A body in the empty-pages pool stays fully poisoned (see gc_sweep_page), so
* unpoison the scratch area (link + arena tag) before writing. */
asan_unpoison_memory_region(body, PAGE_POOL_SCRATCH_SIZE, false);
arena->free_count++;
PAGE_POOL_BODY_ARENA(body) = arena;
if (g->page_pool.hot_count < PAGE_POOL_HOT_MAX) {
*(uintptr_t *)body = (uintptr_t)g->page_pool.hot_list;
g->page_pool.hot_list = body;
g->page_pool.hot_count++;
}
else {
*(uintptr_t *)body = (uintptr_t)arena->cold_freelist;
arena->cold_freelist = body;
arena->cold_count++;
}
asan_poison_memory_region(body, HEAP_PAGE_SIZE);
page_pool_release_locked(body, arena);
rb_native_mutex_unlock(&g->page_pool.lock);
#endif
}
Expand Down Expand Up @@ -12428,7 +12512,7 @@ rb_gc_impl_after_fork(void *objspace_ptr, rb_pid_t pid)
heap_alloc_state_clear(objspace);
/* The forking Ractor becomes the child process's main Ractor. */
global_objspace->main_objspace = objspace;
rb_native_mutex_initialize(&rb_global_objspace_instance.page_pool.lock);
page_pool_lock_initialize(&rb_global_objspace_instance.page_pool.lock);
}
}

Expand Down
Loading