Deep Engineering
Продвинутый·Опубликовано·25 МИН

Сборщик мусора: почему пауза не растёт вместе с кучей, а GOGC ничего не ускоряет

Сборщик Go конкурентный и неперемещающий, и из этого выводится всё остальное: пауза короткая и от размера кучи не зависит, а GOGC — не ускоритель, а ручка обмена времени на память. Замерено: куча выросла в шестнадцать раз, медианная пауза — в 1,22, и даже эта разница меньше разброса между двумя повторами одного замера; GOGC=400 даёт вчетверо меньше циклов и в два с половиной раза больший пик памяти.

Полное техническое изложение

TL;DR

Сборщик возвращает программе память объектов, до которых она больше не может добраться. «Не может добраться» — это не «больше не нужен», а недостижим по ссылкам от корней: сборщик считает не полезность, а достижимость. Отсюда сразу две вещи: забытая ссылка на ненужный объект — это и есть утечка в Go, а работа сборщика пропорциональна числу живых объектов, а не мусора.

Главное следствие ломает интуицию: пауза не растёт вместе с кучей. Замерено: живых данных 16 → 256 МБ, медианная пауза 39 → 48 мкс, то есть рост в 1,22 раза при шестнадцатикратном росте кучи — и даже эта разница меньше, чем расхождение двух повторов одного и того же замера. Программу останавливают дважды за цикл и ненадолго, всё остальное идёт одновременно с ней. А вот работа разметки с кучей растёт — и её постоянно путают с паузой, после чего оптимизируют величину, которая не менялась.

Дальше — устройство и ручки, и то и другое привязано к версии Go. Нынешний сборщик — конкурентный, трёхцветный, помечающий и подметающий, неперемещающий и непоколенный; это свойства реализации, а не обещания языка. GOGC ничего не ускоряет: это ручка обмена — GOGC=400 вместо 100 дал вчетверо меньше циклов и в два с половиной раза больший пик кучи на одной и той же работе. Цель цикла считается от живых данных (GOGC=100 — вдвое, GOGC=400 — впятеро), поэтому потолка в GOGC нет; потолок — это GOMEMLIMIT, и он мягкий: живые данные, переросшие предел, дают не падение, а непрерывную сборку. И память сборщик не отдаёт ОС сразу — RSS больше HeapAlloc не потому, что где-то утечка.

Порог входа
Перед уроком достаточно понимать
  • программа во время работы просит память под новые значения, и эта память откуда-то берётся;
  • одни значения нужны программе долго, другие — на несколько строк, и заранее это не расписано;
  • значения ссылаются друг на друга: в одном лежит ссылка на другое.
Заранее знать не нужно
  • трёхцветная разметка, барьер записи, остановка мира, фазы цикла сборки;
  • GOGC, GOMEMLIMIT, HeapAlloc, RSS, GODEBUG=gctrace=1.

Что здесь на самом деле спрашивают

Вопросов почти всегда три, и они идут по нарастающей:

  1. «Какой в Go сборщик?» — проверяют, назовёте ли вы слово «конкурентный», а не только mark and sweep.
  2. «Насколько велики паузы и от чего зависят?» — здесь ломается большинство: паузу путают со стоимостью сборки.
  3. «Как его настраивать?» — и правильный ответ начинается со слова «обычно никак», а продолжается тем, чем именно платят за каждую ручку.

Спина у темы одна: сборщик Go оптимизирован под задержку, а не под пропускную способность. Он готов потратить больше процессорного времени, лишь бы не останавливать программу надолго. Всё остальное — следствия.

База: что вообще делает сборщик

Прежде чем считать паузы и крутить ручки, стоит назвать задачу. Она короткая и не содержит ни одного термина Go:

Найти объекты, до которых программа больше не может добраться, и вернуть их память.

Слово «не может добраться» здесь ключевое — и оно означает не «не будет использовать», а недостижим по ссылкам:

Сборщик не знает, «нужен» объект или нет. Он знает только, есть ли до него путь от корней. Отсюда сразу два следствия, которые дальше объясняют всё остальное: объект, на который осталась одна забытая ссылка, живёт вечно (это и есть утечка в Go), и работа сборщика пропорциональна числу ЖИВЫХ объектов, а не мусора — мусор он не трогает вовсе.

