Программирование GoПамять и GCGo-разработчик серверных приложений

После глубокой рекурсии память, занятая стеками горутин, долго не уменьшается. Каким механизмом Go решает, ...

После глубокой рекурсии память, занятая стеками горутин, долго не уменьшается. Каким механизмом Go решает, когда уменьшать выросший стек?

Проходите собеседования с ИИ помощником Hintsage

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

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

Исторический контекст

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

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

Постановка проблемы

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

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

Подробное решение

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

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

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

Уменьшение стека не обязательно происходит на каждом цикле GC. Рантайм применяет эвристики, чтобы не уменьшить стек сразу перед новым расширением. Если горутина снова быстро войдёт в глубокий вызов, чередование shrink и grow создаст лишние копирования и ухудшит производительность.

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

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

Ситуация из практики

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

Рассматривались три варианта:

  • принудительно запускать GC после обработки сообщения — может уменьшить задержки памяти, но добавляет CPU-затраты и паузы;
  • создавать отдельную горутину на каждое тяжёлое сообщение — изолирует большой стек от долгожившего worker, но увеличивает стоимость планирования и требует контроля параллелизма;
  • заменить рекурсивный разбор на итеративный или ограничить глубину вложенности — уменьшает пиковый стек, но может усложнить код и обработку ошибок.

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

Что кандидаты часто упускают

  1. Уменьшается ли стек сразу после возврата из глубокой функции?

Нет. Возврат освобождает кадр логически, но не обязан уменьшать ёмкость стека. Немедленное сжатие было бы слишком дорогим и могло бы вызвать постоянное чередование расширения и уменьшения. Проверка выполняется позднее, обычно в рамках работы сборщика мусора и при наличии достаточного недоиспользования.

  1. Гарантирует ли вызов GC уменьшение стеков и RSS?

Нет. Сборка мусора может создать возможность для shrink, но конкретный стек должен удовлетворять условиям рантайма. Даже после уменьшения стека освобождённая память может остаться внутри аллокатора Go или быть не сразу возвращена операционной системе, поэтому RSS способен почти не измениться.

  1. Почему большие стеки горутин влияют на работу GC, даже если локальные объекты уже не используются?

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