Объясните механизм: как Go увеличивает стек горутины при углублении вызовов и почему это не равно последовательной heap-аллокации каждого локального значения?
Стек горутины в Go растёт динамически по мере необходимости. При нехватке места runtime выделяет более крупный непрерывный участок стека, переносит туда его содержимое и исправляет ссылки на перемещённые значения. Это отличается от heap-аллокации локальных переменных: обычные локальные данные остаются на стеке, пока escape analysis не определит необходимость размещения в куче.
Фиксированный большой стек для каждой горутины неэффективен: при большом числе горутин значительный объём памяти простаивает. Фиксированный маленький стек, наоборот, ограничивает глубину вызовов и усложняет обработку рекурсии.
Динамический стек позволяет запускать много горутин с умеренным начальным потреблением памяти и расширять его только при фактической необходимости. Такой подход переносит часть затрат с постоянного резервирования памяти на операции роста и копирования стека.
Глубокая рекурсия, большие кадры функций или большое количество одновременно активных вызовов могут вызвать рост стека. Если рост происходит часто, приложение получает дополнительные затраты на выделение нового стека и копирование его содержимого, а задержки могут стать заметными.
Неверно считать, что каждый локальный массив или параметр при этом становится объектом кучи. Такое заключение смешивает две разные операции: изменение размера стека горутины и escape analysis. Однако чрезмерно большие кадры могут увеличить общий объём памяти, который приходится хранить и сканировать для активной горутины.
Когда текущего стека недостаточно для очередного вызова, runtime останавливает выполнение этой горутины, выделяет стек большего размера и переносит туда активные кадры. Компилятор предоставляет карты стека, поэтому runtime знает, какие слова являются указателями и какие значения нужно корректно обновить после перемещения.
После роста старый стек освобождается. Поэтому адрес локального значения на стеке нельзя считать стабильным: код Go не должен сохранять такой адрес в предположении, что стек никогда не переместится. Компилятор и runtime согласованно поддерживают корректность указателей.
Рост стека сам по себе не означает, что локальные переменные превратились в heap-объекты. Локальная переменная попадает в кучу только при выполнении условий escape analysis, например когда её значение должно пережить текущий вызов или стать доступным из места, где стековая область уже недействительна.
Стек горутины также участвует в работе GC. Сборщик сканирует активные части стеков по картам указателей, чтобы определить достижимость объектов кучи. Поэтому большое число горутин с глубокими стеками может увеличивать объём работы GC даже при небольшом количестве heap-аллокаций.
Стек может уменьшаться во время работы runtime, когда обнаруживается, что выделенный размер существенно превышает текущую потребность. Уменьшение не происходит после каждого возврата из функции и не обязано немедленно возвращать память операционной системе. Следовательно, кратковременный пик глубины вызовов может некоторое время отражаться на памяти процесса.
Основные компромиссы таковы:
В сервисе обработчик разбирал вложенную структуру входного документа рекурсивно. Для обычных документов глубина была небольшой, но специально сформированный документ вызывал много последовательных расширений стека и увеличивал задержку обработки.
Рассматривались три варианта. Увеличение числа горутин не решало проблему и могло увеличить суммарное потребление памяти. Ограничение глубины было простым, но меняло допустимый формат входных данных и требовало корректно обрабатывать отказ. Переписывание обхода на итеративный вариант с явным стеком сделало потребление памяти контролируемым, но усложнило код.
Выбрали итеративный обход с проверкой максимального размера явного стека. Это устранило зависимость от глубины call stack, позволило задать понятный лимит для входа и сохранило предсказуемое поведение при атакующих данных. Если рекурсия остаётся предпочтительной, следует отдельно измерять глубину, размеры кадров, частоту роста стеков и время GC, а не судить о проблеме только по числу heap-аллокаций.
Ответ: Для корректного Go-кода — нет. Runtime переносит содержимое стека, а компилятор и runtime обновляют указатели, относящиеся к перемещённым данным. Поэтому перемещение стека не нарушает семантику указателей в программе.
Однако нельзя полагаться на стабильность числового адреса, полученного через небезопасные операции. Код с unsafe, ручным хранением адресов или взаимодействием с внешним кодом обязан учитывать специальные правила и не должен использовать адрес стекового значения как долговечный идентификатор.
Ответ: Рост стека не создаёт отдельный heap-объект для каждого локального значения. Но сам стек содержит указатели на объекты кучи, и после роста GC должен сканировать актуальную активную область нового стека.
Поэтому влияние проявляется главным образом через объём сканируемой памяти и число указательных слотов, а не через количество обычных heap-аллокаций. Если кадры содержат много указателей, глубокая рекурсия может заметнее увеличивать стоимость сканирования.
Ответ: При итеративном обходе состояние вызовов не исчезает, а перемещается в явно управляемую структуру: срез, очередь или другой контейнер. Если эта структура хранит все уровни обхода, её объём может быть сопоставим с необходимой глубиной рекурсии.
Преимущество итеративного варианта в контроле: можно задать лимит, переиспользовать буфер, выбрать компактное представление состояния и избежать непредсказуемого роста call stack. Но если явный стек без ограничений динамически расширяется или удерживает крупные объекты, он сам может вызвать значительные heap-аллокации и давление на GC.