контракт языкаПостановка задачи: это верно для любого сборщика, считающего достижимость, и от версии Go не зависит. Устройство цикла и ручки GOGC и GOMEMLIMIT идут ниже и привязаны к версии.

Обратите внимание, чего в этой постановке нет. Нет ни слова о том, когда сборщик приходит, сколько он работает и останавливает ли программу. Это всё вопросы устройства, и ответы на них разные у разных сборщиков и у разных версий одного.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: сборщик ищет недостижимое и возвращает его память, а не следит за тем, что программе «ещё пригодится». Дальше — про то, как он умудряется делать это, почти не останавливая программу, и чем за это платят.

Механизм 1: один цикл сборки целиком

Теперь про то самое устройство — как сборщик обходит достижимое, не отбирая у программы процессор надолго. Цикл состоит из четырёх фаз, и мир останавливается только в двух из них, на короткое время:

▐ подготовка разметки          остановка мира, микросекунды
█████████ конкурентная разметка   программа РАБОТАЕТ, обход идёт параллельно
▐ завершение разметки           остановка мира, микросекунды
█████████ подметание              программа работает, память возвращается

Вот из этой картинки и следует всё, что урок дальше проверяет замером: паузы — это две узкие полоски, а работа — две широкие, и растут они по-разному.

Чтобы разметка могла идти одновременно с программой, нужен барьер записи: пока идёт разметка, каждая запись указателя проходит через него, и барьер не даёт программе спрятать от сборщика ещё не обойдённый объект. Это и есть налог конкурентности — постоянная надбавка к процессорному времени в обмен на короткую паузу.

Механизм 2: что не растёт вместе с кучей

Главное утверждение темы стоит проверить первым:

наблюдение замераbench/gogc/internals.go, go1.24.7 linux/amd64, два ядра — то есть два исполняющих потока. И версия рантайма, и число потоков, которым разрешено выполнять код, здесь часть условий замера: на другой версии и на другом числе ядер абсолютные микросекунды будут другими.

Прогон bench/gogc/internals.go печатает:

живых данныхпауза, медиана90-й процентиль
16 МБ39 мкс66
64 МБ41 мкс73
256 МБ48 мкс107
16 МБ — повтор54 мкс

Куча выросла в шестнадцать раз — пауза не выросла: ×1,22 против ×16. Это и есть то самое свойство, ради которого сборщик Go устроен именно так: он работает одновременно с программой, а останавливает её дважды за цикл и ненадолго — на подготовку разметки и на её завершение.

Отдельно стоит сказать, что именно замерено. PauseNs из MemStats — это время остановки мира, а не длительность цикла. Длительность цикла с кучей как раз растёт, но программа в это время работает.

И оговорка про честность числа: замер шёл на двух ядрах и на одной конкретной версии рантайма, поэтому абсолютные микросекунды не значат ничего. Значение имеет одно — что столбец не растёт. И само это свойство — свойство реализации, а не языка: сборщик менялся от версии к версии и будет меняться дальше, поэтому и его поведение, и число потоков, которым разрешено выполнять код, называют вместе с версией Go, а не как всеобщий закон.

Последняя строка таблицы — не опечатка, а цена деления прибора. Это тот же замер на тех же 16 МБ, сделанный ещё раз: 54 мкс против 39. Расхождение двух повторов ОДНОГО размера оказалось больше, чем разница между 16 и 256 МБ, — а значит, обсуждать по этой таблице направление изменения нельзя вовсе. Она отвечает ровно на один вопрос: масштабируется ли пауза вместе с кучей. Ответ — нет: куча ×16, пауза ×1,22.

Строка эта появилась не сразу, и стоит сказать, зачем. Замер печатал медиану из восемнадцати циклов, и трёх прогонов подряд на этой машине хватило, чтобы на 16 МБ получить 55, 71 и 37 мкс: разброс больше, чем расстояние между строками таблицы. Пока повтора рядом не было, из таких чисел легко читалось направление — и читалось неверно. Теперь выборка поднята до двухсот циклов, повтор печатается рядом, а прогон сам печатает, различима ли разница строк его собственным прибором.

