2 lines
16 KiB
JSON
2 lines
16 KiB
JSON
{"index":366,"slug":"dconf-2017-под-капотом-мусорщика-ди-дмитрий-ол","title":"Сборщик мусора D под нагрузкой: от паузы к устройству пулов","excerpt":"Пауза в D-приложении не доказывает, что виноват только GC. Разбираем пулы малых и больших объектов, консервативную маркировку и порядок проверки перед изменением режима сборки.","contentHtml":"<p>Программа на D начинает отвечать рывками. В логах появляются редкие длинные паузы, RSS растёт, а команда сразу обвиняет сборщик мусора. Цена такой ошибки высока: можно отключить GC и получить неконтролируемый рост памяти, либо начать вручную освобождать объекты там, где проблема возникла в аллокаторе, жизненном цикле данных или профиле нагрузки.</p>\n<p>Тезис статьи простой: GC нельзя оценивать по одному симптому. Сначала разделите время приложения, время выделения памяти и время сбора. Затем посмотрите, какие объекты живут долго, а какие создаются ради одной итерации. Устройство сборщика объясняет, почему эти измерения важнее совета «вызови <code>GC.collect</code>».</p>\n<h2>Что именно делает сборщик</h2>\n<p>Сборщик D освобождает память объектов, до которых программа больше не может добраться. Он находит корни, проходит по памяти и отмечает возможные ссылки. Цена зависит от реализации runtime, размера кучи, числа объектов и объёма сканируемой памяти.</p>\n<p>Разбор DConf 2017 описывает GC как набор пулов. Пул содержит отображённую память и метаданные: биты занятости, биты маркировки, списки свободных блоков и сведения, позволяющие восстановить начало объекта по адресу внутри него. Сборщик разделяет пулы малых и больших объектов. Это ускоряет обычные выделения, но создаёт дополнительные пути поиска.</p>\n<p>Для малого объекта размер округляется до класса. В историческом описании использовались классы 16, 32, 64, 128, 256, 512, 1024 и 2048 байт. Если точный размер не совпадает с классом, часть блока остаётся неиспользованной. Это внутренняя фрагментация. При неудачном распределении размеров она увеличивает объём памяти без увеличения полезных данных.</p>\n<figure><img src=\"/assets/illustrations/gc-small-pools.svg\" alt=\"Пулы малых объектов в сборщике D\" /><figcaption>Учебная схема связи классов размеров со страницами малого пула.</figcaption></figure>\n<p>Пулу нужно также найти объект по внутреннему указателю. В описанной архитектуре сначала проверяется диапазон адресов, затем выбирается пул, затем по таблице страниц определяется класс размера. Для большого объекта дополнительно находится начало диапазона, потому что указатель может указывать не на первый байт.</p>\n<p>Если пулов много, поиск по ним становится частью маркировки. Пусть GC проверяет N возможных значений, а выбор пула занимает log(P), где P — число пулов. Тогда один проход получает множитель N × log(P). Это не доказывает, что любая такая реализация медленная в production. Это показывает, что в профиле нужно искать не только вызов GC, но и работу внутри маркировки.</p>\n<h2>Почему пауза возникает в разных местах</h2>\n<p>Сборку удобно рассматривать как четыре фазы: подготовка, маркировка, очистка и возврат свободной памяти. Одинаковая по длительности пауза может иметь разные причины, потому что фазы используют разные структуры данных.</p>\n<p>На подготовке runtime готовит сведения для следующего цикла. Если свободные блоки и биты занятости нужно заново сопоставить с битами маркировки, сборщик проходит по спискам и метаданным до основной работы. Такой проход попадает в stop-the-world и заметен, когда свободные списки состоят из множества разрозненных элементов.</p>\n<p>Маркировка сканирует корни и найденные области. Консервативный сборщик не всегда может доказать, что машинное слово является указателем. Он сохраняет возможные ссылки, чтобы не удалить живой объект. Обратная сторона — ложные указатели удерживают память дольше, а сканирование требует дополнительных проверок.</p>\n<p>Для каждого кандидата маркировка может проверить диапазон кучи, найти пул, определить тип блока, восстановить начало объекта, проверить бит маркировки и добавить область в следующий проход. Смешивание сканируемой и несканируемой памяти в одном пуле добавляет проверку таблицы. Разделение пулов по этим свойствам могло бы сократить критический путь, но это изменение дизайна runtime.</p>\n<p>Очистка вызывает финализаторы, если они есть, и отмечает освобождённые блоки. Затем списки свободных блоков могут быть перестроены. Если это требует линейного прохода по пулам, время зависит от резервов и количества страниц, а не только от числа недостижимых объектов.</p>\n<p>Большие выделения образуют отдельный риск. Для объекта размером в десятки или сотни мегабайт постраничные метаданные могут оказаться дорогими относительно самого объекта. Такой объект нельзя оценивать тем же профилем, что и поток короткоживущих строк.</p>\n<h2>Учебный пример измерения</h2>\n<p>Ниже приведён минимальный пример для локального эксперимента. Он не сообщает production-результат и не доказывает, что участок требует ручного управления GC. Его задача — зафиксировать границу batch и сравнить наблюдения до и после изменения кода.</p>\n<pre><code>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// а не вызываем в каждой итерации.</code></pre>\n<p>В этом фрагменте нет искусственной аллокации на каждой итерации. Поэтому он не служит тестом производительности GC. Для полезного эксперимента возьмите реальную функцию, запишите входной набор, режим сборки и число повторов, а затем сравните время, аллокации и паузы.</p>\n<p>Отключение GC меняет условия эксперимента. Память, которую программа перестала использовать, не вернётся автоматически, пока GC выключен. Если участок создаёт больше временных данных, чем предполагалось, RSS растёт даже при хорошей скорости. Поэтому <code>GC.disable</code> допустим только на короткой измеримой границе с гарантированным возвратом к обычному режиму.</p>\n<h2>Диагностика: симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Возможная причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Редкая длинная пауза</td><td>Полная маркировка или перестройка свободных списков</td><td>Снять несколько профилей и отделить время GC</td><td>Найти фазу и размер кучи; не отключать GC по одному событию</td></tr><tr><td>Heap растёт между циклами</td><td>Долгоживущие ссылки, кэш или ложные указатели</td><td>Сравнить удерживаемые объекты и граф ссылок</td><td>Убрать ненужные ссылки, ограничить кэш и повторить замер</td></tr><tr><td>Частые сборки</td><td>Много мелких временных объектов</td><td>Посчитать аллокации в горячем цикле и размеры классов</td><td>Переиспользовать буферы и сократить промежуточные значения</td></tr><tr><td>Пауза после batch</td><td>Отложенная работа GC на границе набора данных</td><td>Сравнить профиль с явным сбором в конце batch</td><td>Оставить collect только при повторяемом улучшении</td></tr><tr><td>Большой RSS после крупного объекта</td><td>Фрагментация или дорогие метаданные пула</td><td>Измерить размер объекта, резерв и возврат страниц</td><td>Изменить модель буфера или способ крупных выделений</td></tr></tbody></table>\n<h2>Порядок проверки</h2>\n<ol><li>Зафиксируйте задержку, частоту, размер входа и допустимый порог.</li><li>Соберите профиль release-бинарника на повторяемом наборе. Debug-результат не переносите на production без проверки.</li><li>Разделите время обработки, аллокации и сборку. Сохраните размер занятой памяти до и после batch.</li><li>Найдите горячие места, где создаются временные объекты: строки, массивы, замыкания и промежуточные структуры.</li><li>Проверьте удерживаемые ссылки. Рост heap после сборки указывает на живые ссылки или ложные указатели, а не автоматически на неисправный GC.</li><li>Повторите замер после изменения одного фактора. Не меняйте одновременно структуру данных, режим GC и размер batch.</li><li>Проверьте <code>GC.collect</code> только на границе операции. Сравните распределение задержек, а не только среднее.</li><li>Оставьте ручную настройку только с условием применимости, лимитом времени и проверкой возврата к обычному режиму.</li></ol>\n<h2>Ограничения модели</h2>\n<p>Разбор DConf 2017 описывает устройство и направления улучшения исторического GC. Детали зависят от версии компилятора и runtime. Нельзя переносить утверждение о конкретном поиске пула или фазе паузы на современную сборку без чтения исходников и профиля.</p>\n<p>Консервативная маркировка объясняет риск ложных указателей, но не позволяет по одному росту памяти доказать их наличие. Внутреннюю фрагментацию также нельзя вычислить по одному RSS: нужны классы выделений, резерв и фактически занятые байты.</p>\n<p>Учебный код не моделирует сетевой сервис, конкурентные потоки, большие объекты и реальные SLA. Он нужен только для воспроизводимого сравнения двух локальных вариантов. Production-результат появляется после профиля на реальной нагрузке, контрольной группе и сохранённом пороге.</p>\n<h2>Критерий готовности</h2>\n<p>Диагностика готова, если для исходной паузы записаны фаза GC, размер кучи, число аллокаций и повторяемый вход; изменение улучшает выбранный порог на release-профиле; RSS не растёт за установленный лимит; а ручное управление имеет условие возврата. Если эти данные не собраны, вывод «виноват сборщик» остаётся гипотезой.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://dlang.org/spec/garbage.html\">D Language Specification: Garbage Collection</a> — модель автоматической памяти, корни и ограничения.</li><li><a href=\"https://dlang.org/library/core/memory.html\">D standard library: core.memory</a> — API runtime для статистики и управления GC.</li><li><a href=\"https://dconf.org/2017/\">DConf 2017</a> — официальный контекст конференции.</li></ul>"}
|