Why Unicorn Workers Grow Fat

Anyone running Unicorn (or Puma, or Einhorn) has likely seen the same puzzle: forked workers start lean, then balloon to roughly their parent's size within a short time. In a serious production deployment each worker can consume hundreds of megabytes, and memory—not CPU—becomes the bottleneck. The operating system's virtual memory manager is supposed to prevent this via copy-on-write: when a process forks, its child shares the parent's 4k memory pages until it writes to one, at which point the kernel copies and reassigns just that page.

Child processes transitioning from mostly shared memory to mostly copied as they mature.
Child processes transitioning from mostly shared memory to mostly copied as they mature.

That mechanism only helps if workers refrain from modifying shared pages. Most programs have a large body of static objects initialized once and never touched again—prime candidates for staying shared. Yet in practice nearly nothing is reused across workers. The reason lives in the details of Ruby's allocation strategy.

Heap Pages and Slots

Ruby requests memory from the operating system in chunks it calls heap pages. These are not the OS's 4k pages (which we'll call OS pages); a heap page spans several OS pages in virtual memory. Ruby sizes heap pages to cover an even multiple of OS pages—typically four 4k OS pages for one 16k heap page—so there's no waste at the boundary.

A heap, its heap pages, and slots within each page.
A heap, its heap pages, and slots within each page.

In source comments you'll also see "heap" or "slab" or "arena." Ruby's own code uses heap page for a single chunk and heap for the collection of pages, so we'll stick to that. Each heap page has a header and a set of slots, where each slot can hold one RVALUE—an in-memory Ruby object. Heap pages are linked together so the whole set can be traversed.

Initializing the Heap

Heap setup happens in Init_heap in gc.c, invoked from ruby_setup in eval.c, the core entry point for any Ruby process. Along with the heap, ruby_setup initializes the stack and VM. Init_heap picks an initial page count from a target slot number, defaulting to 10,000 slots; that default can be changed via configuration or environment variable.

#define GC_HEAP_INIT_SLOTS 10000

The slots-per-page calculation is straightforward. Starting with a 16k target (1 << 14), subtract a few bytes for malloc bookkeeping and a few more for the page header, then divide by the known RVALUE size:

/* default tiny heap size: 16KB */
#define HEAP_PAGE_ALIGN_LOG 14
enum {
    HEAP_PAGE_ALIGN = (1UL << HEAP_PAGE_ALIGN_LOG),
    REQUIRED_SIZE_BY_MALLOC = (sizeof(size_t) * 5),
    HEAP_PAGE_SIZE = (HEAP_PAGE_ALIGN - REQUIRED_SIZE_BY_MALLOC),
    HEAP_PAGE_OBJ_LIMIT = (unsigned int)(
        (HEAP_PAGE_SIZE - sizeof(struct heap_page_header))/sizeof(struct RVALUE)
    ),
}

On a 64-bit system an RVALUE takes 40 bytes. With defaults, Ruby boots with 24 heap pages at 408 slots each, growing the heap as needed.

What an RVALUE Holds

An RVALUE is the generic in-memory representation of a Ruby object. Its definition in gc.c is essentially a C union of every type Ruby might store:

typedef struct RVALUE {
    union {
        struct RBasic  basic;
        struct RObject object;
        struct RClass  klass;
        struct RFloat  flonum;
        struct RString string;
        struct RArray  array;
        struct RRegexp regexp;
        struct RHash   hash;
        struct RData   data;
        struct RTypedData   typeddata;
        struct RStruct rstruct;
        struct RBignum bignum;
        struct RFile   file;
        struct RNode   node;
        struct RMatch  match;
        struct RRational rational;
        struct RComplex complex;
    } as;

    ...
} RVALUE;

That union is why Ruby can assign any type to any variable: the object is just the list of all possibilities, compacted so they share the same memory. Only one member is live at a time, and the union is sized to its largest member. Take strings as an example. The RString struct in ruby.h shows the pattern:

struct RString {
    struct RBasic basic;
    union {
        struct {
            long len;
            char *ptr;
            union {
                long capa;
                VALUE shared;
            } aux;
        } heap;
        char ary[RSTRING_EMBED_LEN_MAX + 1];
    } as;
};

Several details stand out. First, RString embeds RBasic, a struct common to all in-memory Ruby types that distinguishes them from one another. Second, a union with char ary[RSTRING_EMBED_LEN_MAX + 1] means short strings are stored inline inside the slot—no extra allocation. Finally, a string may point at another string via VALUE shared and reuse its backing memory.