До медианы в этом замере печатался максимум, и он давал на 16 МБ 755 мкс против 34 на 256 — то есть таблицу, из которой следовало бы, что пауза с ростом кучи падает. Причина оказалась не в сборщике: максимум из двенадцати измерений на двухъядерной машине — это худший чужой процесс. Медиана отвечает на заданный вопрос, максимум — на другой.

А что тогда растёт? Работа разметки: чем больше живых объектов, тем больше указателей надо обойти. Это видно в доле процессорного времени, не в паузе. И путать их дорого — программу начинают оптимизировать под величину, которая не менялась.

Механизм 3: GOGC — не ускоритель, а ручка обмена

деталь реализации · Go 1.24Числа ниже — поведение нынешнего рантайма. Сама формула цели описана в руководстве, но конкретные значения зависят от версии и от машины.

Определение в руководстве дано формулой, и это важно: в нём нет слова «память», есть отношение.

The GOGC parameter determines the target heap size after each GC cycle: Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100.

перевод

Параметр GOGC определяет целевой размер кучи после каждого цикла сборки: целевая память кучи = живая куча + (живая куча + корни GC) × GOGC / 100.

A Guide to the Go Garbage Collector

Прогон подтверждает арифметику и показывает цену:

GOGCцель / живыецикловпик кучи
50×1,512424,3 МБ
100×2,011231,8 МБ
200×3,02647,5 МБ
400×5,04378,9 МБ

Читать таблицу надо по двум столбцам сразу. Работа во всех строках одна и та же — двести тысяч выделений по килобайту при шестнадцати мегабайтах живых данных. Циклов при GOGC=400 в восемь раз меньше, чем при 50, — и пик кучи втрое выше.

Это не ускорение, а перенос цены со времени на память. Отсюда и правило выбора: GOGC поднимают, когда памяти в избытке, а профиль показывает время в сборке; опускают — когда память в обрез. «Хорошего значения по умолчанию» тут нет; есть только то, чего в системе больше.

И то, чего у GOGC нет: потолка. Цель считается от живых данных, а значит, растут живые — растёт и цель. Программа с растущим кешем съест всю память, ни разу не нарушив настройку.

Механизм 4: GOMEMLIMIT — потолок, и почему он мягкий

Замер показывает разницу прямо:

цель следующего цикла
GOGC=100, живых 16 МБ32,3 МБ
GOGC=100, живых 64 МБ128,3 МБ
предел 96 МБ, живых 16 МБ32,3 МБ
предел 96 МБ, живых 64 МБ88,4 МБ

Вторая строка — то самое отсутствие потолка. Четвёртая — что добавляет GOMEMLIMIT: цель перестаёт подниматься выше предела, и сборка начинает идти чаще, удерживая программу под ним.

Слово «мягкий» в документации стоит не для мягкости:

the memory limit is a soft limit… the Go runtime makes no guarantees that it will maintain this memory limit under all circumstances; it only promises some reasonable amount of effort.

перевод

предел памяти — мягкий… рантайм Go не даёт гарантий, что удержит этот предел при любых обстоятельствах; он обещает лишь разумные усилия.

A Guide to the Go Garbage Collector

Если живые данные сами перерастут предел, рантайм не убьёт программу — он будет собирать почти непрерывно. Руководство называет это смертельной спиралью. Симптом характерный и обманчивый: процесс жив, метрики памяти в норме, полезной работы нет.

Отсюда две практические рекомендации, которые и хотят услышать. Ставить предел ниже настоящей границы контейнера — иначе вместо честного OOM получится живой по метрикам процесс без работы. И не ставить GOMEMLIMIT вместо GOGC: они не заменяют друг друга. GOGC управляет обычным режимом, GOMEMLIMIT страхует край.

Глубже: почему сборщик такой, какой есть

деталь реализации · Go 1.24Свойства нынешнего сборщика, а не гарантии языка: спецификация Go не обещает ни неперемещаемости объектов, ни отсутствия поколений. Всё перечисленное здесь уже менялось за историю рантайма и может измениться снова.

Три решения, о которых спрашивают на позициях повыше.

