diff --git a/editorial/agent-rewrites/366.json b/editorial/agent-rewrites/366.json index 04b1b56..9d06409 100644 --- a/editorial/agent-rewrites/366.json +++ b/editorial/agent-rewrites/366.json @@ -1 +1,7 @@ -{"index":366,"slug":"dconf-2017-под-капотом-мусорщика-ди-дмитрий-ол","title":"Сборщик мусора D под нагрузкой: от паузы к устройству пулов","excerpt":"Пауза в D-приложении не доказывает, что виноват только GC. Разбираем пулы малых и больших объектов, консервативную маркировку и порядок проверки перед изменением режима сборки.","contentHtml":"

Программа на D начинает отвечать рывками. В логах появляются редкие длинные паузы, RSS растёт, а команда сразу обвиняет сборщик мусора. Цена такой ошибки высока: можно отключить GC и получить неконтролируемый рост памяти, либо начать вручную освобождать объекты там, где проблема возникла в аллокаторе, жизненном цикле данных или профиле нагрузки.

\n

Тезис статьи простой: GC нельзя оценивать по одному симптому. Сначала разделите время приложения, время выделения памяти и время сбора. Затем посмотрите, какие объекты живут долго, а какие создаются ради одной итерации. Устройство сборщика объясняет, почему эти измерения важнее совета «вызови GC.collect».

\n

Что именно делает сборщик

\n

Сборщик D освобождает память объектов, до которых программа больше не может добраться. Он находит корни, проходит по памяти и отмечает возможные ссылки. Цена зависит от реализации runtime, размера кучи, числа объектов и объёма сканируемой памяти.

\n

Разбор DConf 2017 описывает GC как набор пулов. Пул содержит отображённую память и метаданные: биты занятости, биты маркировки, списки свободных блоков и сведения, позволяющие восстановить начало объекта по адресу внутри него. Сборщик разделяет пулы малых и больших объектов. Это ускоряет обычные выделения, но создаёт дополнительные пути поиска.

\n

Для малого объекта размер округляется до класса. В историческом описании использовались классы 16, 32, 64, 128, 256, 512, 1024 и 2048 байт. Если точный размер не совпадает с классом, часть блока остаётся неиспользованной. Это внутренняя фрагментация. При неудачном распределении размеров она увеличивает объём памяти без увеличения полезных данных.

\n
\"Пулы
Учебная схема связи классов размеров со страницами малого пула.
\n

Пулу нужно также найти объект по внутреннему указателю. В описанной архитектуре сначала проверяется диапазон адресов, затем выбирается пул, затем по таблице страниц определяется класс размера. Для большого объекта дополнительно находится начало диапазона, потому что указатель может указывать не на первый байт.

\n

Если пулов много, поиск по ним становится частью маркировки. Пусть GC проверяет N возможных значений, а выбор пула занимает log(P), где P — число пулов. Тогда один проход получает множитель N × log(P). Это не доказывает, что любая такая реализация медленная в production. Это показывает, что в профиле нужно искать не только вызов GC, но и работу внутри маркировки.

\n

Почему пауза возникает в разных местах

\n

Сборку удобно рассматривать как четыре фазы: подготовка, маркировка, очистка и возврат свободной памяти. Одинаковая по длительности пауза может иметь разные причины, потому что фазы используют разные структуры данных.

\n

На подготовке runtime готовит сведения для следующего цикла. Если свободные блоки и биты занятости нужно заново сопоставить с битами маркировки, сборщик проходит по спискам и метаданным до основной работы. Такой проход попадает в stop-the-world и заметен, когда свободные списки состоят из множества разрозненных элементов.

\n

Маркировка сканирует корни и найденные области. Консервативный сборщик не всегда может доказать, что машинное слово является указателем. Он сохраняет возможные ссылки, чтобы не удалить живой объект. Обратная сторона — ложные указатели удерживают память дольше, а сканирование требует дополнительных проверок.

\n

Для каждого кандидата маркировка может проверить диапазон кучи, найти пул, определить тип блока, восстановить начало объекта, проверить бит маркировки и добавить область в следующий проход. Смешивание сканируемой и несканируемой памяти в одном пуле добавляет проверку таблицы. Разделение пулов по этим свойствам могло бы сократить критический путь, но это изменение дизайна runtime.

\n

Очистка вызывает финализаторы, если они есть, и отмечает освобождённые блоки. Затем списки свободных блоков могут быть перестроены. Если это требует линейного прохода по пулам, время зависит от резервов и количества страниц, а не только от числа недостижимых объектов.

\n

Большие выделения образуют отдельный риск. Для объекта размером в десятки или сотни мегабайт постраничные метаданные могут оказаться дорогими относительно самого объекта. Такой объект нельзя оценивать тем же профилем, что и поток короткоживущих строк.

\n

Учебный пример измерения

\n

Ниже приведён минимальный пример для локального эксперимента. Он не сообщает production-результат и не доказывает, что участок требует ручного управления GC. Его задача — зафиксировать границу batch и сравнить наблюдения до и после изменения кода.

\n
import core.memory : GC;\nimport std.stdio : writeln;\n\nvoid processBatch(const int[] values) {\n    auto before = GC.stats();\n    foreach (value; values) {\n        auto squared = value * value;\n        writeln(squared);\n    }\n    auto after = GC.stats();\n    writeln(\"allocated: \", after.usedSize - before.usedSize);\n}\n\n// GC.collect() проверяем на границе batch,\n// а не вызываем в каждой итерации.
\n

В этом фрагменте нет искусственной аллокации на каждой итерации. Поэтому он не служит тестом производительности GC. Для полезного эксперимента возьмите реальную функцию, запишите входной набор, режим сборки и число повторов, а затем сравните время, аллокации и паузы.

\n

Отключение GC меняет условия эксперимента. Память, которую программа перестала использовать, не вернётся автоматически, пока GC выключен. Если участок создаёт больше временных данных, чем предполагалось, RSS растёт даже при хорошей скорости. Поэтому GC.disable допустим только на короткой измеримой границе с гарантированным возвратом к обычному режиму.

\n

Диагностика: симптом → причина → проверка → действие

\n
СимптомВозможная причинаПроверкаДействие
Редкая длинная паузаПолная маркировка или перестройка свободных списковСнять несколько профилей и отделить время GCНайти фазу и размер кучи; не отключать GC по одному событию
Heap растёт между цикламиДолгоживущие ссылки, кэш или ложные указателиСравнить удерживаемые объекты и граф ссылокУбрать ненужные ссылки, ограничить кэш и повторить замер
Частые сборкиМного мелких временных объектовПосчитать аллокации в горячем цикле и размеры классовПереиспользовать буферы и сократить промежуточные значения
Пауза после batchОтложенная работа GC на границе набора данныхСравнить профиль с явным сбором в конце batchОставить collect только при повторяемом улучшении
Большой RSS после крупного объектаФрагментация или дорогие метаданные пулаИзмерить размер объекта, резерв и возврат страницИзменить модель буфера или способ крупных выделений
\n

Порядок проверки

\n
  1. Зафиксируйте задержку, частоту, размер входа и допустимый порог.
  2. Соберите профиль release-бинарника на повторяемом наборе. Debug-результат не переносите на production без проверки.
  3. Разделите время обработки, аллокации и сборку. Сохраните размер занятой памяти до и после batch.
  4. Найдите горячие места, где создаются временные объекты: строки, массивы, замыкания и промежуточные структуры.
  5. Проверьте удерживаемые ссылки. Рост heap после сборки указывает на живые ссылки или ложные указатели, а не автоматически на неисправный GC.
  6. Повторите замер после изменения одного фактора. Не меняйте одновременно структуру данных, режим GC и размер batch.
  7. Проверьте GC.collect только на границе операции. Сравните распределение задержек, а не только среднее.
  8. Оставьте ручную настройку только с условием применимости, лимитом времени и проверкой возврата к обычному режиму.
\n

Ограничения модели

\n

Разбор DConf 2017 описывает устройство и направления улучшения исторического GC. Детали зависят от версии компилятора и runtime. Нельзя переносить утверждение о конкретном поиске пула или фазе паузы на современную сборку без чтения исходников и профиля.

\n

Консервативная маркировка объясняет риск ложных указателей, но не позволяет по одному росту памяти доказать их наличие. Внутреннюю фрагментацию также нельзя вычислить по одному RSS: нужны классы выделений, резерв и фактически занятые байты.

\n

Учебный код не моделирует сетевой сервис, конкурентные потоки, большие объекты и реальные SLA. Он нужен только для воспроизводимого сравнения двух локальных вариантов. Production-результат появляется после профиля на реальной нагрузке, контрольной группе и сохранённом пороге.

\n

Критерий готовности

\n

Диагностика готова, если для исходной паузы записаны фаза GC, размер кучи, число аллокаций и повторяемый вход; изменение улучшает выбранный порог на release-профиле; RSS не растёт за установленный лимит; а ручное управление имеет условие возврата. Если эти данные не собраны, вывод «виноват сборщик» остаётся гипотезой.

\n

Проверяемые источники

\n"} +{ + "index": 366, + "slug": "dconf-2017-под-капотом-мусорщика-ди-дмитрий-ол", + "title": "Сборщик мусора D под нагрузкой: от паузы к устройству пулов", + "excerpt": "Пауза в D-приложении не доказывает, что виноват только GC. Разбираем пулы малых и больших объектов, консервативную маркировку и порядок проверки перед изменением режима сборки.", + "contentHtml": "

Представим вечерний batch в D-сервисе: оператор заметил симптом: редкая длинная пауза, RSS растёт, а разработчик сначала хочет отключить сборщик мусора. Цена такого шага понятна: пока GC выключен, временные данные продолжают занимать память, и процесс может упереться в лимит. Но ручное освобождение тоже не лечит паузу, если её вызвали аллокатор, жизненный цикл ссылок или профиль нагрузки.

\n

Сначала зафиксируйте, что именно произошло: время обработки, размер входа, число аллокаций и длительность остановки. Затем отделите работу приложения от работы GC и только после этого меняйте код или режим сборки. Исторический разбор Дмитрия Ольшанского помогает понять, почему одной строки «GC медленный» недостаточно: стоимость возникает в пулах, таблицах, маркировке и восстановлении списков свободных блоков.

\n

Сценарий: пауза в batch

\n

В этом сценарии команда получает один симптом — задержка появилась после нескольких проходов по данным. Сначала она смотрит на среднее время batch и не находит проблемы. Затем разработчик сравнивает максимальную паузу GC с размером кучи и видит, что среднее скрывает редкие остановки. После этого команда проверяет, сколько объектов переживает сборку, и возвращается к исходной гипотезе: виноват не обязательно сам GC, а способ выделения и удержания памяти.

\n

Сценарий учебный: это не отчёт о production-замере. Его результатом должна быть запись «какую фазу и какой показатель мы проверили», а не обещание ускорения. Для исторических деталей ниже используется разбор состояния runtime в 2017 году; для API — актуальная документация D, поэтому границы между ними отмечены отдельно.

\n

Что описывает исторический разбор

\n

В записи от 15 июня 2017 года автор описывает GC как набор пулов. Пул объединяет отображённую память и метаданные: таблицы битов отметки и освобождения, списки свободных блоков и сведения о расположении объектов. Если один пул не обслуживает запрос, runtime создаёт новый. Это описание объясняет архитектуру, но не заменяет проверку исходников конкретной версии druntime.

\n

Исторический runtime разделял малые и большие объекты. Для малых выделений автор указывает верхнюю границу 2 КБ и классы размеров 16, 32, 64, 128, 256, 512, 1024 и 2048 байт. Запрос округляется к классу, после чего проверяется глобальный список свободных блоков. Если подходящего блока нет, пул выделяет страницу и добавляет её в список объектов этого класса.

\n
\"Схема
Учебная схема: размер страницы связывается с классом малых объектов через таблицу страниц.
\n

Класс назначается странице, а не каждому объекту отдельно. Поэтому внутренний указатель сначала сопоставляется со страницей, затем с классом размера, и только потом по маске вычисляется начало объекта. В статье 2017 года это названо источником дополнительных обращений к таблице страниц и метаданным. Вывод ограничен: речь идёт о конструкции, разобранной автором в тот момент, а не о гарантии для любого современного GC.

\n

Степени двойки упрощают вычисление маски, но дают внутреннюю фрагментацию: запрос, чуть превышающий нижний класс, получает следующий блок. Верхняя оценка до 50% относится к такому правилу округления, а не к каждому распределению. Фактический перерасход зависит от распределения размеров, поэтому его нельзя выводить из одного значения RSS.

\n

Большие объекты и путь указателя

\n

Для больших объектов исторический разбор указывает гранулярность страницы 4 КБ и отдельную таблицу, которая помогает восстановить начало объекта по адресу внутри диапазона. При выделении свободный список страниц просматривается линейно. Для объекта на 100 МБ и больше метаданные могут стать заметными относительно полезных данных, особенно если изменение размера не удаётся выполнить на месте.

\n

В маркировке возможный указатель проходит несколько проверок. Сначала runtime проверяет диапазон кучи, затем ищет подходящий пул. После этого таблица страниц сообщает, является ли адрес малым объектом, началом большого объекта, продолжением диапазона или свободной памятью. Только затем вычисляется начало блока и проверяется бит отметки.

\n

Так появляется оценка N × log(P): N — число проверяемых значений, P — число пулов, а log(P) — стоимость бинарного поиска пула в описанной архитектуре. Это модель пути, а не бенчмарк. Она подсказывает, что профилировать нужно не только внешний вызов сборки, но и количество сканируемых областей, пулов и кандидатов.

\n

Почему у сборки несколько фаз

\n

В историческом описании цикл состоит из подготовки, маркировки, очистки и возврата памяти. На подготовке runtime подготавливает сведения о свободных областях. Если для этого нужно пройти по спискам свободных блоков, работа попадает в критический участок паузы. На маркировке консервативный GC сохраняет значение, похожее на указатель, когда не может доказать обратное.

\n

Ложный указатель не доказывает утечку: он лишь может удержать объект до следующего цикла. Точно так же рост heap после сборки не доказывает дефект GC — объект может оставаться достижимым через кэш или долгожившую структуру. Поэтому проверка должна сопоставлять живые ссылки, профили аллокаций и фазу, в которой выросла задержка.

\n

На очистке вызываются финализаторы, если они предусмотрены, и отмечаются свободные блоки. На возврате памяти восстанавливаются списки свободных блоков; исторический разбор отдельно указывает на повторный проход по малым пулам. Это объясняет, почему одинаковая по длительности пауза может зависеть не только от числа недостижимых объектов, но и от числа страниц и элементов метаданных.

\n

Актуальная документация D формулирует более устойчивый контракт: GC консервативно маркирует достижимую память, а GC.collect может приостановить потоки на части цикла. Точные фазы, пороги и структуры зависят от реализации. Если нужен ответ для конкретной версии компилятора, сначала закрепите версию и прочитайте её druntime, а затем подтвердите вывод профилем.

\n

Воспроизводимый пример измерения

\n

Ниже приведён минимальный пример для локального сравнения двух вариантов. usedSize — размер занятой памяти GC-heap, который может обновляться после сборки. allocatedInCurrentThread — накопительный счётчик байтов, выделенных текущим потоком. Поэтому пример показывает два разных показателя и не выдаёт ни один из них за длительность паузы.

\n
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.

\n

Матрица диагностики

\n
СимптомГипотезаПроверкаОграниченное действие
Редкая длинная паузаМаркировка или проход по метаданнымСопоставить фазу GC, размер кучи и максимум паузыСначала найти горячую фазу, не отключать GC по одному событию
Heap растёт после сборкиЖивые ссылки, кэш или ложные указателиСравнить удерживаемые объекты и граф ссылокОграничить кэш, убрать ненужную ссылку и повторить замер
Частые сборкиМного короткоживущих объектовПосчитать аллокации в цикле и размеры классовПереиспользовать буфер и убрать промежуточные значения
Рост RSS после крупного блокаФрагментация или стоимость метаданныхСравнить размер блока, резерв и возврат страницПроверить модель больших буферов, не переносить вывод на малые объекты
Пауза после batchСборка совпала с границей аллокацииСравнить распределение задержек с явным сбором на границеОставить GC.collect только при повторяемом улучшении
\n

Порядок проверки

\n
  1. Зафиксируйте симптом: задержку, частоту, размер входа, RSS и допустимый порог.
  2. Запустите release-бинарник на повторяемом наборе. Debug-результат не переносите на production без отдельной проверки.
  3. Отделите время обработки, аллокаций и сборки. Сохраните показатели до и после batch.
  4. Найдите горячие места, где создаются строки, массивы, замыкания и промежуточные структуры.
  5. Проверьте удерживаемые ссылки. Рост heap после сборки означает наличие живых данных или ложных указателей, но не доказывает неисправность GC.
  6. После этого измените только один фактор: структуру данных, размер batch или режим сборки.
  7. Сравните медиану, хвост задержек и RSS. Среднее значение не заменяет p95 или p99, если проблема редкая.
  8. Только после повторяемого улучшения проверяйте GC.collect на границе операции. Для GC.disable задайте короткий участок, условие возврата и лимит накопления временных данных.
\n

Ограничения и критерий готовности

\n

Исторический разбор полезен как карта решений GC 2017 года, но не как обещание поведения современного runtime. Порог 2 КБ, классы размеров, гранулярность страницы и четыре фазы нужно перепроверять для версии, на которой работает ваш сервис. Документация core.memory подтверждает публичные счётчики и управление, но не превращает учебный пример в production-бенчмарк.

\n

Диагностика готова, если записаны версия компилятора, фаза GC, размер кучи, число аллокаций, повторяемый вход и выбранный порог; изменение улучшает хвост задержек на release-профиле; RSS не выходит за лимит; а ручное управление имеет понятное условие возврата. Если этих данных нет, вывод «виноват сборщик» остаётся гипотезой.

\n

Проверяемые источники

\n\n" +}