Управление памятью в Ruby
Предпосылки
Исполнение — VALUE, VM-стек, фреймы. Объекты и классы — RBasic, RObject, flags. Виртуальная память — страницы, TLB, CoW после
fork(). Управление памятью ОС —malloc/freeкак базовая модель ручного управления. Базовые структуры данных: связный список (для free list), граф (для графа объектов).
← Формы объектов | Array →
Сначала разберём базовые подходы к управлению памятью, затем — как это устроено в MRI/CRuby.
Часть I: Теория управления памятью и GC
Управление памятью: три подхода
Ruby создаёт объекты на каждом шагу исполнения: строки, массивы, хеши, замыкания. Без освобождения память процесса растёт — и за минуты интенсивной работы программа упирается в OOM. Существует три подхода к освобождению: ручной (C — malloc/free; подробнее о работе с динамической памятью — в управлении памятью), scope-based (Rust — RAII) и автоматический (GC). Ruby использует третий — недетерминированную автоматическую сборку мусора.
Подходы к управлению памятью
Существует несколько основных подходов управления памятью.
Самое базовое — ручное управление памятью. Примером служит ассемблер: разработчик должен сам выделить память, сам положить данные в память, сам освободить память.
Но с ручным управлением памятью есть проблема: из-за того что выделение памяти и освобождение — это две отдельные операции, по мере увеличения размера программы и её логической сложности повышается риск, что разработчик пропустит операцию освобождения памяти. К чему это приведёт? К тому, что мы получим memory leak (утечка памяти — непрерывный рост потребления из-за неосвобождённых объектов), и память процесса закончится.
Это является причиной для создания языков с автоматическим управлением памятью.
Автоматическое управление памятью
В языках C/C++ существует разделение: локальные переменные с фиксированным размером используют automatic storage (стек) — память выделяется при входе в функцию и освобождается при выходе, без явных операций программиста. Но для данных с динамическим размером или временем жизни, превышающим scope функции, используется heap (куча — область динамической памяти) с ручным управлением (malloc/free).
C++ и Rust решают проблему heap-объектов через концепцию RAII (Resource Acquisition Is Initialization — захват ресурса есть инициализация). Хотя правильнее было бы назвать это “Scope-Bound Resource Management”. Смысл этого понятия в том, что во всех языках программирования более высокоуровневых, чем ассемблер, мы можем создавать свои абстракции — то есть свои типы данных — и RAII связывает acquisition и release вместе с данными в одну атомарную абстракцию.
Терминология: Если память — это ресурс, то её выделение под процесс — это захват ресурса (acquisition) у системы, а освобождение — это возврат (release) ресурса системе.
RAII переиспользует механизм automatic storage: объект-обёртка живёт на стеке, и при выходе из scope компилятор автоматически вызывает его деструктор, который освобождает связанные heap-ресурсы.
Во всех этих случаях мы управляем памятью детерминированно: момент освобождения известен на этапе компиляции и определяется структурой кода (выход из лексического scope). Это не описывает всех интересных случаев в этих языках (умные указатели, RC и т.п., которые работают на основе Reference Counting), но и они тоже детерминированны.
В языках с детерминированным управлением памятью инструменты языка намеренно ограничены так, чтобы время жизни данных можно было определить заранее. Но есть другие языки — такие как Ruby, Python — где динамически изменять можно всё что угодно. Для таких языков детерминированная модель не работает, поэтому они используют недетерминированное автоматическое управление памятью.
Недетерминированное автоматическое управление памятью
В таких языках, как Ruby, из-за динамической природы невозможно предсказать, в какой момент программа перестанет использовать те или иные данные. Из-за этого освобождение памяти происходит недетерминированно.
В чём заключается недетерминированность? В том, что в таких языках, как Rust, очистка памяти является частью пошагового исполнения программы. В языках как Ruby это совершенно не так. Для таких языков исполнение программы и управление памятью разделяется на две независимые задачи: исполнение программы и сборка мусора.
Терминология: Для первого процесса есть классический термин из теории GC — Mutator (лат. mutare — «изменять»: сама программа с точки зрения GC, потому что она изменяет граф объектов). Термин ввёл Дейкстра. А Collector — это то, что собирает ненужные объекты.
Garbage Collector и исполнение программы
Назовём объектом выделенную память и записанные туда данные. При исполнении динамических программ создаётся множество объектов, одни объекты ссылаются на другие объекты — мы получаем структуру, которую валидно назвать граф объектов. Со временем в процессе исполнения программы связи меняются, и наш граф соответственно тоже. Это означает, что какие-то объекты или деревья становятся недостижимыми. Когда объект недостижим, его уже никто не сможет использовать, таким образом он не нужен программе и может быть удалён. Собственно, для поиска таких объектов и их очистки существует отдельная задача — Garbage Collector.
Как это работает? Мы чередуем исполнение и GC. Исполнение создаёт объекты и увеличивает общее потребление памяти, потом GC очищает те из них, которые недостижимы, и уменьшает потребление памяти.
Корни (roots) — это отправные точки для обхода графа объектов. К корням относятся: локальные переменные в текущем стеке вызовов, глобальные переменные, константы, внутренние объекты VM. Всё, что недостижимо от корней — мусор.
Tracing GC: базовые алгоритмы
Как работает Garbage Collector? Он берёт граф объектов и обходит его. Те объекты, до которых он смог добраться, остаются — их мы называем живые объекты. Те, до которых не смог добраться, освобождаются — их мы называем мёртвые объекты. Такой алгоритм работы называется tracing (трассирующий сборщик мусора).
Существует несколько вариантов tracing GC:
Mark-and-Sweep — сначала помечаем живые объекты от корней, потом освобождаем непомеченные (мёртвые).
Mark-and-Compact — после маркировки живые объекты перемещаются в начало кучи, устраняя фрагментацию; всё, что не перемещено, считается свободным.
Copying GC — память разделяется на две половины (from-space и to-space). Аллокация происходит в from-space. При заполнении живые объекты копируются в to-space компактно. После копирования from-space полностью свободен, и половины меняются ролями.
Важно: Все эти шаги идут друг за другом. Иначе, если представить, что мы сделали mark, потом исполняли программу, потом запустили sweep — как мы отличим новые объекты от мёртвых объектов?
Все базовые варианты GC являются Stop-The-World (STW). Это значит, что в момент работы GC программа останавливается, чтобы проделать работу. Снаружи это проявляется как пики задержек: в какой-то момент запрос/интерфейс отвечает нормально, а в какой-то — «подвисает» на время GC.
Оптимизации GC
Lazy Sweeping
Проблема STW: В классическом mark-sweep программа останавливается на время mark, затем на время sweep. Это одна длинная пауза, во время которой пользователь ждёт.
Первый шаг — разделение фаз: Mark завершается, программа продолжает работу, затем запускается sweep. Вместо одной большой паузы — две меньшие. Но sweep всё ещё останавливает программу целиком.
Второй шаг — отложенный (lazy) sweep: Sweep запускается не сразу после mark, а когда программе нужна память для новых объектов. Если памяти хватает — sweep откладывается. Пауза не просто разделена, а отложена до необходимости.
Третий шаг — инкрементальный sweep: Даже отложенный sweep может быть долгим, если накопилось много мусора. Решение: очищать не всё за раз, а порциями при каждой аллокации. Общий объём работы тот же, но паузы сглаживаются — программа остаётся отзывчивой.
Терминология: Lazy sweep — устоявшееся понятие в теории GC (The Garbage Collection Handbook, раздел 2.5). В теории lazy (отложенность) и incremental (порционность) — отдельные оптимизации. Ruby 1.9.3 реализует оба вместе.
STW: Mark ──────> Sweep ──────> Программа работает
пауза пауза
Lazy sweep: Mark ──> Программа ──> нужна память? ──> sweep порции ──> аллокация
пауза микропауза
Проблема безопасности: Разделение фаз создаёт новую проблему — между mark и sweep программа создаёт новые объекты, которые mark не видел. Как sweep отличит их от мусора?
Важное различие: mark vs sweep работают на разных уровнях
- Mark работает с графом объектов — обходит ссылки от roots, помечает достижимые объекты
- Sweep работает с секторами памяти — обходит страницы, освобождает непомеченные слоты
Решение — разделение памяти на секторы для sweep и аллокации:
Секторы: [1][2][3][4][5][6]
░ ░ · · · ·
^ ^
sweep аллокация
░ = ждёт очистки · = очищено
Sweep и аллокация работают с секторами памяти, двигаясь по кругу. Новые объекты попадают только в очищенные секторы — sweep их не затронет. Mark же работает логически с графом: он “не знает” про секторы, он знает про ссылки между объектами.
Компромисс — floating garbage: Между mark и sweep программа может удалить ссылку на помеченный объект. Объект уже мусор, но sweep его не соберёт — он был помечен как живой. Такой мусор “плавает” (floating garbage) до следующего цикла GC. Название — метафора: объект не привязан ни к живым (недостижим), ни к мёртвым (не собран), он плавает между состояниями. Это компромисс: меньше паузы, но часть мусора живёт дольше.
Generational GC
Проблема: При каждом GC мы сканируем все объекты. Чем больше объектов в программе, тем дольше пауза STW.
Решение: Эмпирическое наблюдение, подтверждённое десятилетиями исследований: большинство объектов умирают молодыми. Это называется generational hypothesis или infant mortality.
В типичной программе:
- Временные объекты (промежуточные строки, итераторы, блоки) создаются и становятся мусором почти сразу
- Долгоживущие объекты (конфигурация, кэши, синглтоны) живут до конца программы
- Объектов «среднего возраста» относительно мало
Идея: разделить объекты на поколения (generations):
- Молодое поколение (young generation) — недавно созданные объекты
- Старое поколение (old generation) — объекты, пережившие несколько циклов GC
Если объект переживает несколько циклов GC, он продвигается (promotion) из young в old. Mark-фаза разделяется на два варианта:
- Minor GC — проходим только young-объекты
- Major GC — проходим все объекты (young + old)
Результат: Minor GC сканирует небольшое количество объектов — паузы короткие. Major GC по-прежнему сканирует всё, но запускается редко. Средняя отзывчивость программы улучшается.
Проблема: ссылки Old → Young
Старый объект ──ссылается──→ Молодой объект
При minor GC мы сканируем только young-объекты, начиная с корней. Но молодой объект может быть недостижим напрямую из корней — единственная ссылка на него идёт из старого объекта. Если мы не посмотрим на старый объект, мы не узнаем про молодой и ошибочно соберём его как мусор.
Инструмент: Write Barrier и Remembered Set
Write barrier — код, который выполняется при каждой записи ссылки в объект. Когда old-объект получает ссылку на young-объект, write barrier перехватывает это и запоминает.
Remembered set — структура данных, где GC хранит old-объекты, которые могут содержать ссылки на young. При minor GC эти объекты сканируются дополнительно к young-поколению.
1. Старый объект получает ссылку на молодой
2. Write barrier срабатывает:
- Замечает: old-объект ссылается на young-объект
- Добавляет старый объект в remembered set
3. При minor GC:
- Сканируем корни → находим young-объекты
- Сканируем remembered set → находим старый объект → находим молодой
- Молодой объект помечен как живой, не собран
Incremental GC
Проблема: Generational GC решает проблему частых пауз, но со временем количество old-объектов растёт. Major GC становится “бутылочным горлышком” — он длится заметно дольше minor GC и ухудшает отзывчивость.
Решение: Разбить major GC на маленькие шаги, чередуя их с исполнением программы.
[minor] → ... → [minor] → [MAJOR шаг] → [программа] → [MAJOR шаг] → ... → [MAJOR завершён] → [minor]
Проблема реализации: как возобновлять mark эффективно
Если mark-фаза использует один бит («помечен» / «не помечен»), возобновление после паузы наталкивается на проблему: среди помеченных объектов часть уже полностью обработана (все дети найдены), а часть ещё нет — но одного бита недостаточно, чтобы отличить одних от других. Единственный способ найти необработанных — пройти по всем помеченным и проверить их детей заново. Чем больше живых объектов, тем дольше каждое возобновление.
Инструмент: Tri-color marking
Tri-color marking (трёхцветная разметка) решает эту проблему, вводя явное разделение между «найден, но не обработан» и «полностью обработан».
Ключевое отличие от одного бита: необработанные объекты хранятся в явной рабочей очереди (mark stack). Возобновление — это O(1) pop из очереди, а не поиск среди всех помеченных. Каждый объект обрабатывается ровно один раз.
Вместо одного бита используются три состояния:
White (белый): объект ещё не посещён. В начале mark-фазы все объекты белые. После завершения mark-фазы белые объекты — это мусор.
Grey (серый): объект достижим (живой), но его дети (объекты, на которые он ссылается) ещё не обработаны. Серые объекты — это явная рабочая очередь GC.
Black (чёрный): объект достижим, и все его дети уже добавлены в очередь обработки. GC закончил с этим объектом.
Ключевое отличие: серые объекты хранятся в mark stack — явном списке. Каждый шаг обрабатывает фиксированное количество объектов из этого списка:
- Взять N объектов из mark stack
- Для каждого:
- Просканировать его ссылки
- Белых детей покрасить в серый, добавить в mark stack
- Покрасить объект в чёрный
- Вернуть управление программе
Результат: Каждый шаг обрабатывает ровно N объектов — паузы предсказуемы и не зависят от общего количества объектов.
Проблема: граф меняется между шагами
В stop-the-world GC граф объектов не меняется во время mark-фазы. Но в incremental GC между шагами выполняется программа, которая может создавать новые ссылки.
Ключевое свойство корректного tri-color marking — tri-color invariant: никогда не должно быть прямой ссылки из чёрного объекта на белый. Почему? Чёрный объект уже обработан, GC к нему не вернётся. Если он ссылается на белый, этот белый объект никогда не будет найден и будет ошибочно собран как мусор.
Инструмент: Write Barrier для Incremental
Write barrier используется и здесь, но с другой целью. Когда программа создаёт ссылку из чёрного объекта на белый, write barrier перехватывает это и делает так, чтобы белый объект не был потерян сборщиком: например, сразу помечает белого ребёнка (делает его серым) или возвращает родителя в очередь. В MRI/CRuby используется подход «пометить ребёнка»: ребёнок становится серым и добавляется в mark stack.
1. Чёрный объект получает ссылку на белый
2. Write barrier срабатывает:
- Замечает: чёрный → белый
- Помечает белого ребёнка (делает его серым)
- Добавляет ребёнка в mark stack
3. При следующем шаге GC:
- Ребёнок будет обработан (его ссылки просканированы)
- Объект не потерян
Итог: Write Barrier служит двум целям
- Для Generational GC: отслеживание ссылок old → young (добавление в remembered set)
- Для Incremental GC: поддержание tri-color invariant (не допустить прямой ссылки black → white)
Если упростить до одной операции “записали ссылку parent → child”, то write barrier проверяет две разные проблемы:
- Во время major GC с разметкой шагами: если
parentуже чёрный, аchildещё белый →childпереводится в серый и попадает в очередь маркировки (иначе нарушится tri-color invariant). - Для generational GC (чтобы minor GC не пропустил old → young): если
parentold, аchildyoung →parentпомечается как remembered, чтобы minor GC потом просканировал его ссылки.
Если ни одна из этих ситуаций не подходит — барьер ничего не делает.
Compaction
Проблема: Mark-and-sweep освобождает память мёртвых объектов, но не перемещает живые. После многих циклов GC память становится фрагментированной — живые объекты разбросаны по разным страницам, между ними пустые слоты.
Фрагментация бьёт по трём направлениям. Достаточно одного живого объекта на странице, чтобы всю страницу нельзя было вернуть ОС — память процесса в RAM не падает, даже если большинство слотов свободны. Связанные объекты оказываются на разных страницах — растёт число cache misses. Наконец, GC вынужден обходить множество полупустых страниц, тратя время впустую.
Решение: Compaction (уплотнение) — перемещение живых объектов так, чтобы они лежали плотно, а свободная память собралась в конце.
Сложности реализации
1. Обновление ссылок: Когда объект перемещается, все ссылки на него становятся недействительными. GC должен найти и обновить каждую ссылку на новый адрес.
2. Pinned objects: Compaction хочет перемещать объекты, но внешний код (за пределами управляемой памяти) может держать сырой указатель на объект. Если переместить — внешний код будет читать мусор по старому адресу. Решение — пометить такие объекты как “неперемещаемые” (pinned). Compaction двигает остальные, pinned остаются на месте. Компромисс: compaction работает, но pinned объекты создают “дырки” которые нельзя убрать.
3. Forwarding: Когда объект перемещается, все ссылки на него становятся невалидными — они указывают на старый адрес. Проблема: как найти новый адрес? Решение: на старом месте оставляют forwarding pointer — адрес нового местоположения. При обходе графа для обновления ссылок GC видит forwarding pointer и знает куда перенаправить.
Алгоритмы compaction
Two-finger compaction: Два курсора движутся навстречу друг другу — один ищет свободные слоты (слева), другой живые объекты (справа). Когда оба нашли цель — объект перемещается.
Copying collector: живые объекты копируются в новое место “плотным блоком”, а старое пространство становится целиком свободным. Для compaction важно именно это: после копирования легче получить полностью пустые страницы.
Ruby GC — генерационный инкрементальный mark-sweep с компактификацией. Mark обходит граф живых объектов от корней (VM-стек, глобальные переменные); всё достижимое — живое, остальное — мусор. Sweep освобождает неотмеченные слоты — Ruby делает это лениво, по мере аллокации, а не за один проход. Генерации отражают эмпирический факт: большинство объектов умирают молодыми, поэтому молодые объекты проверяются часто (minor GC), старые — редко (major GC); write barrier следит за ссылками из старых объектов на молодые. Инкрементальность разбивает mark-фазу major GC на шаги через tri-color marking, чтобы пауза не превышала 1–2 ms. Компактификация перемещает живые объекты для снижения фрагментации и возврата страниц ОС (Ruby 2.7+).
Часть II: Реализация GC в Ruby (MRI/CRuby)
Алгоритмы из Part I описывают общие принципы, но конкретная реализация зависит от решений VM: как устроен объект в памяти, из чего состоит куча, что именно запускает GC. MRI комбинирует несколько оптимизаций — generational, incremental, lazy sweep, compaction — и каждая проявляется в конкретных структурах данных. Проследим это на жизни одного объекта — массива arr.
В MRI Ruby‑объекты живут в слотах кучи. Пока есть свободные слоты, аллокация просто берёт следующий слот. Когда слоты заканчиваются, GC размечает достижимые объекты (mark) и освобождает слоты недостижимых (sweep), чтобы аллокация могла продолжиться.
Старт: массив как объект в памяти Ruby
Возьмём конкретный объект:
arr = []В MRI переменная arr живёт в VM‑стеке (в локальных переменных фрейма), а сам объект массива живёт в куче.
VALUE и pointer tagging
В CRuby все ссылки на объекты представлены типом VALUE (подробнее — в заметке об исполнении) — это uintptr_t, целое число размером с указатель (8 байт на 64-bit системах).
Ruby использует выравнивание: объекты в куче выровнены по границе 8 байт, поэтому младшие 3 бита адреса всегда нули. Этот факт используется для pointer tagging: примитивные объекты (маленькие числа, символы, nil, true, false) хранятся прямо в VALUE, а младшие биты кодируют тип. Так Ruby отличает примитивы от указателей на объекты в куче.
Массив — не примитив, поэтому arr хранит указатель на объект в куче.
RVALUE и RBasic
На уровне GC каждый объект в куче занимает слот (часто его называют RVALUE). В начале слота лежит заголовок struct RBasic (flags + klass — см. Объекты и классы). После заголовка идут данные, специфичные для типа (struct RArray, struct RString, struct RObject и др.). Для массива это поля struct RArray (например, длина и место, где лежат элементы).
Итого на этом шаге:
- VM-стек → локальная переменная
arr(VALUE) arr(VALUE) → объектArrayв куче (struct RArray, начинается сstruct RBasic)
Аллокация: страницы, слоты, free list
Чтобы создать arr, Ruby должен выделить RVALUE-слот в куче под новый объект.
Куча в MRI разбита на страницы (struct heap_page) — это собственные страницы Ruby GC, не путать со страницами виртуальной памяти ОС, хотя принцип похож: фиксированные блоки для управления памятью. Каждая страница содержит слоты (RVALUE-слоты) одинакового размера; один слот — место под один объект.
Когда слот свободен, его память используется как узел связного списка struct free_slot: узел лежит прямо в памяти свободного слота (это не отдельный массив указателей). Свободные слоты этой страницы образуют связный список heap_page->freelist. В обычном случае аллокация — взять следующий свободный слот (O(1)) и записать туда новый объект.
Начиная с Ruby 3.0 выполнение идёт внутри Ractor’ов: даже если программа не создаёт Ractor’ы явно, существует main ractor. Аллокация обычно выдаёт слоты через локальный кэш текущего ractor (newobj‑кэш): он забирает heap_page->freelist у одной страницы и выдаёт слоты из этого списка, пока он не закончится.
Этот free list пополняется на sweep: когда массив станет мусором, sweep-фаза пометит его слот как свободный и вернёт в heap_page->freelist — следующий объект сможет занять это место.
Итого по структурам аллокации:
- куча содержит страницы (
struct heap_page), страница содержит слоты (RVALUE-слоты) - свободные слоты страницы связаны в
heap_page->freelist(struct free_slot)
Рост массива: embedded → внешний буфер (malloc) и VWA
Пока массив маленький, Ruby старается держать его элементы прямо внутри RVALUE‑слота: так доступ к элементам не требует отдельного malloc и лучше попадает в CPU cache.
На уровне struct RArray есть два режима хранения элементов:
- embedded: элементы лежат в самом объекте (
RArray.as.ary[]), а длина закодирована во флагах - heap: объект хранит
len/capaи указательRArray.as.heap.ptrна отдельный C‑буфер с элементами
Когда массив растёт и embedded‑места не хватает, Ruby переключает массив на heap‑представление: выделяет буфер под элементы и записывает в объект указатель. Это даёт массиву произвольный размер, но появляется второй слой памяти: слот → указатель → элементы.
Variable Width Allocation (VWA)
До Ruby 3.2 размер RVALUE‑слота был фиксирован: BASE_SLOT_SIZE (в типичном 64-bit build ≈ 40 байт). Это ограничивало “встроенную” ёмкость: часть массивов раньше уходила во внешний буфер, и на пути появлялись дополнительные malloc/free и лишний уровень косвенности.
Ruby 3.2 ввёл Variable Width Allocation (VWA): GC‑куча для Ruby‑объектов делится на несколько пулов слотов фиксированного размера (size classes). У каждого пула свой размер слота; в MRI по умолчанию таких пулов 5 (HEAP_COUNT = 5). Размеры слота = BASE_SLOT_SIZE × 2^n (в типичном 64-bit build это 40, 80, 160, 320, 640 байт).
Практический эффект для массива: если при создании массива Ruby знает, что ему нужно больше embedded‑места (например, литерал или создание массива с ёмкостью), он может выделить RArray в более крупном слоте — тогда embedded‑ёмкость выше, и массив дольше живёт без отдельного malloc.
Это работает не за счёт “ручного выбора пула”, а через размер аллокации: чем больше объект (struct RArray) — тем более крупный слот нужен, и аллокатор берёт его из соответствующей size class.
В реализации MRI один такой пул страниц одного slot_size — это rb_heap_t. В коде массив таких пулов называется rb_objspace_t.heaps[HEAP_COUNT] (хотя логически это именно пулы/size classes, а не “куча из куч”).
Аллокация в VWA: size class → page → slots
Выбор пула начинается с размера аллокации: по размеру аллоцируемого объекта (в коде alloc_size) вычисляется heap_idx (size class), и дальше аллокатор работает с соответствующим пулом rb_heap_t (один slot_size).
У пула есть список страниц pages, но для аллокации важнее free_pages: это страницы, из которых сейчас разрешено брать слоты. Страница попадает в free_pages после sweep в текущем цикле (или когда GC добавляет в пул новую/воскрешённую пустую страницу). В struct heap_page это флаг flags.before_sweep: пока он true, страница ещё не прошла sweep и из неё не аллоцируют.
Внутри страницы свободные слоты связаны в heap_page->freelist. Аллокатор берёт страницу из rb_heap_t.free_pages, забирает её heap_page->freelist и выдаёт слоты из этого списка. Когда список заканчивается — берёт следующую страницу из free_pages.
Итого по VWA:
Array(struct RArray) хранит элементы либо embedded (as.ary[]в слоте), либо черезas.heap.ptrво внешний буфер- состояние GC (
rb_objspace_t) содержитheaps[HEAP_COUNT]— массив пулов (rb_heap_t) - каждый пул (
rb_heap_t) держит страницы (struct heap_page) одного размера слота и списокfree_pages(страницы, готовые для аллокации) - каждая страница держит свободные слоты в
heap_page->freelist - аллокация берёт страницу из
free_pagesи выдаёт слоты из еёfreelist
Когда запускается GC: аллокация → sweep → mark
Аллокация RVALUE‑объектов идёт ступенями. Обычно newobj‑кэш текущего Ractor выдаёт слоты из своего локального freelist. Когда он заканчивается, Ractor берёт страницу из rb_heap_t.free_pages (нужной size class) и забирает её heap_page->freelist. Если в нужной size class free_pages пуст — GC делает работу, после чего аллокатор пробует снова.
Напомним: GC состоит из двух фаз — mark (разметить живые объекты) и sweep (освободить слоты мёртвых).
Триггеры sweep (lazy)
Sweep в MRI запускается по требованию аллокации: когда у Ractor закончился локальный freelist и в нужном пуле (rb_heap_t) пуст free_pages, GC делает небольшой шаг sweep и пробует снова.
- В пуле нет
free_pages→ sweep делает порцию работы (просвипывает страницы, суммарно ~2048 слотов) - Появились
free_pages→ аллокатор берёт страницу и продолжает аллокацию; sweep откладывается до следующей необходимости
Это lazy sweep в действии: mark делается один раз на цикл, а sweep может выполняться многими короткими шагами по запросу аллокации.
Триггеры mark
Если после нескольких sweep‑шагов free_pages всё ещё не появился — GC вынужден сделать mark, чтобы понять, какие объекты живые, и какие слоты можно освободить.
Практически это выглядит как “подготовка страниц для аллокации”:
- Если GC уже идёт (выполняется mark или sweep) — продолжить его.
- Если можно добавить новую страницу в пул (из глобального пула пустых страниц или через выделение новой) — добавить её.
- Если страницу добавить нельзя — запустить новый GC‑цикл (
gc_start), после чего sweep должен подготовитьfree_pagesдля аллокации.
На Ruby-уровне причину запуска mark можно увидеть в GC.latest_gc_info[:gc_by]:
:newobj— при создании Ruby-объекта свободных слотов не хватило даже после sweep:malloc— C‑выделения памяти превысили бюджетmalloc_limit(GC ограничивает суммарныйmallocмежду запусками mark):method— ручной вызовGC.start:capi— GC вызван через C API:stress— включён стресс‑режим GC
GC Roots в Ruby
Когда начинается mark, GC не может обойти «все объекты сразу»: он начинает обход графа объектов от корней (roots). Если объект достижим от любого root — он живой.
В нашем примере корень — это локальная переменная arr в VM-стеке: пока она существует, массив достижим и должен пережить GC.
Кроме стека, к roots относятся глобальные переменные, константы (включая классы и модули), внутренние объекты VM, и объекты, которые C-расширения намеренно добавляют в roots через rb_gc_register_mark_object (C API: «считай этот объект достижимым от корней»).
Типичная причина “утечки памяти” в Ruby — объект случайно остаётся достижимым от root (например, через глобальную переменную или константу).
Как mark размечает arr: tri-color в структурах MRI
Корни задают точки старта, а дальше GC проходит по ссылкам и помечает всё достижимое. На уровне кучи «пометить объект» означает пометить его слот на странице кучи.
В MRI метки живости хранятся в bitmap’ах на странице (struct heap_page), по одному биту на слот:
heap_page->mark_bits— «этот слот помечен как живой»heap_page->marking_bits— «этот слот сейчас в очереди на обработку ссылок»
А сама очередь “серых” объектов — это rb_objspace_t.mark_stack: туда складываются объекты, чьи ссылки ещё нужно просканировать.
Это напрямую соответствует tri-color marking:
- White:
mark_bit = 0 - Grey:
mark_bit = 1иmarking_bit = 1(объект найден, но его дети ещё не обработаны) - Black:
mark_bit = 1иmarking_bit = 0(дети обработаны)
Упрощённо, mark выглядит так:
push(root)
while (obj = pop(mark_stack))
scan_references(obj) { |child| mark_and_push(child) }
clear_marking_bit(obj) # obj становится black
end
В нашем примере это выглядит так:
- Root
arr— этоVALUEна VM-стеке. Если вVALUEхранится указатель на объект в куче, то он указывает на RVALUE-слот. - GC помечает слот
arrвmark_bitsи кладётarrвmark_stack(делает его grey). - GC достаёт
arrизmark_stack, читаетstruct RArrayи сканирует его ссылки (элементы массива):- если массив embedded — элементы лежат прямо в
as.ary[]; - если heap — элементы лежат в буфере
as.heap.ptr, но в обоих случаях элементы — этоVALUE.
- если массив embedded — элементы лежат прямо в
- Для каждого элемента: если
VALUEявляется указателем на объект в куче, GC помечает слот “ребёнка” и кладёт его вmark_stack. - После сканирования всех ссылок
arrстановится black (для него сбрасываетсяmarking_bit).
Полный mark — это обход всего достижимого графа. Если делать его часто на большом heap — будет дорого. Поэтому Ruby старается, чтобы большинство сборок ограничивались young‑поколением: так появляется RGenGC.
RGenGC — Generational GC в Ruby
Если массив становится частью долгоживущего состояния (например, кэша), он переживает несколько циклов и становится старым (old).
Ruby 2.1 ввёл RGenGC — Restricted Generational Garbage Collection: он делает большинство сборок быстрыми за счёт того, что чаще собирает только young‑поколение.
Minor vs Major GC
- Minor GC — сканируем только young-объекты + remembered set
- Major GC — сканируем все объекты
Это позволяет иметь много быстрых minor GC и мало медленных major GC.
Как объект становится old: age → promotion
Возраст объекта (age, значения 0–3) хранится в bitmap heap_page->age_bits (2 бита на объект). Когда age достигает 3 (RVALUE_OLD_AGE), объект promoted в old‑поколение.
У promoted объекта в struct RBasic выставляется флаг FL_PROMOTED. Это “быстрый ответ на вопрос: объект old?” — он нужен write barrier, потому что барьер срабатывает на каждой записи ссылки, и проверка должна быть O(1).
Для minor GC promoted/old‑объекты считаются “не собирать”: MRI отмечает их слоты в heap_page->uncollectible_bits. При запуске minor GC разметка начинается не с нуля: mark_bits страницы инициализируются из uncollectible_bits, поэтому такие слоты считаются живыми и не будут освобождены на sweep.
Write barrier: ссылка old → young (remembered set)
Представим, что массив стал долгоживущим, и мы добавляем в него новый объект:
CACHE = []
CACHE << []
arr = CACHE[0] # долгоживущий массив
arr << Object.new # old → youngЕсли при minor GC не учитывать arr, то объект, добавленный в массив, можно ошибочно собрать: он может быть достижим только через old‑массив. Поэтому при записи ссылки срабатывает write barrier, и массив помечается как “remembered” (в MRI: выставляется бит heap_page->remembered_bits). Затем minor GC дополнительно сканирует remembered set и находит все young‑объекты, на которые ссылаются old‑объекты.
В этом примере нас интересует generational часть барьера (remembered set): она нужна только для случая old → young. В major GC, где разметка может идти шагами, write barrier ещё и поддерживает tri-color invariant (black → white): ребёнок “подкрашивается” и попадает в очередь маркировки.
Упрощённый алгоритм generational write barrier для такой записи:
- Родитель old? Проверяем
FL_PROMOTEDу массива.- Если родитель не old →
return(minor GC и так увидит эту ссылку через young‑родителя).
- Если родитель не old →
- Ребёнок young? Проверяем
FL_PROMOTEDу добавляемого объекта.- Если ребёнок old →
return(это не old→young, minor GC не нужно об этом знать).
- Если ребёнок old →
- old→young → помечаем родителя как remembered: выставляем бит в
heap_page->remembered_bits(и на minor GC этот родитель будет просканирован как часть remembered set).
Почему “Restricted”
Идея generational GC — сделать большинство сборок быстрыми: на minor GC сканировать только young, а старые объекты не трогать. Но это корректно только если GC знает все ссылки вида old → young. За это отвечает write barrier: при записи ссылки он фиксирует “этот старый объект теперь нужно пересканировать на minor” (через remembered set).
Проблема Ruby в том, что C-расширения могут записывать VALUE напрямую в память объекта или его буфера. Для GC это выглядит как “записи ссылки не было”: write barrier не срабатывает, remembered set не обновляется. Если такой объект был бы old, minor GC мог бы не увидеть появившуюся ссылку old → young и ошибочно собрать живой young‑объект.
Поэтому Ruby делает generational GC restricted: promotion в old разрешён только тем объектам, для которых можно гарантировать работу write barrier.
Ruby ввёл разделение:
WB-protected: объекты, для которых гарантируется корректная работа write barrier. Они могут быть promoted в old-поколение.
WB-unprotected (shady): объекты, чьи ссылки могут быть изменены в обход барьера (например, из C-кода прямой записью VALUE). Термин “shady” (теневой, сомнительный) использовался в ранних обсуждениях и до сих пор встречается в коде Ruby.
В MRI это хранится как bitset на странице кучи: heap_page->wb_unprotected_bits. Если бит для объекта выставлен — объект WB-unprotected, если нет — WB-protected.
Как объект становится WB‑unprotected на практике: это либо внутреннее решение MRI для конкретных объектов, либо явный сигнал от C‑кода через API rb_gc_writebarrier_unprotect(obj) (когда расширение не может гарантировать корректное использование write barrier при мутациях ссылок).
На уровне реализации это поддерживается bitmap’ом heap_page->uncollectible_bits: он помечает слоты, которые minor GC не должен освобождать. Old‑объекты попадают туда при promotion, и тем же механизмом Ruby держит uncollectible “shady” WB-unprotected объекты — их достижимость проверит только major GC.
WB-unprotected объекты не могут быть promoted в old-поколение — это и есть “ограничение” (restriction): Ruby не может позволить им стать “старыми”, потому что тогда minor GC перестанет видеть их изменения ссылок. На minor GC такие объекты считаются живыми автоматически (uncollectible), но их ссылки всё равно сканируются, чтобы не пропустить ссылки на young. Собрать их как мусор может только major GC.
Это позволило внедрять write barrier постепенно, класс за классом, не ломая совместимость с существующими C-расширениями.
Если WB-unprotected объектов становится много, minor GC начинает дорожать и плохо освобождает память, поэтому Ruby может чаще переходить к major GC (в GC.latest_gc_info[:major_by] это видно как :shady).
В новых версиях Ruby список WB-protected объектов постепенно расширяется (конкретный перечень зависит от версии).
Логика обновления remembered set
Remembered set не должен “засоряться” навсегда: ссылка old→young может исчезнуть, а young‑объект со временем может стать old.
В MRI remembered set самоочищается на каждом minor GC: GC берёт remembered set текущего цикла, сканирует эти старые объекты, и по мере сканирования заново помечает родителя remembered только если он всё ещё ссылается на young. Если young‑ребёнок уже old или ссылка удалена — родитель просто не будет remembered в следующем цикле.
Отсюда естественные сценарии:
- Young-объект promoted в old: ссылка old→young стала old→old, старый объект можно убрать из remembered set
- Young-объект остаётся young: старый объект остаётся в remembered set
- Ссылка удалена: старый объект можно убрать из remembered set
Когда minor превращается в major
Когда mark всё-таки нужен, Ruby по умолчанию начинает с minor GC (он быстрый). Но иногда minor не решает проблему, и Ruby переходит к major GC. На Ruby-уровне причину можно увидеть в GC.latest_gc_info[:major_by]:
:nofree— minor не освободил слоты (young‑мусора мало):oldgen— old‑поколение переполнено:shady— много WB-unprotected объектов (minor их не соберёт; их достижимость проверяет только major):oldmalloc—mallocв old‑объектах превысил бюджетoldmalloc_limit:force— принудительный major (GC.startи похожие триггеры)
Major GC в MRI: lazy sweep и incremental marking
Когда arr и другие old‑объекты накапливаются, major GC становится заметен по паузам. В MRI паузы сокращают двумя идеями:
- Sweep делается ленивым (работает порциями по мере необходимости).
- Mark для major GC делается инкрементальным (работает шагами, чередуясь с выполнением программы).
Путь к инкрементальности
Koichi Sasada в описании RIncGC (Feature #10137) отмечает:
“Ruby 1.9.3 introduced a ‘lazy sweep’ GC which reduces pause time in the sweeping phase… Lazy sweep is half of the incremental GC algorithm. Now, we need to make major GC marking phase incremental.”
То есть до Ruby 2.2 была “полуинкрементальность”:
| Фаза | Ruby 1.9.3+ | Ruby 2.2+ |
|---|---|---|
| Mark | Полностью за один проход (stop-the-world, STW) | Инкрементально (шагами) |
| Sweep | Инкрементально (lazy sweep) | Инкрементально (lazy sweep) |
Lazy sweep разбивает sweep-фазу на шаги: вместо очистки всех мёртвых объектов за раз, Ruby очищает по мере необходимости — когда нужны новые слоты для аллокации. Каждый шаг обрабатывает порцию (~2048 слотов).
Но mark-фаза оставалась монолитной. Для minor GC это не критично (он быстрый), но major GC с большим количеством old-объектов мог занимать десятки миллисекунд. RIncGC сделал mark-фазу major GC инкрементальной, завершив путь к полной инкрементальности.
Структуры данных для Lazy Sweep в Ruby
Lazy sweep использует разделение страниц внутри каждого пула (rb_heap_t). Ключевая идея: страница либо «ещё не swept», либо «уже swept». В struct heap_page это флаг flags.before_sweep (true = ещё не swept), а на уровне rb_heap_t есть sweeping_page (итератор) и free_pages (страницы, из которых аллокатор может брать слоты).
Важно различать два уровня “свободного”:
- внутри страницы свободные слоты лежат в
heap_page->freelist; - на уровне пула
free_pages— список страниц, на которых после sweep есть хотя бы один свободный слот (и значит из них можно аллоцировать).
Внутри rb_heap_t есть ещё pooled_pages — временный буфер страниц во время lazy sweep; для аллокации важен именно free_pages.
Newobj‑кэш ractor может на время забирать heap_page->freelist у выбранной страницы, поэтому в начале sweep GC сбрасывает newobj‑кэши и возвращает свободные слоты обратно на страницы.
Как mark работает с памятью:
Mark обходит граф объектов по ссылкам, а не страницам. На minor GC обходит только young объекты + remembered set (old считаются уже помеченными). Страницы — физическое хранение, на одной странице могут быть и young и old вперемешку.
Жизненный цикл страницы (sweep):
Mark завершён → GC входит в sweep:
- всем страницам выставлен `before_sweep=true`
- `free_pages` (на уровне `rb_heap_t`) очищен
- newobj‑кэши ractor’ов сброшены (свободные слоты возвращены на страницы)
Аллокации нужен слот:
- если у текущего Ractor есть локальный `freelist` → берём слот и продолжаем
- иначе, если `free_pages` не пуст → берём страницу из `free_pages` и забираем её `heap_page->freelist`
- иначе → GC делает sweep‑шаг (просвипывает ещё страницы с `before_sweep=true`, суммарно ~2048 слотов) и подготавливает страницы со свободными слотами так, чтобы они появились в `free_pages`
Sweep‑шаги повторяются в рамках одного GC‑цикла столько раз, сколько нужно аллокатору; новый mark будет только в следующем цикле.
Инвариант безопасности: Страницы с before_sweep=true и free_pages не пересекаются. Аллокатор берёт только из free_pages, sweep обрабатывает только страницы с before_sweep=true.
Почему метки живости — bitmap (CoW)
Mark размечает граф через mark_bits/marking_bits и mark_stack. Эти структуры важны не сами по себе — они появились как ответ на ограничения памяти после fork() (CoW) и как фундамент для incremental marking.
Ruby 1.8, 1.9: FL_MARK в объекте
Метка “живой” хранилась прямо в поле flags struct RBasic — флаг FL_MARK:
RVALUE-слот (~40 байт)
├── RBasic
│ ├── flags: ... | FL_MARK | ... ← метка внутри объекта
│ └── klass: ...
└── данные объекта
Проблема: При маркировке GC модифицирует сами объекты. Это ломает Copy-on-Write (CoW) — механизм ОС (виртуальная память), позволяющий форкнутым процессам разделять память до первой записи. После fork() родительский и дочерний процессы разделяют страницы памяти. Но как только GC начинает маркировку, он записывает в объекты, и ОС вынуждена копировать страницы — память процесса в RAM резко растёт (вплоть до ~2x для затронутых страниц).
Ruby 2.0: Bitmap Marking
Narihiro Nakamura вынес метки в отдельную структуру — bitmap heap_page->mark_bits (далее — mark_bits[]). В этом bitmap один бит соответствует одному слоту:
Heap Page Mark Bitmap
┌────┬────┬────┬────┬────┐ ┌─┬─┬─┬─┬─┐
│obj │obj │free│obj │obj │ │1│1│0│1│1│
└────┴────┴────┴────┴────┘ └─┴─┴─┴─┴─┘
0 1 2 3 4 0 1 2 3 4
Бит i соответствует слоту i: 1 = живой, 0 = мусор
Теперь при маркировке объекты не модифицируются — записывается только bitmap. CoW работает корректно.
Tri-color и mark_stack/marking_bits дают возможность делать mark шагами и корректно “досканировать” граф позже: серые объекты лежат в mark_stack, а marking_bits даёт O(1) проверку “объект ещё в очереди на обработку ссылок?”.
Важно: Incremental marking применяется только к major GC: именно major mark может быть достаточно длинным, чтобы его было выгодно дробить.
При incremental marking в конце mark-фазы нужен дополнительный проход по всем живым WB-unprotected объектам — они могли изменить ссылки в обход write barrier.
Фрагментация и Compaction в Ruby
Инкрементальный mark помогает с паузами, но отдельно остаётся вопрос памяти процесса: освобождённые слоты могут оказаться “размазаны” по куче так, что целые страницы кучи не освобождаются. В этом случае Ruby‑процесс может потреблять гигабайты памяти, хотя реально использует сотни мегабайт — живые объекты разбросаны по страницам, и ни одну страницу нельзя вернуть ОС. Это увеличивает стоимость серверов и может вызвать OOM на других процессах.
Eden vs Tomb pages
Eden pages — страницы кучи GC с хотя бы одним живым объектом (нельзя вернуть ОС). Tomb pages — полностью пустые (можно вернуть).
Важно: здесь “eden” — просто термин “страница с живыми объектами”, не имеет отношения к young‑поколению в generational GC.
Фрагментация: большинство страниц остаются eden из-за разбросанных живых объектов:
[live][ ][ ][live][ ][live][ ][ ][live]
После compaction:
[live][live][live][live][ ][ ][ ][ ][ ]
Теперь страницы справа становятся tomb и могут быть возвращены ОС.
T_MOVED и жизненный цикл forwarding
Когда объект перемещается, на его старом месте Ruby оставляет маркер T_MOVED с forwarding address — адресом нового местоположения.
Жизненный цикл:
- Compaction перемещает объект → на старом месте создаётся T_MOVED с адресом нового места
- Фаза обновления ссылок → GC обходит все объекты, проверяет их ссылки. Если ссылка указывает на T_MOVED — заменяет на forwarding address
- После compaction → ни один объект не должен ссылаться на T_MOVED. Слоты с T_MOVED становятся свободными при следующем sweep
До: [obj_A] ──ссылка──→ [obj_B старый адрес]
Move: [obj_A] ──ссылка──→ [T_MOVED → новый адрес] [obj_B новый адрес]
Update: [obj_A] ──ссылка──→ [obj_B новый адрес] [T_MOVED → free]
Pinned objects в Ruby
На уровне страницы (struct heap_page) есть два bitmap’а:
uncollectible_bits— объекты, которые minor GC не освобождает (old + “shady” WB-unprotected)pinned_bits— объекты, которые нельзя перемещать при compaction
Объект становится pinned (не перемещаемым) если:
- Помечен через
rb_gc_mark()(legacy API для C-расширений) - Адрес передан во внешний код
Для C-расширений, поддерживающих compaction, есть rb_gc_mark_movable().
История Compaction в Ruby
- Ruby 2.7 (2019):
GC.compact— ручной вызов, Aaron Patterson - Ruby 3.0+:
GC.auto_compact = true— автоматически при major GC (по умолчанию выключено)
Compaction и VWA
VWA позволяет compaction перемещать объекты между пулами разных размеров — объект может быть перемещён в больший или меньший слот в зависимости от фактического использования памяти.
Ractors и GC
Пока в процессе один ractor, newobj‑кэш просто делает аллокацию дешёвой. Когда ractor’ов несколько, появляется новая цена: граница “пополнить локальный кэш” требует синхронизации на общих структурах кучи, и аллокация начинает упираться в блокировки.
Проблема: синхронизация на аллокации
Если каждой аллокации нужно брать mutex, чтобы достать страницу из rb_heap_t.free_pages и получить свободный слот, блокировки быстро становятся узким местом и “съедают” параллелизм.
Newobj‑кэш ractor
Ruby использует двухуровневую схему:
- Глобальный уровень:
rb_heap_t.free_pagesи страницы (struct heap_page) общие; чтобы забрать страницу, нужна блокировка. - Локальный уровень: у каждого Ractor есть newobj‑кэш (
rb_ractor_newobj_cache_t) — по одному локальномуfreelistна каждыйheap_idx(size class).
Этот локальный freelist — тот же список struct free_slot, просто временно вынесенный из heap_page->freelist выбранной страницы.
Обычно Ractor выдаёт слоты из локального freelist (O(1) без блокировок). Когда он пуст, Ractor под блокировкой берёт страницу из free_pages, забирает её heap_page->freelist и снова продолжает аллокацию локально.
Перед sweep GC сбрасывает эти newobj‑кэши и возвращает остатки freelist обратно на страницы — иначе sweep “не увидит” часть свободных слотов.
GC и Ractors
GC ставит VM‑барьер: все Ractor’ы доходят до безопасной точки и останавливаются. При incremental marking это серия коротких остановок вместо одной длинной.
На пользовательском уровне это означает: Ractor даёт изоляцию и параллелизм Ruby-кода, но GC в текущем CRuby по-прежнему координируется глобально между ractor’ами.
Что может измениться дальше
Этот раздел — не часть текущей модели, а направление экспериментов в CRuby.
В обсуждениях развития CRuby есть идея ractor-local GC: локальные, non-shareable объекты каждого ractor’а собирать независимо, а глобальную координацию оставлять только для shareable heap. Такой дизайн обещает уменьшить число ситуаций, где GC должен останавливать все ractor’ы сразу.
Пока это нужно воспринимать именно как roadmap/эксперимент. Текущую реализацию CRuby по-прежнему стоит понимать так: память ractor’ов изолирована на уровне модели объектов, но GC остаётся глобально координируемым механизмом VM.
Сравнение GC: CRuby vs JRuby vs TruffleRuby
Если приложению критичны минимальные паузы GC или настоящий параллелизм на CPU, выбор реализации Ruby начинает иметь значение.
JRuby и TruffleRuby работают на JVM, где доступен широкий выбор сборщиков и режимов: параллельные потоки GC, конкурентные фазы (часть работы идёт параллельно с программой), эвакуационные/компактирующие стратегии. В обмен — другая модель производительности: часть ускорения приходит после прогрева, когда рантайм успевает скомпилировать горячий код в машинный код, и другое “поведение по памяти”.
CRuby тоже движется в этом направлении (modular GC и эксперименты с MMTk), но его GC пока остаётся более консервативным. Для большинства приложений это не критично, но для latency‑sensitive систем JRuby/TruffleRuby могут дать преимущество.
Часть III: GC и пользовательский код
До сих пор мы смотрели на GC изнутри — аллокация, mark, sweep, compaction. Но GC взаимодействует и с пользовательским кодом: иногда нужно выполнить действие при сборке объекта, иногда — позволить GC собрать объект, на который ещё есть ссылка, а иногда — подстроить параметры GC под профиль нагрузки.
Финализаторы
GC решает, когда освободить объект. Но если объект владеет внешним ресурсом (файл, сокет, временная директория), освобождение памяти недостаточно — нужно выполнить действие при освобождении. Для этого существуют финализаторы.
class TempFile
def initialize
@path = "/tmp/file_#{object_id}"
File.write(@path, "")
# Если забудут вызвать close — удалим файл когда объект соберётся
ObjectSpace.define_finalizer(self, TempFile.invoke_cleanup(@path))
end
def close
File.delete(@path) if File.exist?(@path)
ObjectSpace.undefine_finalizer(self)
end
# Метод класса — не замыкает self
def self.invoke_cleanup(path)
proc { File.delete(path) if File.exist?(path) }
end
endЗачем: Страховка на случай если программист забыл вызвать close. Ресурс освободится когда объект соберётся GC.
В stdlib: Tempfile, IO, FFI-обёртки над C-библиотеками.
Ограничения: GC недетерминирован — неизвестно когда вызовется. При exit! финализаторы не вызываются. Лучше явный ensure.
WeakRef
Финализатор реагирует на сборку объекта. WeakRef решает другую задачу — позволяет сборку: это ссылка, которая не предотвращает сборку объекта. Концептуально аналогична Weak<T> в Rust (при использовании с Rc<T>).
require 'weakref'
obj = Object.new
weak = WeakRef.new(obj)
weak.some_method # работает, пока obj жив
obj = nil
GC.start
weak.weakref_alive? # false
weak.some_method # WeakRef::RefErrorПрименение: Кэши с автоматическим вытеснением — кэш не удерживает объекты в памяти, если они больше никому не нужны.
GC Tuning
Когда GC становится проблемой — latency spikes в ответах, высокое потребление памяти, частые major GC — первый шаг: диагностика. GC.stat возвращает текущую статистику (количество сборок, размер кучи, общее число аллоцированных объектов), а GC.latest_gc_info показывает детали последнего GC (:major_by, :gc_by — те самые триггеры из предыдущих секций).
Настройка выполняется через переменные окружения. Если приложение при старте аллоцирует много объектов (Rails boot), увеличение RUBY_GC_HEAP_INIT_SLOTS (начальное количество слотов) и RUBY_GC_HEAP_GROWTH_FACTOR (множитель роста) сокращает количество GC-циклов на старте. Если GC запускается слишком часто из-за C-расширений, выделяющих много malloc-памяти, RUBY_GC_MALLOC_LIMIT и RUBY_GC_OLDMALLOC_LIMIT позволяют поднять пороги, после которых GC считает malloc-бюджет исчерпанным.
Modular GC (Ruby 3.4+)
GC в MRI — результат 15 лет эволюции: от простого mark-sweep до generational incremental compacting collector. Но все эти оптимизации вшиты в VM. Ruby 3.4 сделал первый шаг к разделению: Modular GC — экспериментальная возможность подключать альтернативные реализации сборщика мусора.
Первый внешний GC — библиотека на основе MMTk (Memory Management Toolkit), фреймворка для реализации и экспериментов с различными GC-алгоритмами:
# Требует сборки Ruby с --with-modular-gc
make modular-gc MODULAR_GC=mmtk
RUBY_GC_LIBRARY=mmtk ruby script.rbSources
- CRuby source:
gc.c,gc/directory — GC implementation: https://github.com/ruby/ruby/blob/master/gc.c - Ruby docs:
GCmodule: https://docs.ruby-lang.org/en/master/GC.html - Ruby docs:
ObjectSpace: https://docs.ruby-lang.org/en/master/ObjectSpace.html - Ruby 3.4.0 Release Notes — Modular GC: https://www.ruby-lang.org/en/news/2024/12/25/ruby-3-4-0-released/
- Koichi Sasada, RubyKaigi 2025, Toward Ractor local GC: https://rubykaigi.org/2025/presentations/ko1.html
- Peter Zhu, 2022, Variable Width Allocation: https://blog.peterzhu.ca/variable-width-allocation/
- Peter Zhu, Matt Valentine-House, 2022, Garbage Compaction for Ruby: https://shopify.engineering/ruby-garbage-collection-compaction
← Формы объектов | Array →