Bug #22454
openHeaps of exited Ractors are reclaimed only by a global GC, so memory grows without bound with short-lived Ractors
Description
- 8 rounds of 1,000 short-lived Ractors on 4.1.0-preview1: RSS climbs from 159 MB to 1,125 MB, with a single global GC.
- With a GC.start after each round, it stops at ~435 MB.
- On 4.0.7 it stays at 34–44 MB.
- It tells the server story: OOM-killed in 8 GB once the first sockets closed.
- It asks for a trigger based on the exited Ractors still waiting to be reclaimed, and notes that even 435 MB is ~10× 4.0's figure.
Updated by matteo.folin@gmail.com (Matteo Folin) 1 day ago
- Subject changed from exited Ractors' memory is reclaimed only by a global GC, so memory grows without bound. to Heaps of exited Ractors are reclaimed only by a global GC, so memory grows without bound with short-lived Ractors
- ruby -v changed from 4.1.0-preview1 to ruby 4.1.0preview1 (2026-10-11 master 2d08b5f42f)
Description¶
With the per-Ractor GC in 4.1.0-preview1, a program that keeps creating
short-lived Ractors grows its RSS by ~140 MB per 1,000 exited Ractors.
As I understand the NEWS, a dead Ractor's heap is reclaimed by a global
GC, but nothing starts one when Ractors exit, so the heaps pile up.
In 8,000 Ractors only one global GC ran, and memory kept growing after
it. Calling GC.start after each round of 1,000 keeps RSS flat, so
the memory is reclaimable. The automatic trigger doesn't seem to
account for it.
A server that runs a Ractor per connection hits this directly: it ran
out of memory (killed by the kernel's OOM killer in an 8 GB container)
a few seconds after its first few thousand connections closed. On 4.0
the same server stays flat.
Reproduction¶
# Rounds of 1,000 short-lived Ractors, each allocating a little and exiting.
# Pass "gc" to run GC.start after each round.
Warning[:experimental] = false
def rss_mb = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i / 1024
def gc_stat = (GC.stat(scope: :global) rescue GC.stat)
8.times do |round|
Array.new(1000) { Ractor.new { Array.new(900) { "x" * 30 }.size } }.each(&:join)
GC.start if ARGV[0] == "gc"
stat = gc_stat
puts "round #{round}: rss #{rss_mb} MB, Ractor.count #{Ractor.count}, " \
"GCs #{stat[:count]}, global GCs #{stat.fetch(:global_gc_count, "-")}"
end
Results¶
Linux aarch64 (Docker on macOS, official ruby images), pinned to 4
cores.
4.1.0-preview1, ruby dead_ractor_heaps.rb:
round 0: rss 159 MB, Ractor.count 1, GCs 1000, global GCs 0
round 1: rss 302 MB, Ractor.count 1, GCs 2000, global GCs 0
round 2: rss 448 MB, Ractor.count 1, GCs 3000, global GCs 0
round 3: rss 596 MB, Ractor.count 1, GCs 4000, global GCs 0
round 4: rss 733 MB, Ractor.count 1, GCs 5001, global GCs 1
round 5: rss 860 MB, Ractor.count 1, GCs 6001, global GCs 1
round 6: rss 988 MB, Ractor.count 1, GCs 7001, global GCs 1
round 7: rss 1125 MB, Ractor.count 1, GCs 8001, global GCs 1
4.1.0-preview1, ruby dead_ractor_heaps.rb gc:
round 0: rss 162 MB, Ractor.count 1, GCs 1001, global GCs 1
round 1: rss 292 MB, Ractor.count 1, GCs 2002, global GCs 2
round 2: rss 435 MB, Ractor.count 1, GCs 3003, global GCs 3
round 3: rss 435 MB, Ractor.count 1, GCs 4004, global GCs 4
...
round 7: rss 436 MB, Ractor.count 1, GCs 8008, global GCs 8
4.0.7, either way: RSS 34 → 39 MB over the 8 rounds (44 MB with gc).
Expected¶
RSS bounded without an explicit GC.start, for example a global GC (or
reclaiming dead heaps without one) triggered by the memory or the count
of exited Ractors' heaps waiting to be reclaimed.
Also worth a look: even with GC.start every round, RSS levels off at
~435 MB, against ~44 MB on 4.0.7.