VALUE: Pointer or Scalar

Not everything Ruby deals with becomes an RVALUE. In C extensions you'll encounter VALUE, the type used to pass around any Ruby value. Its definition in ruby.h is simpler—just a pointer:

typedef uintptr_t VALUE;

The clever part (or the gross part, depending on taste) is that VALUE can hold more than pointers to RVALUEs. By comparing to constants and using bit shifts, Ruby stuffs certain scalar types directly into the pointer-sized value. Simple constants are defined in ruby.h:

enum ruby_special_consts {
    RUBY_Qfalse = 0x00,		/* ...0000 0000 */
    RUBY_Qtrue  = 0x14,		/* ...0001 0100 */
    RUBY_Qnil   = 0x08,		/* ...0000 1000 */

    ...
}

Fixnums work by left-shifting the value one bit and marking the lowest bit. The same trick handles "flonums" (floats) and symbols. To figure out what occupies a VALUE, Ruby checks the pointer against known flags for these stack-bound types; if none match, it falls through to the heap as shown in ruby.h:

static inline VALUE
rb_class_of(VALUE obj)
{
    if (RB_IMMEDIATE_P(obj)) {
        if (RB_FIXNUM_P(obj)) return rb_cInteger;
        if (RB_FLONUM_P(obj)) return rb_cFloat;
        if (obj == RUBY_Qtrue)  return rb_cTrueClass;
        if (RB_STATIC_SYM_P(obj)) return rb_cSymbol;
    }
    else if (!RB_TEST(obj)) {
        if (obj == RUBY_Qnil)   return rb_cNilClass;
        if (obj == RUBY_Qfalse) return rb_cFalseClass;
    }
    return RBASIC(obj)->klass;
}

Keeping such values off the heap saves slots and speeds things up. Flonums were added relatively recently; its author estimated a ~2x speedup for simple float arithmetic.

Avoiding Collisions

How does Ruby guarantee a scalar value never collides with a legitimate pointer? The 40-byte RVALUE size plus aligned malloc ensures every real object address is divisible by 40. In binary, such an address always ends in three zero bits (...xxxx x000). All the tag flags Ruby uses for fixnums, flonums, and symbols occupy one of those three bits, so pointers and scalars can never overlap. This "tagged pointer" technique isn't Ruby-specific—it shows up across systems that need to pack type information into a word-sized value.

The Cost of Per-Object Writes

Now the worker bloat makes sense. Ruby's slots and VALUEs are designed for speed and density, not for sharing across processes. When a forked worker touches its objects—which it does constantly, because few Ruby objects are truly immutable—each write dirties an OS page that contains many slots of heap page data. The virtual memory system then has to copy the entire OS page for that worker alone. A page that holds multiple distinct Ruby objects gets copied at the first modification of any one of them, destroying any sharing that could have been preserved. The result is that with typical workloads, copy-on-write gives Ruby workers almost none of its intended benefit. Static configuration and other read-heavy data may technically be shareable, but the moment any neighboring object is written, the whole page is duplicated anyway.

Inside Object Allocation

When Ruby creates a string, the entry point is str_new0 in string.c. The logic is a direct confirmation of what RString's structure implies: if the value fits, Ruby embeds it directly into the slot. Otherwise, it calls ALLOC_N to request space from the operating system's heap and stores the resulting pointer in the slot's as.heap.ptr field.

static VALUE
str_new0(VALUE klass, const char *ptr, long len, int termlen)
{
    VALUE str;

    ...

    str = str_alloc(klass);
    if (!STR_EMBEDDABLE_P(len, termlen)) {
        RSTRING(str)->as.heap.aux.capa = len;
        RSTRING(str)->as.heap.ptr = ALLOC_N(char, (size_t)len + termlen);
        STR_SET_NOEMBED(str);
    }

    if (ptr) {
        memcpy(RSTRING_PTR(str), ptr, len);
    }

    ...

    return str;
}

Claiming a Slot

Deeper in the call chain, str_alloc delegates to newobj_of in gc.c, which needs a free RVALUE slot. It pulls one via heap_get_freeobj_head:

static inline VALUE
newobj_of(VALUE klass, VALUE flags, VALUE v1, VALUE v2, VALUE v3, int wb_protected)
{
    rb_objspace_t *objspace = &rb_objspace;
    VALUE obj;

    ...

    if (!(during_gc ||
          ruby_gc_stressful ||
          gc_event_hook_available_p(objspace)) &&
        (obj = heap_get_freeobj_head(objspace, heap_eden)) != Qfalse) {
        return newobj_init(klass, flags, v1, v2, v3, wb_protected, objspace, obj);
    }

    ...
}

