Array
Предпосылки
динамический массив (capacity vs length, амортизированный рост, переаллокация), объекты и классы (VALUE, RBasic, слоты), GC (VWA, размеры слотов, compaction), иерархия памяти (cache lines, пространственная локальность).
Динамический массив хранит элементы в непрерывном буфере и растёт при переполнении — эти принципы одинаковы в любом языке. Вопрос реализации: где именно лежит этот буфер? В Ruby ответ зависит от размера массива. Маленький массив помещает элементы прямо в слот объекта — без malloc, без указателя, с минимальным расстоянием до CPU-кеша. Когда элементов становится больше, чем вмещает слот, Ruby выделяет отдельный буфер. Эти два режима — embedded и heap — определяют внутреннюю жизнь RArray.
Два режима хранения
Структура RArray начинается с заголовка RBasic (16 байт: flags + klass). Дальше идёт union — одна и та же память интерпретируется по-разному в зависимости от флага RARRAY_EMBED_FLAG в поле flags:
Embedded (флаг RARRAY_EMBED_FLAG установлен):
┌─────────────────┬──────────────────────────────────┐
│ RBasic (16 B) │ элементы: VALUE[] прямо в слоте │
│ flags + klass │ длина закодирована во flags │
└─────────────────┴──────────────────────────────────┘
Heap (флаг не установлен):
┌─────────────────┬─────┬──────┬─────┐ ┌──────────────────┐
│ RBasic (16 B) │ len │ capa │ ptr │ --> │ VALUE[] в буфере │
│ flags + klass │ │ │ │ └──────────────────┘
└─────────────────┴─────┴──────┴─────┘
В embedded-режиме длина массива кодируется прямо во flags заголовка, а элементы (VALUE, 8 байт каждый) лежат сразу после заголовка. Нет ни указателя, ни отдельной аллокации — один cache line захватывает и метаданные, и данные. Embedded режим держит данные рядом с метаданными по принципу пространственной локальности: одно обращение к памяти загружает и заголовок, и данные.
В heap-режиме объект хранит длину (len), ёмкость (capa) и указатель (ptr) на буфер, выделенный через malloc. Доступ к элементам требует разыменования указателя — второе обращение к памяти, потенциально на другой странице.
Сколько элементов помещается в слот
Embedded-ёмкость определяется простой формулой: всё пространство слота после заголовка RBasic делится на размер одного VALUE.
embedded_capacity = (slot_size - 16) / 8
С VWA (Ruby 3.2+) слоты бывают пяти размеров:
| Слот | Embedded capacity |
|---|---|
| 40 B | 3 элемента |
| 80 B | 8 элементов |
| 160 B | 18 элементов |
| 320 B | 38 элементов |
| 640 B | 78 элементов |
До VWA все слоты были 40 байт — embedded вмещал максимум 3 элемента. Теперь массив из 8 элементов (типичный размер опций метода, координат, небольших коллекций) помещается в 80-байтный слот целиком — без malloc, без дополнительного указателя, с одним обращением к памяти вместо двух.
При создании массива GC выбирает слот подходящего размера. Если Ruby знает начальную ёмкость (литерал, Array.new(n)), он запрашивает слот, достаточный для embedded-хранения. Пустой [] получает слот 40 байт с ёмкостью 3.
Рост: когда embedded перестаёт хватать
Допустим, массив живёт в слоте 40 байт (embedded capacity = 3):
a = [] # embedded, capacity = 3
a << 1 << 2 # [1, 2] — ещё помещается
a << 3 # [1, 2, 3] — слот заполнен
a << 4 # нужен 4-й элемент → переход на heapПри добавлении четвёртого элемента Ruby проверяет: помещается ли новый размер в текущий слот? Нет — значит, пора переключаться на heap. Ruby выделяет буфер через malloc, копирует элементы из слота в буфер, сбрасывает флаг RARRAY_EMBED_FLAG и записывает указатель на буфер.
Размер слота зафиксирован при создании объекта — Ruby не перемещает объект в больший слот на лету. Это решение GC: перемещение между size pools происходит только при compaction.
Минимальный heap-буфер
Когда массив переходит на heap, Ruby выделяет буфер минимум на 16 элементов (ARY_DEFAULT_SIZE), даже если нужны всего 4. Без этого минимума массив, растущий с 4 до 10 элементов, переаллоцировался бы 3+ раза (на 4, 6, 9 элементов при коэффициенте 1.5). С минимумом 16 — ноль переаллокаций до 16-го элемента.
Стратегия роста heap-буфера
Когда heap-буфер заполняется, Ruby увеличивает его на 50% от текущего размера (коэффициент 1.5). Для сравнения, динамический массив в теории обычно удваивается — Ruby выбрал более экономный по памяти вариант. При 1.5 массив после расширения использует минимум 67% буфера (при 2.0 — 50%).
heap capacity = 16 → добавляем элементы → заполнился
+8 = 24 → заполнился
+12 = 36 → заполнился
+18 = 54 → ...
Рост на 50% даёт амортизированную O(1) вставку — как и при удвоении, — но с меньшим потолком неиспользуемой памяти.
Shared-массивы: Copy-on-Write
Массив из 1000 элементов занимает 8 КБ данных (1000 × 8 байт VALUE). Если каждый вызов ary[100..200] копирует 100 элементов — это 800 байт и memcpy на каждый срез. В коде, активно работающем со срезами (парсинг, обработка данных), эти копии складываются в заметный расход CPU и памяти.
Ruby избегает копирования: создание подмассива (ary[1..3]) или копии (ary.dup) не копирует элементы. Дочерний массив получает указатель на буфер родителя и флаг RARRAY_SHARED_FLAG. Родитель помечается как RARRAY_SHARED_ROOT_FLAG, и его счётчик ссылок увеличивается.
a = [1, 2, 3, 4, 5]
a (shared root)
ptr --> [1][2][3][4][5]
^
b = a[1..3] |
b (shared) --+
ptr --> [2][3][4] (смещённый указатель в тот же буфер)
Буфер копируется только при модификации дочернего массива — это Copy-on-Write. Пока массивы только читаются, они разделяют память. Для приложений, активно использующих срезы (парсинг, обработка данных), это экономит и время, и память.
Shared-корень не может быть перемещён GC compaction, пока на него есть ссылки: он помечается как pinned.
Уменьшение массива
Динамический массив в теории описывает проблему shrink: после удаления большинства элементов capacity остаётся на пиковом уровне, и память тратится впустую. Некоторые реализации (Java ArrayList) предлагают явный trimToSize(), другие уменьшают буфер автоматически при падении заполненности ниже порога.
Ruby не делает ни того, ни другого для heap-буфера. Массив, выросший до capacity 10 000 и затем pop’нувшийся до 100 элементов, сохраняет буфер на 10 000 — capacity не уменьшается. Явного метода для сжатия capacity нет.
Единственный механизм возврата памяти — переход heap → embedded. Когда длина массива после pop, shift, clear или slice! снова помещается в слот, Ruby копирует элементы из heap-буфера обратно в слот, освобождает буфер через free и выставляет RARRAY_EMBED_FLAG. Условия: массив не shared и не frozen. Для массива в слоте 80 байт это означает ≤8 элементов — типичный порог для небольших коллекций. Но массив из 100 элементов в heap-буфере на 10 000 не получит ни уменьшения буфера, ни перехода в embedded.
Compaction и embedded-режим
GC compaction перемещает объекты, чтобы освободить целые страницы для возврата ОС. При перемещении GC может выбрать слот другого размера. Если массив в heap-режиме перемещается в больший слот, где его элементы помещаются в embedded — Ruby переключает массив обратно в embedded и освобождает heap-буфер.
До compaction: [слот 40 B] + [malloc-буфер на 6 элементов] → heap mode
После: [слот 80 B, 8 embedded capacity] → embedded mode
Это единственный способ увеличить слот объекта — Ruby не делает этого при обычном росте, только при GC compaction. Результат: меньше malloc-буферов, лучше локальность, меньше фрагментация.
Жизненный цикл массива
Создание: [] → embedded (слот 40 B, capacity 3)
Рост: << << << << → heap (malloc, min capacity 16, рост ×1.5)
Срез: a[1..3] → shared (указатель в буфер родителя, CoW)
Модификация: shared << x → копирование буфера (break CoW)
Уменьшение: pop/clear → heap capacity не уменьшается; если помещается в слот → обратно embedded
Compaction: GC перемещает в больший слот → может вернуться в embedded
Sources
- CRuby source:
array.c— Array implementation: https://github.com/ruby/ruby/blob/master/array.c - CRuby source:
include/ruby/internal/core/rarray.h— RArray struct: https://github.com/ruby/ruby/blob/master/include/ruby/internal/core/rarray.h - Ruby docs:
Array: https://docs.ruby-lang.org/en/master/Array.html - Peter Zhu, Variable Width Allocation — RubyKaigi 2022
- Pat Shaughnessy, Ruby Under a Microscope — Ch. 2: How Ruby Stores Data Internally