Непоколенный. Гипотеза поколений («большинство объектов умирает молодыми») в Go выполняется — но её выгоду уже собрал escape-анализ: короткоживущие объекты в основном не доходят до кучи вообще, они остаются на стеке. Молодое поколение оказалось бы наполовину пустым, а барьеры для него стоили бы на каждой записи указателя.

Неперемещающий. Объекты не двигаются, поэтому адреса стабильны — а это делает дешёвым взаимодействие с C и позволяет существовать unsafe.Pointer. Цена — фрагментация, которую вместо уплотнения решает аллокатор с классами размеров.

Конкурентный, с барьером записи. Разметка идёт одновременно с программой, и чтобы программа не спрятала от неё объект, каждая запись указателя во время разметки проходит через барьер. Это и есть постоянный налог сборщика на процессорное время — тот самый обмен «больше ЦП, меньше пауза».

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

Четыре величины, которые смешивают в одну и потом спорят о показаниях:

величиначто этогде смотреть
HeapAllocзанято живыми объектами сейчасMemStats
NextGCцель следующего циклаMemStats
HeapReleasedстраницы, возвращённые ОСMemStats
RSSчто процесс держит с точки зрения ОСtop, cgroup

RSS больше HeapAlloc всегда, и это нормально: в него входят взятые, но ещё не возвращённые страницы, стеки горутин, метаданные аллокатора и код. Утечка выглядит иначе — растёт HeapAlloc после полной сборки.

И связь с предыдущим уроком. Меньше выделений в куче — меньше живых объектов — меньше работы разметке. Но правилом оптимизации это не является: escape-анализ уже отсеял большую часть короткоживущего, а переписывание кода ради стека почти всегда делает его длиннее и хрупче. Сначала профиль, потом решение — тот же порядок, что и в уроке про escape-анализ.

Как отвечать на собеседовании

Короткий ответ: сборщик возвращает память объектов, до которых от корней больше нет пути, и делает это одновременно с программой — останавливая её дважды за цикл и ненадолго. Поэтому пауза короткая и от размера кучи не зависит: у меня 16 и 256 мегабайт живых данных дали 39 и 48 микросекунд — рост в 1,22 раза на шестнадцатикратной куче. Растёт при этом не пауза, а работа разметки — доля процессорного времени, и путают с паузой именно её.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Назовите свойства, а не аббревиатуру — и с привязкой к версии. «В нынешних версиях Go это конкурентный трёхцветный mark-and-sweep, неперемещающий и непоколенный. Оптимизирован под задержку: готов тратить больше ЦП, лишь бы не останавливать программу. Язык при этом ничего из перечисленного не обещает — это свойства реализации».

Про GOGC скажите «обмен». «Он задаёт, во сколько раз куче позволено вырасти сверх живых данных. GOGC=400 дал мне вчетверо меньше циклов и в два с половиной раза больший пик кучи. Сборка от этого не дешевеет».

Назовите, чего у GOGC нет. «Потолка. Цель считается от живых данных, поэтому растущий кеш съест память, не нарушив настройки. Потолок — это GOMEMLIMIT, и он мягкий».

И закончите тем, чего не надо делать. «Крутить ручки без профиля не стоит: сборщик на обычной нагрузке стоит единицы процентов ЦП. Дешевле убрать выделения, чем настраивать их уборку».

И одна оговорка, на которой легко переусердствовать: числа отсюда — не константы языка. Микросекунды пауз сняты на конкретной версии рантайма и на двух исполняющих потоках, и на другой машине они будут другими. Точная формулировка звучит так: запоминают не значения, а то, какая величина от чего зависит — пауза от размера кучи не зависит, работа разметки зависит, а цена GOGC платится памятью.

Дальше спросят

Спросят дальше

Что такое трёхцветная разметка и зачем нужен барьер записи?

Короткий ответ

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

Проблема в том, что программа работает одновременно с разметкой и может переставить указатель так, что белый объект окажется достижим только из чёрного, — а чёрные уже не пересматривают. Барьер записи ловит такие записи и подкрашивает объект в серый. Это и есть та работа, которой платят за конкурентность.

Спросят дальше

Зачем нужен runtime.GC(), если сборщик работает сам?

Короткий ответ

Почти никогда не нужен, и в этом ответ. Ручной вызов останавливает мир и делает полную сборку целиком, то есть отбирает ровно то преимущество, ради которого сборщик сделан конкурентным.