Ruby's global interpreter lock (GIL) ensures that only one thread executes Ruby code at a time, so grabbing the next slot from the heap's freelist requires no additional locking. The freed slot is simply re-pointed to the next free entry.

static inline VALUE
heap_get_freeobj_head(rb_objspace_t *objspace, rb_heap_t *heap)
{
    RVALUE *p = heap->freelist;
    if (LIKELY(p != NULL)) {
        heap->freelist = p->as.free.next;
    }
    return (VALUE)p;
}

Once a slot is secured, newobj_init performs generic setup before returning control to str_new0, which handles string-specific initialization like copying in the content.

Eden and the Tomb

The request for a free slot targets heap_eden. Eden is one of two heaps Ruby tracks; it holds pages that contain live objects. The other heap is the tomb.

When a garbage collection pass finds a page with no remaining live objects, that page moves from eden to the tomb. If Ruby later needs a new heap page, it first tries to resurrect one from the tomb before asking the OS for more memory. Pages that stay dead in the tomb for a long period might eventually be returned to the OS, though in practice this is rare.

New pages from the OS go through initialization in gc.c. Ruby calculates memory offsets for the page's start and end slots, then walks the entire range, calling heap_page_add_freeobj on every slot:

static struct heap_page *
heap_page_allocate(rb_objspace_t *objspace)
{
    RVALUE *start, *end, *p;

    ...

    for (p = start; p != end; p++) {
        heap_page_add_freeobj(objspace, page, (VALUE)p);
    }
    page->free_slots = limit;

    return page;
}

Each slot is added to a single linked list of free slots, tracked by a freelist pointer on the heap. The chain is constructed by following the free.next field stored on each free RVALUE itself. The garbage collector uses the same function to return dead objects to the free list during its sweep phase.

static inline void
heap_page_add_freeobj(rb_objspace_t *objspace, struct heap_page *page, VALUE obj)
{
    RVALUE *p = (RVALUE *)obj;
    p->as.free.flags = 0;
    p->as.free.next = page->freelist;
    page->freelist = p;

    ...
}
A heap's freelist pointer to a free RVALUE, and the continuing linked list.
A heap's freelist pointer to a free RVALUE, and the continuing linked list.

Why Forked Workers Bloat

Putting all of this together exposes the core tension with copy-on-write: once Ruby writes an object to a slot, that slot never moves, neither within a page nor between pages. A live object is pinned in place for the life of the process.

A running Ruby program allocates and frees objects continuously, so any given heap page quickly becomes a patchwork of live and dead slots. That is exactly the state of a Unicorn master process when it forks its workers. The workers start with a complete copy-on-write share of the parent's memory, but the first time a child initializes a slot or runs a GC pass, the OS must copy the entire underlying page. Over time, every page that contains a single live slot gets touched, and each worker ends up with a fully private, divergent copy of memory.

Copy-on-write is a powerful mechanism, but it offers little to a forking Ruby process when live objects are scattered across every page.

Compaction on the Horizon

The Ruby core team has long been aware of this limitation. Ruby 2.0 introduced separate heap bitmaps for garbage collection marks. Previously, mark flags lived directly on each slot, which meant a GC mark pass after a fork would dirty every OS page containing live objects. Moving those flags to page-level bitmaps limits the damage: a forked child that runs GC only copies the pages holding the bitmaps, keeping the rest of memory shared with the parent for longer.

The next major step is GC compaction. Aaron Patterson has been publicly discussing this work for years and has noted it ran in production at GitHub with positive results. The proposed API is a simple call before forking workers:

# Called before a parent forks any workers
before_fork do
  GC.compact
end

The idea is to let the parent finish its object churn during initialization, then move all surviving objects into slots on a minimal set of stable pages. Forked children would then share a much larger portion of memory with the parent — pages that are unlikely to be written to again.

A fragmented heap before and after GC compaction.
A fragmented heap before and after GC compaction.

For large Ruby deployments, this is significant. Memory is typically the binding constraint on how many workers a single machine can run. Compaction promises to cut a meaningful portion of per-worker memory usage, letting operators run more workers per box and fewer boxes overall. For a fleet of Unicorn (or similar) servers, the cost savings are direct and immediate.