{ "index": 366, "slug": "dconf-2017-под-капотом-мусорщика-ди-дмитрий-ол", "title": "Сборщик мусора D под нагрузкой: от паузы к устройству пулов", "excerpt": "Пауза в D-приложении не доказывает, что виноват только GC. Разбираем пулы малых и больших объектов, консервативную маркировку и порядок проверки перед изменением режима сборки.", "contentHtml": "
Представим вечерний batch в D-сервисе: оператор заметил симптом: редкая длинная пауза, RSS растёт, а разработчик сначала хочет отключить сборщик мусора. Цена такого шага понятна: пока GC выключен, временные данные продолжают занимать память, и процесс может упереться в лимит. Но ручное освобождение тоже не лечит паузу, если её вызвали аллокатор, жизненный цикл ссылок или профиль нагрузки.
\nСначала зафиксируйте, что именно произошло: время обработки, размер входа, число аллокаций и длительность остановки. Затем отделите работу приложения от работы GC и только после этого меняйте код или режим сборки. Исторический разбор Дмитрия Ольшанского помогает понять, почему одной строки «GC медленный» недостаточно: стоимость возникает в пулах, таблицах, маркировке и восстановлении списков свободных блоков.
\nВ этом сценарии команда получает один симптом — задержка появилась после нескольких проходов по данным. Сначала она смотрит на среднее время batch и не находит проблемы. Затем разработчик сравнивает максимальную паузу GC с размером кучи и видит, что среднее скрывает редкие остановки. После этого команда проверяет, сколько объектов переживает сборку, и возвращается к исходной гипотезе: виноват не обязательно сам GC, а способ выделения и удержания памяти.
\nСценарий учебный: это не отчёт о production-замере. Его результатом должна быть запись «какую фазу и какой показатель мы проверили», а не обещание ускорения. Для исторических деталей ниже используется разбор состояния runtime в 2017 году; для API — актуальная документация D, поэтому границы между ними отмечены отдельно.
\nВ записи от 15 июня 2017 года автор описывает GC как набор пулов. Пул объединяет отображённую память и метаданные: таблицы битов отметки и освобождения, списки свободных блоков и сведения о расположении объектов. Если один пул не обслуживает запрос, runtime создаёт новый. Это описание объясняет архитектуру, но не заменяет проверку исходников конкретной версии druntime.
\nИсторический runtime разделял малые и большие объекты. Для малых выделений автор указывает верхнюю границу 2 КБ и классы размеров 16, 32, 64, 128, 256, 512, 1024 и 2048 байт. Запрос округляется к классу, после чего проверяется глобальный список свободных блоков. Если подходящего блока нет, пул выделяет страницу и добавляет её в список объектов этого класса.
\nКласс назначается странице, а не каждому объекту отдельно. Поэтому внутренний указатель сначала сопоставляется со страницей, затем с классом размера, и только потом по маске вычисляется начало объекта. В статье 2017 года это названо источником дополнительных обращений к таблице страниц и метаданным. Вывод ограничен: речь идёт о конструкции, разобранной автором в тот момент, а не о гарантии для любого современного GC.
\nСтепени двойки упрощают вычисление маски, но дают внутреннюю фрагментацию: запрос, чуть превышающий нижний класс, получает следующий блок. Верхняя оценка до 50% относится к такому правилу округления, а не к каждому распределению. Фактический перерасход зависит от распределения размеров, поэтому его нельзя выводить из одного значения RSS.
\nДля больших объектов исторический разбор указывает гранулярность страницы 4 КБ и отдельную таблицу, которая помогает восстановить начало объекта по адресу внутри диапазона. При выделении свободный список страниц просматривается линейно. Для объекта на 100 МБ и больше метаданные могут стать заметными относительно полезных данных, особенно если изменение размера не удаётся выполнить на месте.
\nВ маркировке возможный указатель проходит несколько проверок. Сначала runtime проверяет диапазон кучи, затем ищет подходящий пул. После этого таблица страниц сообщает, является ли адрес малым объектом, началом большого объекта, продолжением диапазона или свободной памятью. Только затем вычисляется начало блока и проверяется бит отметки.
\nТак появляется оценка N × log(P): N — число проверяемых значений, P — число пулов, а log(P) — стоимость бинарного поиска пула в описанной архитектуре. Это модель пути, а не бенчмарк. Она подсказывает, что профилировать нужно не только внешний вызов сборки, но и количество сканируемых областей, пулов и кандидатов.
\nВ историческом описании цикл состоит из подготовки, маркировки, очистки и возврата памяти. На подготовке runtime подготавливает сведения о свободных областях. Если для этого нужно пройти по спискам свободных блоков, работа попадает в критический участок паузы. На маркировке консервативный GC сохраняет значение, похожее на указатель, когда не может доказать обратное.
\nЛожный указатель не доказывает утечку: он лишь может удержать объект до следующего цикла. Точно так же рост heap после сборки не доказывает дефект GC — объект может оставаться достижимым через кэш или долгожившую структуру. Поэтому проверка должна сопоставлять живые ссылки, профили аллокаций и фазу, в которой выросла задержка.
\nНа очистке вызываются финализаторы, если они предусмотрены, и отмечаются свободные блоки. На возврате памяти восстанавливаются списки свободных блоков; исторический разбор отдельно указывает на повторный проход по малым пулам. Это объясняет, почему одинаковая по длительности пауза может зависеть не только от числа недостижимых объектов, но и от числа страниц и элементов метаданных.
\nАктуальная документация D формулирует более устойчивый контракт: GC консервативно маркирует достижимую память, а GC.collect может приостановить потоки на части цикла. Точные фазы, пороги и структуры зависят от реализации. Если нужен ответ для конкретной версии компилятора, сначала закрепите версию и прочитайте её druntime, а затем подтвердите вывод профилем.
Ниже приведён минимальный пример для локального сравнения двух вариантов. usedSize — размер занятой памяти GC-heap, который может обновляться после сборки. allocatedInCurrentThread — накопительный счётчик байтов, выделенных текущим потоком. Поэтому пример показывает два разных показателя и не выдаёт ни один из них за длительность паузы.
import core.memory : GC;\nimport std.stdio : writeln;\n\nvoid processBatch(const int[] values) {\n auto before = GC.stats();\n\n foreach (value; values) {\n auto squared = value * value;\n writeln(squared);\n }\n\n auto after = GC.stats();\n writeln(\"heap used delta: \",\n cast(long) after.usedSize - cast(long) before.usedSize);\n writeln(\"thread allocations delta: \",\n after.allocatedInCurrentThread - before.allocatedInCurrentThread);\n}\n\n// Сравнивайте варианты на одном входе и в одном режиме сборки.\nВ цикле нет искусственного выделения памяти, поэтому фрагмент не является нагрузочным тестом GC. Его задача — показать границу измерения. Для реального сравнения сохраните входной набор, версию компилятора, режим сборки, число повторов и профиль пауз. Если нужно проверить именно остановки, используйте доступные профили runtime, а не делайте вывод по разнице usedSize.
| Симптом | Гипотеза | Проверка | Ограниченное действие |
|---|---|---|---|
| Редкая длинная пауза | Маркировка или проход по метаданным | Сопоставить фазу GC, размер кучи и максимум паузы | Сначала найти горячую фазу, не отключать GC по одному событию |
| Heap растёт после сборки | Живые ссылки, кэш или ложные указатели | Сравнить удерживаемые объекты и граф ссылок | Ограничить кэш, убрать ненужную ссылку и повторить замер |
| Частые сборки | Много короткоживущих объектов | Посчитать аллокации в цикле и размеры классов | Переиспользовать буфер и убрать промежуточные значения |
| Рост RSS после крупного блока | Фрагментация или стоимость метаданных | Сравнить размер блока, резерв и возврат страниц | Проверить модель больших буферов, не переносить вывод на малые объекты |
| Пауза после batch | Сборка совпала с границей аллокации | Сравнить распределение задержек с явным сбором на границе | Оставить GC.collect только при повторяемом улучшении |
GC.collect на границе операции. Для GC.disable задайте короткий участок, условие возврата и лимит накопления временных данных.Исторический разбор полезен как карта решений GC 2017 года, но не как обещание поведения современного runtime. Порог 2 КБ, классы размеров, гранулярность страницы и четыре фазы нужно перепроверять для версии, на которой работает ваш сервис. Документация core.memory подтверждает публичные счётчики и управление, но не превращает учебный пример в production-бенчмарк.
Диагностика готова, если записаны версия компилятора, фаза GC, размер кучи, число аллокаций, повторяемый вход и выбранный порог; изменение улучшает хвост задержек на release-профиле; RSS не выходит за лимит; а ручное управление имеет понятное условие возврата. Если этих данных нет, вывод «виноват сборщик» остаётся гипотезой.
\nGC.stats, профилирования и управления сборкой.