Законных случаев два: замеры, где нужна воспроизводимая точка отсчёта, и момент, когда программа заведомо переходит из фазы с большим потреблением в фазу простоя — например, после разовой загрузки данных. В обычном коде вызов runtime.GC() — это почти всегда попытка лечить симптом.

Спросят дальше

Как понять, что программа тратит время именно на сборку?

Короткий ответ

Начинают не с профиля, а с GODEBUG=gctrace=1: рантайм печатает строку на каждый цикл — сколько прошло, какая была куча, сколько заняли фазы. Если циклов много и они плотные, дальше идут в pprof, но уже зная, что искать.

Само число «сколько процентов ЦП съел сборщик» даёт MemStats.GCCPUFraction. У него есть подвох, о котором стоит сказать: это величина накопительная, с момента старта программы, поэтому сравнивать по ней два режима внутри одного процесса нельзя.

Спросят дальше

Почему RSS процесса больше, чем показывает HeapAlloc?

Короткий ответ

Потому что это разные величины. HeapAlloc — это занятые сейчас объекты; RSS включает ещё и страницы, которые рантайм у ОС уже взял, но пока не вернул, стеки горутин, метаданные аллокатора и код.

Освобождённые страницы возвращаются ОС постепенно, фоновым процессом, и это осознанное решение: вернуть страницу дёшево, а получить обратно — дорого. Поэтому «память не освобождается» почти всегда означает не утечку, а именно это. Настоящая утечка выглядит иначе: HeapAlloc растёт после полной сборки.

Спросят дальше

Финализаторы — можно ли на них полагаться?

Короткий ответ

Нет, и это надо уметь сказать твёрдо. runtime.SetFinalizer не даёт никаких гарантий: финализатор может не выполниться вовсе (программа завершилась раньше), выполняется в неизвестный момент и в отдельной горутине, а любая ссылка из финализатора обратно на объект воскрешает его на ещё один цикл.

Ресурсы освобождают defer и Close(), а не сборщик. Законное применение финализатора одно — страховка от забытого Close, которая пишет предупреждение в лог; так его использует, например, стандартная библиотека для файлов.

Спросят дальше

Что изменится, если поставить GOGC=off?

Короткий ответ

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

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

Частые заблуждения

Утверждение

чем больше куча, тем длиннее паузы

На самом деле

Замерено обратное: живых данных 16 и 256 МБ дали медианную паузу 39 и 48 мкс — то есть рост в 1,22 раза на куче, выросшей в шестнадцать. Повтор того же замера на 16 МБ дал 54 мкс, больше обеих строк, так что и этот рост прибором не различается. Растёт не пауза, а работа разметки — доля процессорного времени. Их путают, и тогда программу оптимизируют под величину, которая не менялась.

Утверждение

GOGC побольше — программа быстрее

На самом деле

Это обмен, а не ускорение. На одной и той же работе GOGC=400 против 100 дал вчетверо меньше циклов и в два с половиной раза больший пик кучи. Сборка не дешевеет — цена переносится со времени на память.

Утверждение

GOGC ограничивает, сколько памяти займёт программа

На самом деле

Никакого потолка в GOGC нет: цель считается от живых данных по формуле руководства, поэтому растут живые — растёт и цель. Программа с растущим кешем съест всю память, не нарушив настройки. Потолок задаётся отдельно, через GOMEMLIMIT.

Утверждение

GOMEMLIMIT гарантирует, что программа не выйдет за предел

На самом деле

Предел мягкий: документация обещает «разумные усилия», а не гарантию. Если живые данные сами перерастут предел, рантайм не упадёт, а будет собирать почти непрерывно — руководство называет это смертельной спиралью. Процесс жив, метрики в норме, работы нет.

Утверждение

сборщик останавливает программу на время сборки

На самом деле

Он останавливает её дважды за цикл и ненадолго — на подготовку разметки и на её завершение, — а всё остальное делает одновременно с ней. Поэтому длительность цикла и пауза — разные величины: первая с кучей растёт, вторая нет.

Утверждение

в Go поколенческий сборщик, как в JVM

На самом деле

