Feature #22227
openPer-Ractor GC: collect each Ractor's heap locally, stop the world only when needed
Description
Companion PR: https://github.com/ruby/ruby/pull/18194
Abstract¶
This proposal makes the default GC operate per Ractor. Each Ractor owns an
objspace (a private heap) and collects it on its own thread without stopping
any other Ractor ("local GC"). A stop-the-world collection over all
objspaces ("global GC") runs only when something genuinely global has to be
reclaimed: shareable objects, memory retained across Ractor boundaries, and
the heaps of dead Ractors. With this change, allocation-heavy Ractor
programs scale like forked processes, and Ractor communication can even beat
interprocess communication; end-to-end single-Ractor workloads stay within
2-4% of current performance (focused allocation microbenchmarks move a few
percent in both directions, see section 4.4).
1. Motivation¶
Today all Ractors allocate from one shared objspace, and every GC stops
every Ractor. This has two consequences:
- GC pause time grows with the total heap of all Ractors, while the pause
still stops everyone. N allocating Ractors trigger GCs N times as often,
and each GC scans N heaps. - The synchronization needed to stop N mutators at every GC dominates as N
grows.
In practice, a program that parses JSON in 16 Ractors spends 10x the wall
clock of its single-Ractor version (for the same per-worker work), while the
identical code in 16 forked processes stays at the hardware's parallel
limit of about 3.3x. Ractors currently lose to fork exactly on the
workloads they were designed for.
The root cause is architectural, not incremental: CRuby's current shared
objspace couples every Ractor to the same stop-the-world GC. This proposal
splits the heap.
2. Design overview¶
Terminology used below:
- objspace: one Ractor's private heap: its pages, freelists, mark
stack, generational state and counters. - local GC: a collection of a single objspace, run by its owner thread,
with no VM lock and no barrier. Other Ractors keep running. - global GC: a stop-the-world mark/sweep over all objspaces as one
heap. Heap traversal is exact; machine-stack roots remain conservative. - shareable object:
Ractor.shareable?(obj) == true(immutable values,
Class/Module, shareable Procs, and so on). - shref ("shareable reference"): an unshareable object directly
referenced by a shareable one. Details are given in 2.1 and 2.3.
2.1 The containment invariant¶
The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except
through edges that the GC has been explicitly told about (shrefs and
in-flight messages).
The invariant has an equally important flip side: a shareable object can be
referenced from any Ractor at any time, and nothing records who references
it. A local GC therefore never collects a shareable object, and never even
traverses one; it only marks them all as live (2.3). Only a global GC, whose
unified mark sees every Ractor's references, can prove a shareable object
dead and reclaim it. In other words: unshareable objects are the local GC's
responsibility, shareable objects are exclusively the global GC's.
Ruby-level Ractor semantics already forbid directly sharing unshareable
objects. CRuby, however, has a small number of implementation-level edges
that cross the boundary, and the GC work is to make every one of them
visible to the collector:
- A shareable object can hold an implementation-level reference to an
unshareable one: for example, writing through a shareable Proc's
environment stores an unshareable value into a shareable env, and a
shareable object's hidden instance variables (its fields imemo) can stay
unshareable. The write barrier detects every such store and records
the target as a shref. The transition that makes an object shareable
likewise registers any pre-existing outgoing edges of this kind (for
example, the hidden field values thatRactor.make_shareable's traversal
does not reach are shref-recorded at promotion time). - A message in flight (sent, not yet received) lives in the sender's
objspace while being reachable only from queue structures. The send path
pins the payload in the sender's objspace until the receiver
materializes it (2.6).
Given the invariant, marking one objspace's roots reaches everything a local
GC is allowed to free, so a local GC requires no process-wide barrier and
never stops another Ractor; its synchronization is confined to leaf mutexes
and atomic bitmap operations on shared metadata (2.3).
GC.verify_internal_consistency walks the heap and checks the invariant
edge by edge (builds with RGENGC_CHECK_MODE also run it around every
collection); it caught most of the bugs found during development.
2.2 Heap layout and ownership¶
Every heap page records its owning objspace, so any object's owner is one
load away: GET_HEAP_OBJSPACE(obj) reads the page header. There is no
per-object tag; ownership is a property of the page.
Page bodies come from a process-wide page pool: large mmap arenas carved
into page-sized bodies and recycled through a freelist under a dedicated
mutex. Ractors therefore exchange pages (allocation, death, inheritance)
without mmap/munmap churn, and without serializing on the kernel's mmap
lock.
A process-wide page index (all pages of all objspaces, sorted by body
address) supports the conservative machine-stack scan of a global GC and
ownership tests; only stop-the-world code reads it, so it needs no reader
lock.
Allocation is a bump-pointer fast path (a cursor advancing through free
regions) into per-objspace, per-size-class heaps. The upstream per-Ractor
newobj cache became unnecessary (the objspace itself is per Ractor), which
makes the allocation fast path about 9% faster.
2.3 What a local GC does¶
A local GC is the classic incremental/generational collector of CRuby,
restricted to one objspace, plus three rules:
- Never free a shareable object. At the end of marking
(gc_marks_finish), a dedicated pass (pinned_roots_mark) marks every
shareable object on the objspace's pages that ordinary marking did not
reach: mark bit only, no traversal. Another Ractor may hold the only
reference to it; only a global GC can prove a shareable object dead.
The count of objects kept alive this way is also a useful retention
metric (an upper bound on garbage only a global GC can reclaim). - Treat shrefs as roots. A shref is marked and traversed like a
remembered old-to-young target: the shareable parent is never traversed
by a local GC (rule 1), so without this its unshareable children would
look unreachable. - Never touch a foreign object's GC state. Marking stops at any
object owned by another objspace ("foreign objects are live leaves"):
its mark/age/remembered bits belong to its owner, and writing them would
race the owner's local GC.
Because objects can become shareable at any time between collections, rule 1
scans the shareable/shref bitmaps in every mark cycle rather than trying to
maintain a pin set across sweeps.
Generational collection stays per objspace. The remembered-set bitmap has
exactly one class of cross-thread writers (another Ractor's write barrier
remembering a shareable object), and that path uses an atomic bitmap set;
everything else is single-writer under the owner's GVL.
Locking model. A local GC takes neither the VM lock nor any barrier;
it never stops or waits for another Ractor.
The main Ractor's local GC is the one exception in two bounded windows: it
walks VM-global roots and weak tables (rb_vm_mark, freeing entries of
fstring/symbol/ci tables), which other Ractors rewrite under the VM lock,
so it takes a no-barrier VM lock just for those stretches; and when a JIT
is enabled, marking reaches JIT payloads of shareable iseqs, which must
exclude concurrent compilation. One rule is absolute: a GC must never
take the barrier VM lock mid-collection, because the waiter would join a
pending global barrier and expose its half-collected heap to the global GC.
Shared structures that GC paths touch (page pool, the generic-fields table,
registered globals) use their own leaf mutexes instead.
Triggers. Every local GC entry first asks whether a global cycle is
needed instead (see 2.4); otherwise the usual malloc/allocation heuristics
drive local minor/major cycles per objspace.
2.4 What a global GC does¶
A global GC stops the world using the existing barrier mechanism (a GC has
no safepoints, so the barrier implicitly waits for every in-flight local GC
to finish). Then, with the driver thread executing every phase:
- Settle: finish every objspace's lazy sweep, so mark bits have a
single meaning. - Clear: clear every objspace's mark bits, remembered sets, age
metadata and all shref bits. (Missing any of these would leave stale
bits and cause use-after-free; this step is why shref bits may only be
cleared by a global GC.) - Unified mark: mark from every Ractor's roots (C-struct roots,
machine stacks scanned conservatively against the global page index, VM
globals) and traverse exactly, crossing objspace boundaries freely. The
write barrier's shref information is re-derived as a byproduct: each
shareable-to-unshareable edge encountered re-records the shref bit.
In-flight message payloads are re-pinned here (their shref bits were
cleared in step 2). - Weak passes: the generic-fields table (weak keys) is processed to a
fixpoint: values (fields_obj) of live keys are marked, entries of dead
keys are dropped, and since marking a value can make another key live,
the pass repeats until no progress. VM-global weak tables (fstring,
symbols, call caches and so on) are swept once, not once per objspace. - Sweep: sweep every objspace inside the barrier (not lazily). This
is the only place shareable objects and cross-objspace cycles die.
Emptied pages return to the page pool. - Budget: objspaces left with no free pages get the same heap-growth
budget a local full mark would have granted (a global GC bypasses that
bookkeeping), so the next allocation does not fail. - Shareable-object populations and trigger limits are recounted per
objspace.
Global GCs are triggered by: an explicit full GC.start; growth of the
shareable-object population past a per-objspace limit; and accumulation of
heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a
global GC at the same time are serialized by the barrier; the second cycle
is wasted work, not an error.
Compaction. With multiple objspaces, GC.compact (and autocompact on
full marks) runs inside a global GC as three passes over all objspaces:
(a) move every objspace's movable objects, leaving forwarding; (b) update
references everywhere, following forwarding across objspaces, plus the
VM-global side once; (c) sweep and free emptied source pages. It must be
three global passes rather than per-objspace work because a reference can
point at a moved object in another objspace, and freeing a source page
early would let another objspace's update read freed forwarding. A local
(non-stop-the-world) compaction runs only while the process has a single
objspace; objects that C structs of other Ractors point at (an in-flight
payload, a terminated Ractor's pending value) are pinned so they never
move even under global compaction.
Incremental marking similarly runs only in single-objspace mode; the
transition point (Ractor.new making the process multi-Ractor) settles any
cycle in progress.
2.5 Ractor lifecycle and memory hand-off¶
Creation. A Ractor's objspace is created before its thread starts, and
the child's main Thread and root Fiber wrapper objects are allocated
directly into it. Between those allocations and the child joining the
Ractor set, the creator "covers" the child objspace through a dedicated
slot so a concurrent global GC still enumerates it (an unenumerated
objspace would keep stale mark bits). Every failure path of creation
(isolation errors, thread-creation failure) hands the child objspace over
for reclamation rather than leaking it.
Termination. A terminating Ractor runs one last local GC with a by-then minimal root set on its
own thread ("retire GC": roots are minimal by then, and this reclaims
garbage that would otherwise be inherited), and its objspace becomes a
zombie: registered in a VM-wide table, mutated by nobody, but
enumerated by every global GC. Ractor death therefore does not stop the
world.
Inheritance. Ractor#value (and join) absorbs the dead Ractor's
objspace into the caller's: pages are spliced wholesale into the caller's
heaps (kept sorted by body address for the conservative scan), the
finalizer table entries move over, malloc-accounting counters transfer, and
the caller's next collection is forced to a full mark to rebuild
generational state. This is why Ractor#value can return the value by
reference with no copy: after the absorb it is an ordinary object of the
caller. If a dead Ractor's object is itself collected without ever being
joined, its orphan objspace is merged into the main Ractor at main's next
safepoint (scheduled as a postponed job, because the discovery happens
inside a sweep). At VM shutdown all remaining zombies merge into main so
at-exit processing sees every object.
Because a zombie's pages still hold shareable objects that other Ractors
can reach, zombies participate in the global-GC trigger: accumulated
zombie pages past a threshold start a global cycle (measured against what
survived the previous one, so a zombie with much live data does not
retrigger forever).
2.6 Message passing: copy¶
Ractor#send (copy semantics) became a two-phase protocol:
- Snapshot (sender side). The sender deep-copies the object graph
inside its own objspace using a native copier for core types (String,
Array, Hash, Struct, T_OBJECT and so on); types the native copier does
not support fall back to a Marshal round trip.#cloneand
initialize_copyare never invoked, although Marshal-specific hooks
(marshal_dump,_dump) run on the sender for fallback types and can
raise there. Every node of the
snapshot is pinned in the sender's objspace, and the pin cover is
handed off without gaps: from the builder's capture list, to the basket
(queue entry), to the receiver's materialization frame. A global GC
re-pins from whichever holder is current (its clear pass drops all
shref bits, see 2.4 step 2/3). - Materialize (receiver side). When the receiver takes the message
out of the port, it rebuilds the graph inside its own objspace. The
sender-resident snapshot becomes garbage once the copy completes.
Materialization can raise (Marshal load hooks and autoload run user
code; asynchronous interrupts can arrive) and can nest
(Ractor.receiveinside a load hook), so in-progress materializations
form a per-EC chain of machine-stack frames that the GC uses as roots.
Passing the sender-resident graph to the receiver by reference is never
allowed: it would create an unshareable cross-objspace edge that neither
local GC could handle.
2.7 Message passing: move¶
Ractor#send(obj, move: true) serializes the payload graph into an
off-heap, malloc-allocated structure called the courier. While a moved graph
is in flight, there is no heap object representing it: neither side's GC
can mark, sweep, move, or race with it.
- A read-only preflight walks the graph first and raises every "can
not move" error before anything is mutated (capture is destructive, so
failing halfway would corrupt the graph beyond repair). - Capture turns each source object into a node in the courier's node
array (identity and cycles are preserved through node ids and a
by-address dedup table) and immediately neutralizes the source into a
Ractor::MovedObjectshell, without ever passing through an
inconsistent state that a concurrent marker could observe. - Payload internals move by ownership transfer where possible: a String
that owns its buffer hands the pointer over (zero copy); an IO carries
its wholerb_io_tincluding the file descriptor; MatchData dumps its
match registers into an onig-independent blob. Shareable references and
immediates are carried as values; classes of the moved objects are
carried as references (classes are shareable), so subclasses and
singleton classes survive the move. - In-flight couriers are registered in a VM-global list; a global GC's
root pass marks and pins the shareable references they carry (nothing
else roots them in some windows). - Rebuild on the receiver allocates all shells first, then fills
references (two passes break cycles), and inserts Hash entries last, in
reverse id order, so user-defined#hashmethods observe fully built
keys. Rebuilding can raise; an unconsumed courier is freed by the queue
teardown, closing descriptor leaks for moved IOs.
2.8 Generic instance variables¶
Instance variables of non-T_OBJECT hosts (str.instance_variable_set and
friends) live in one process-global table keyed weakly by host object,
guarded by a dedicated leaf mutex. A local GC reads it per object while
marking (it cannot wait for the VM lock there); the global GC processes it
in the weak pass described above; per-object cleanup happens when a host is
freed. An earlier design with per-Ractor tables plus a shared table was
abandoned: entries would have had to migrate on every make-shareable, send
and inheritance, and that boundary was a reliable source of bugs.
2.9 Modular GC API¶
The GC implementation interface (gc/gc_impl.h) gains per-Ractor entry
points: multi_objspace_p, objspace_absorb, objspace_retire_gc,
obj_foreign_p, during_global_gc_p, obj_became_shareable,
pin_in_flight_message, each_objects_shareable, each_objects_foreign,
heap_page_count, gc_rest. An implementation that keeps a single
objspace (MMTk) reports multi_objspace_p() == false and the VM gates the
whole per-Ractor machinery out: every Ractor aliases the one objspace,
retire/absorb/zombie handling become no-ops, and Ractor-related marking
falls back to following everything from the wrapper objects. MMTk
continues to pass its test suite unchanged.
3. Compatibility¶
Ractor#valuebecomes a one-shot ownership transfer: the first call
hands the value over by reference together with the dead Ractor's heap,
and a second call raisesRactor::Error("The value was already
taken"). Returning the same unshareable object again would hand out a
reference to an object now owned by the first caller, recreating exactly
the cross-Ractor sharing that Ractor semantics forbid; special-casing
repeat calls from the same Ractor, or shareable values only, would make
the API's behavior depend on the caller and the value's type, so the
simple rule was chosen. (Previously repeated calls returned the same
object.)GC.startin a multi-Ractor process runs a stop-the-world global GC;
automatic GC in a Ractor collects only its own heap.GC.disable/GC.enablebecome per-Ractor holds on a process-wide
switch: any Ractor's disable stops automatic GC everywhere, but another
Ractor'sGC.enableno longer silently overrides it. A Ractor's hold
is released when it exits.ObjectSpace.each_objectenumerates the calling Ractor's own objects
plus other live Ractors' shareable objects, never their unshareable
ones. Objects of a terminated but not yet inherited Ractor are not
enumerated until its heap is absorbed. (ObjectSpace.dump_alland
memsize_of_allstill cover every object in the process; they walk the
other objspaces under a global barrier.)ObjectSpace.define_finalizeron another Ractor's object raises
Ractor::IsolationError; finalizers are registered, stored and run in
the owner's objspace.- The
RUBY_INTERNAL_EVENT_FREEOBJtracepoint fires only for the main
Ractor's objspace (it runs user callbacks from inside the sweep, which
is unsafe in a lock-free non-main sweep; extending it is future work). - Invalidated call-cache entries are no longer pruned during GC marking;
they are reclaimed at the next lookup of that method or when the class
dies. Retention is bounded (one entry per class-and-method-id pair);
the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries.
4. Performance¶
Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, -O3 release builds.
master is the merge-base of the PR, RLGC is the branch. Every cell is
wall-clock seconds until all N workers finish (lower is better). Because
the machine's clock state drifts on multi-minute scales, each RLGC/master
pair was measured interleaved back to back, and each number is the minimum
over at least 10 runs spread across several sessions.
All three programs are weak-scaling benchmarks: every worker runs the full
workload independently, so with perfect parallelism the wall clock stays
flat as N grows. The ratio tables divide every cell by master-Ractor N=1,
i.e. "how many times the wall time of current Ruby running one Ractor";
a perfectly scaling configuration keeps its N=1 ratio at every N.
"Ractor" rows run the N workers as Ractors, "fork" rows run the identical
code in forked processes: the no-shared-GC baseline that Ractors should
match. Scripts are about 40 lines each and attached to the PR.
4.1 binary-trees (D=16; allocation/GC-heavy)¶
The classic benchmarks-game workload: each worker builds and checksums
millions of short-lived binary trees (3-element arrays). Allocation and GC
dominate; objects die young.
seconds:
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 |
| master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 |
| RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 |
| master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 |
ratio (x master-Ractor N=1):
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | 2.81 |
| master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 |
| RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 |
| master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 |
RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at
N=16); master Ractors are 1.45x behind at N=16. For calibration, a
zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to
10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not
a GC limit.
4.2 JSON parse (embarrassingly parallel)¶
Each worker parses its own copy of the same ~30 KB JSON document (200
nested user records) 8000 times and discards each result: pure
parse-allocate-discard churn, no data crosses workers. This isolates how
allocation and GC scale.
seconds:
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 |
| master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 |
| RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 |
| master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 |
ratio (x master-Ractor N=1):
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | 3.32 |
| master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 |
| RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 |
| master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 |
master Ractors fall off almost immediately (1.39x already at N=2: every
Ractor's allocation churn stops all the others) and end 10x over the
baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes
3.1x faster than master Ractors.
4.3 JSON producer/consumer (Ractor move vs process IPC)¶
The "coordinator hands work to parsers" pattern: the main Ractor/process
produces 20000xN JSON strings (~6 KB each) and distributes them
round-robin to N workers, which parse and discard them. The Ractor
version transfers each string with send(move: true); the fork version
writes it down a pipe (a kernel copy). This measures the data-transfer
path, not just parallel GC.
seconds:
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 |
| master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 |
| RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 |
| master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 |
ratio (x master-Ractor N=1):
| N=1 | N=2 | N=4 | N=8 | N=16 | |
|---|---|---|---|---|---|
| RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | 3.82 |
| master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 |
| RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 |
| master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 |
At N=16 RLGC's move beats even process IPC (2.71s vs 3.02-3.04s: no
kernel copy), while master Ractors are 5.4x behind it. The off-heap
courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that
is the price of making in-flight messages invisible to both sides' GCs,
and a zero-copy fast path remains possible future work.
4.4 Single-Ractor performance¶
Measured with no Ractor.new at all (the N=1 rows above already run one
worker Ractor, which puts the process in multi-Ractor mode). The
allocation fast path itself got faster: the per-Ractor newobj cache is
gone and objects come straight off the objspace's bump-pointer heaps.
| single Ractor, interleaved minima | RLGC | master | |
|---|---|---|---|
Object.new x 20M, GC disabled |
1.83 | 2.01 | -9% |
Object.new x 20M, GC enabled |
1.18 | 1.10 | +7% |
| binary-trees (D=16) | 3.79 | 3.71 | +2% |
| JSON parse (8000 docs) | 1.55 | 1.49 | +4% |
(Object.new rows subtract the loop-only baseline of 0.24s.) Pure
allocation wins by 9%; sweep-time bitmap maintenance (the shareable and
shref bitmaps) gives ~7% back under allocate-and-discard churn, which
nets out to +2-4% on GC-heavy end-to-end workloads and less on anything
that does real work between allocations. The multi-objspace machinery is
gated behind a single-objspace check on hot paths.
4.5 Latency¶
Beyond throughput, the qualitative change is who pauses: on master every
GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes
its GC pauses on every other Ractor in the process. With per-Ractor GC a
Ractor pauses only for its own collections; the remaining process-wide
pauses are explicit GC.start, shareable-population growth and dead-Ractor
cleanup, all of which are rare in steady state.
5. Limitations and future work¶
- Incremental marking and non-stop-the-world compaction currently run only
while the process has a single objspace. Per-objspace incremental
marking is possible in principle (the machinery is per objspace already)
but needs the single-to-multi transition handled mid-cycle. move:uses an off-heap serialization; a zero-copy fast path (moving
whole pages for large graphs) is future work, as is extending the
FREEOBJ tracepoint beyond the main Ractor.- A global GC is still fully stop-the-world; making it concurrent or
incremental is orthogonal future work.
6. Validation¶
make btest (2054 tests) and make test-all are green on x86_64-linux
across release, RUBY_DEBUG=1, ASAN, TSAN and MMTk builds.
GC.verify_internal_consistency includes the containment verifier
(RGENGC_CHECK_MODE builds run it around every collection); it was used
throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle,
copy/move semantics, compaction interaction, and regressions for every bug
found during a multi-week stress campaign (a corpus of ~1000 generated
Ractor programs run under GC.stress, ASAN and TSAN).
Notes¶
- This work completes the design presented in
Toward Ractor local GC (RubyKaigi 2025). - Much of this description, and a large part of the code, was written with
Claude Code (Fable/Opus). - Acknowledgements
- This work started as a collaboration with Rohit Menon.
- Thanks to the Shopify Ractor team for their feedback.
- And thank you, everyone, for waiting so long for this work.
Updated by byroot (Jean Boussier) 2 days ago
GC.start in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap.
Should GC.start gain some sort of local_only: true argument?
I could see this as useful for applications that are still predominantly relying on prefork, hence benefit a lot from Out of Band Garbage Collection (GC.config(rgengc_allow_full_mark: false) Feature #20443), but do have a couple background ractors in each process.
That would probably ease the migration from one model to the other.
Updated by Eregon (Benoit Daloze) about 14 hours ago
ko1 (Koichi Sasada) wrote:
Never free a shareable object. At the end of marking
(gc_marks_finish), a dedicated pass (pinned_roots_mark) marks every
shareable object on the objspace's pages that ordinary marking did not
reach: mark bit only, no traversal.
Is this safe? It's writing concurrently to a shareable object IIUC, and might leave leftover mark bits which might matter for the global GC.
Updated by luke-gru (Luke Gruber) about 12 hours ago
· Edited
Eregon (Benoit Daloze) wrote in #note-2:
Is this safe? It's writing concurrently to a shareable object IIUC, and might leave leftover mark bits which might matter for the global GC.
It only marks shareable objects that are in the creating Ractor's objspace (allocated by that Ractor). The marking is done in a bitmap on the page. Shareable objects in foreign objspaces aren't traversed or marked during local GC.
Updated by ko1 (Koichi Sasada) about 11 hours ago
· Edited
Simple web server result:
https://claude.ai/code/artifact/eaf033a3-b64b-4a7e-af29-8c45a36109b5