skip to content

In a Ruby 4.0 preforking server, why would you run GC.compact in the parent before forking workers, and what are its limits?

level: seniorimportance: should knowfreq 25%

answer

  1. copy-on-write shares parent pages
  2. writes into a shared page copy it
  3. compaction packs live objects together
  4. GC.respond_to?(:compact)
  5. pinned objects cannot move

basics

~20 s

GC.compact moves live objects together so the parent's heap pages are full and dense. Forked workers share those pages copy-on-write; fewer half-empty pages means fewer pages copied when workers allocate. It does not move pinned objects and is not supported on every platform.

solid answer

~50 s

After `fork`, parent and child share memory pages **copy-on-write**: a page is copied only when one side writes to it. In a fragmented heap, live objects are scattered across many partly empty pages, so a worker that allocates into those free slots, or otherwise writes to those pages, copies them one by one and shared memory melts away. `GC.compact`, run once in the parent after boot, runs a full GC that **moves** objects to fill the gaps, leaving denser, fuller pages that workers mostly read. It returns a Hash of what moved (see `GC.latest_compact_info`). Limits: objects **pinned** by C extensions that do not support moving stay put; platforms without compaction raise `NotImplementedError`, so check `GC.respond_to?(:compact)`; and it is a full, blocking pass, so run it at boot, not per request. `GC.auto_compact = true` compacts on every major instead, at a cost on each.

code

ruby · 6 lines
ruby
# in the parent process, after the app is loaded and before forking
if GC.respond_to?(:compact)
  info = GC.compact
  moved = info[:moved].values.sum
  warn "compacted before fork: #{moved} objects moved"
end

go deeper

for a junior

Recall that forked workers share the parent's memory until they write to it, and that GC.compact packs live objects together.

for a middle

Explain why a fragmented heap causes page copies after fork and how GC.compact and GC.latest_compact_info report what moved.

for a senior

Show you compact once at boot, guard for platform support, account for pinned objects and measure per-worker private memory.

for a principal

Weigh preforking memory savings against boot-time cost and decide whether the fleet standardises on this preparation step.

## Copy-on-write and why fragmentation defeats it A **preforking** server boots the application once in a parent process and then `fork`s worker processes. Forked processes share the parent's memory pages **copy-on-write** (CoW): the operating system gives the child the same physical pages and only copies a page when either side writes to it. Loaded code and data the workers only read stay shared, which is where the memory saving of preforking comes from. CRuby stores objects in fixed-size **slots** inside heap pages. After boot, the heap is typically **fragmented**: live objects scattered across many pages, with free slots between them. When a worker then allocates an object, it may land in one of those free slots, which writes to a shared page and forces the OS to copy the whole page. Across many small allocations, a fragmented heap turns into many private copies. ## What GC.compact does `GC.compact` runs a full collection with **compaction**: live objects are moved into fewer pages and the references to them are updated. Its documentation describes it as eliminating unused space (fragmentation) by moving objects into that space. - It returns a Hash describing what was moved; `GC.latest_compact_info` returns the same information later, with `:considered`, `:moved`, `:moved_up` and `:moved_down` counts per object type. - `GC.stat(:compact_count)` and `:total_moved_objects` accumulate over the process's life. Run once in the parent after the application has loaded and before the first `fork`, it leaves the shared heap densely packed. Fewer shared pages then contain free slots for workers to fill, so fewer of them are written to and copied, and more of the parent's memory stays shared. ## Limits and costs | Limit | Detail | |---|---| | Pinned objects | objects referenced in ways the GC cannot update, typically from C extensions that do not support moving, are pinned and stay where they are | | Platform support | where compaction is not supported, `GC.compact` raises `NotImplementedError`; the documented check is `GC.respond_to?(:compact)` | | Pause | it is a full, blocking collection plus moving; do it at boot, never in the request path | | Scope | it helps shared object-heap pages only; malloc'd buffers and memory the worker writes anyway are unaffected | | Measure | the gain depends on the app; compare per-worker private memory with and without it | ## GC.auto_compact and the bundled alternative - `GC.auto_compact = true` makes the collector compact on **every major collection**. Its documentation warns that this degrades major-collection performance. It is a process-wide setting, not a one-off. - Ruby also offers `Process.warmup`, which a preforking server calls in the parent before the first fork; on CRuby it performs a major GC and compacts the heap among other preparation steps. It is the higher-level way to do the same preparation, and its broader behaviour belongs with forking. ## A practical sequence in the parent 1. Load the application and everything the workers will need. 2. Run `GC.compact` once, guarded by `GC.respond_to?(:compact)`. 3. Fork the workers. 4. Compare shared versus private memory per worker against a baseline without the step. ## Summary `GC.compact` before fork packs live objects into fewer, fuller pages so forked workers copy fewer of them. It cannot move pinned objects, is missing on some platforms, and costs a full pause, so it belongs at boot and needs measuring.

  • Why does GC.compact sometimes move far fewer objects than you expect?
    Objects that the GC cannot safely relocate are pinned, typically those referenced from C extensions that do not support compaction, and they stay in place. `GC.latest_compact_info` shows per type how many objects were considered and how many actually moved, which helps find where pinning limits the result.
  • Should you enable GC.auto_compact in every worker instead of compacting once before fork?
    Usually not as a substitute. Auto-compaction runs on every major collection, and its documentation warns that it degrades major-collection performance. It keeps a long-lived heap dense over time, while a one-off `GC.compact` before fork specifically targets pages shared with workers. Measure both against your latency budget.

saying these in an interview costs you the question

  • GC.compact changes the object_id of every object it moves
  • Compaction can move every object, including ones pinned by C extensions
  • GC.compact is cheap enough to call after each request
  • GC.auto_compact = true compacts once and then turns itself off
  • Copy-on-write is irrelevant because each worker loads its own copy of the app