Непоколенческий, и это осознанный выбор. Выгоду от гипотезы поколений в Go уже собрал escape-анализ: короткоживущие объекты в основном не доходят до кучи. Молодое поколение оказалось бы полупустым, а барьеры для него стоили бы на каждой записи указателя.

Утверждение

память не освобождается — значит, утечка

На самом деле

Сборщик не возвращает страницы ОС сразу: они возвращаются постепенно, фоном, потому что вернуть страницу дёшево, а получить обратно дорого. Отсюда RSS больше HeapAlloc. Утечка выглядит иначе: HeapAlloc растёт после полной сборки.

Утверждение

runtime.GC() помогает, когда памяти не хватает

На самом деле

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

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Пять чисел про арифметику GOGC: что вернёт SetGCPercent, во сколько раз цель следующего цикла больше живых данных при GOGC=100 и при GOGC=400, сколько циклов случится с выключенным сборщиком и сколько добавят два ручных вызова runtime.GC().
fmt.Println(debug.SetGCPercent(100))

fmt.Println(round(goalOverLive(100)))
fmt.Println(round(goalOverLive(400)))

fmt.Println(cyclesFor(-1))

debug.SetGCPercent(-1)
var a runtime.MemStats
runtime.ReadMemStats(&a)
runtime.GC()
runtime.GC()
var b runtime.MemStats
runtime.ReadMemStats(&b)
fmt.Println(b.NumGC - a.NumGC)

Практика · оцените

Одна и та же работа: 200 000 выделений по килобайту при 16 МБ живых данных. Во сколько раз меньше циклов сборки случится при GOGC=400, чем при GOGC=100?
раз

Проверка знаний

Вопрос 1 из 6

Живые данные программы выросли с 16 до 256 МБ. Что произойдёт с паузами сборки?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. A Guide to the Go Garbage CollectorОфициальная документация. Официальное руководство рантайма. Определение GOGC там дано формулой, а не словами: «The GOGC parameter determines the target heap size after each GC cycle: Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100» (Параметр GOGC определяет целевой размер кучи после каждого цикла сборки: целевая память кучи = живая куча + (живая куча + корни GC) × GOGC / 100). Ключевое здесь — что цель считается ОТ ЖИВЫХ ДАННЫХ, а значит, потолка в GOGC нет.https://go.dev/doc/gc-guide
  2. A Guide to the Go Garbage Collector — предел памятиОфициальная документация. Про GOMEMLIMIT и про то, почему он мягкий: «the memory limit is a soft limit… the Go runtime makes no guarantees that it will maintain this memory limit under all circumstances; it only promises some reasonable amount of effort» (предел памяти — мягкий… рантайм Go не даёт гарантий, что удержит этот предел при любых обстоятельствах; он обещает лишь разумные усилия). И прямое предупреждение про смертельную спираль: «а death spiral… the Go runtime will constantly run the GC, and program execution will slow down» (пер.: «смертельная спираль… рантайм Go будет постоянно запускать сборку, и выполнение программы замедлится»).https://go.dev/doc/gc-guide#Memory_limit
  3. Пакет runtime/debug — SetGCPercent и SetMemoryLimitОфициальная документация. Сигнатуры, из которых следует практика. «SetGCPercent sets the garbage collection target percentage: a collection is triggered when the ratio of freshly allocated data to live data remaining after the previous collection reaches this percentage. SetGCPercent returns the previous setting» (SetGCPercent задаёт целевой процент сборки мусора: сборка запускается, когда отношение свежевыделенных данных к живым, оставшимся после предыдущей сборки, достигает этого процента. SetGCPercent возвращает предыдущее значение). Возврат предыдущего значения — это и есть идиома «поменять на время и вернуть как было».https://pkg.go.dev/runtime/debug
  4. Пакет runtime — MemStatsОфициальная документация. Откуда взяты числа урока и почему именно эти поля. Про паузы: «PauseNs is a circular buffer of recent GC stop-the-world pause times in nanoseconds» (PauseNs — кольцевой буфер недавних пауз остановки мира при сборке, в наносекундах) — то есть это время ОСТАНОВКИ, а не длительность цикла. Про цель: «NextGC is the target heap size of the next GC cycle» (NextGC — целевой размер кучи следующего цикла сборки).https://pkg.go.dev/runtime#MemStats