До Go 1.3 среда выполнения использовала сегментированные стеки, которые разделялись на связанные фрагменты на границах вызовов функций. Эта конструкция вызывала серьезные «горячие разрывы» производительности, когда граница стека часто пересекалась во время интенсивных циклов. Go 1.3 заменила это на непрерывные стеки, которые копируются в более крупные, единые непрерывные области во время роста. Однако ранние реализации непрерывных стеков никогда не освободили память обратно в кучу, что привело к постоянному росту RSS для горутин, которые временно требовали глубоких стеков вызовов во время инициализации или пакетной обработки. Go 1.5 представила автоматическое уменьшение стека для восстановления неиспользуемой памяти стека во время циклов сборки мусора, завершив жизнь управления памятью для стеков горутин.
Без механизма уменьшения горутина, которая временно входит в глубокую рекурсию (например, обработка глубоко вложенного JSON-документа или обход сложного дерева зависимостей), будет сохранять свое пиковое распределение стека навсегда, даже после возвращения в неактивный событийный цикл. Это приводит к избыточному потреблению памяти в длительных приложениях, особенно использующих пулы рабочих потоков, где горутины чередуются между задачами с высоким использованием стека и неактивными состояниями. Проблема заключается в том, чтобы безопасно определить, когда стек действительно недоинвестирован, и переместить активные фреймы в меньшую область памяти без повреждения текущих вычислений, указателей, выделенных в стеке, или нарушения требований ABI к соглашениям о вызовах.
Среда выполнения Go уменьшает стеки во время фазы маркировки GC, когда сканируются корневые наборы. Она проверяет использование стека каждой горутины; если уровень использования высоких водных знаков падает ниже четверти (25%) от текущего выделенного размера стека, среда выполнения выделяет новый стек размером в половину от текущего (но никогда меньше минимальных 2 КБ). Затем среда выполнения асинхронно останавливает целевую горутину в безопасной точке, копирует живые фреймы стека в новую меньшую область, использует сгенерированные компилятором карты указателей для обновления всех внутренних указателей, ссылающихся на адреса стека, и освобождает старую память стека обратно в аллокатор mheap среды выполнения.
Мы управляли службой обработки журналов с высокой пропускной способностью, где каждая горутина обрабатывала разбор потенциально глубоко вложенных JSON-данных (до 10 000 уровней глубины во время атак с неверными входными данными). После обработки эти горутины возвращались в sync.Pool для ожидания новых подключений. Мы наблюдали, что RSS памяти службы росла линейно с числом собранных горутин, никогда не освобождая память даже в периодах бездействия, что в конечном итоге вызывало OOM-убийства на контейнерах с ограничениями в 4 ГБ, несмотря на то, что фактический рабочий набор составлял всего 200 МБ.
Мы рассматривали возможность принудительного завершения собранных горутин после установленного числа обработанных запросов и создания свежих замен. Это гарантировало бы освобождение памяти стека, поскольку новые горутины начинали с минимальных 2 КБ стеков. Однако этот подход вводил значительные затраты на процессор из-за постоянного создания и уничтожения горутин, нарушал оптимизацию пула TCP-соединений и вызывал более высокую задержку по хвостовым задержкам из-за холодных запусков кэша.
Реализация жесткого предела на рост стека с помощью debug.SetMaxStack предотвратила бы чрезмерное выделение памяти во время глубоких рекурсий. Хотя это защищало от OOM, это также вызывало панику у законных, но глубоких задач разбора с runtime: goroutine stack exceeds 1000000000-byte limit. Это привело бы к потере данных клиентов и ошибкам сервиса, нарушающим наши SLA на надежность, что делало это неприемлемым для производства.
Мы оценили периодическое вызов runtime.GC(), за которым следовал debug.FreeOSMemory() каждые 30 секунд, чтобы форсировать сканирование и уменьшение стека. Это успешно уменьшило RSS, но вводило паузы типа stop-the-world продолжительностью 5-10 мс при каждом вызове, что нарушало наши требования по задержке p99 <2 мс для уровня API и увеличивало использование процессора на 15% из-за вынужденных полных сборок.
В конечном итоге мы доверились родному механизму уменьшения стека в Go, убедившись, что мы использовали Go 1.20+ и настроили GOGC, чтобы чаще вызывать сборки мусора (установив его на 50 вместо 100). Это увеличивало частоту возможностей уменьшения стека без ручного вмешательства. Мы также изменили структуру парсера, чтобы использовать итеративный подход с явным стеком в куче для отслеживания пути, снизив максимальную глубину рекурсии с 10 000 до 100. Это сочетание позволяло естественному уменьшению происходить достаточно часто, чтобы удерживать память в рамках.
RSS службы стабилизировался примерно на уровне 800 МБ под нагрузкой, снизившись с предыдущего потолка в 3,8 ГБ. Профили стека горутин показывали, что 95% собранных работников поддерживали минимальный размер стека в 2 КБ между запросами, с пиками, возникающими только во время активного разбора. Убийства OOM полностью прекратились, а p99 задержка оставалась менее 1,5 мс, так как мы избегали ручных пауз GC и работы горутин.
Происходит ли уменьшение стека сразу, когда функция возвращается и указатель стека уменьшается?
Нет, среда выполнения не отслеживает уменьшение указателя стека в реальном времени для вызова немедленной деалокации. Уменьшение осуществляется исключительно во время фазы маркировки сборки мусора, когда планировщик сканирует все стеки горутин. Среда выполнения проверяет уровень использования стека с момента последней сборки мусора. Если этот уровень ниже 25% от текущего физического распределения, только тогда выполняется логика уменьшения. Эта ленивое выполнение амортизирует стоимость копирования стеков по всем горутинам во время периода, когда мир уже приостановлен для маркировки, хотя фактическая копия требует остановки отдельной горутины.
Какое точное соотношение уменьшения и минимальный размер, и освобождает ли среда выполнения когда-либо память обратно в ОС?
Когда стек подходит для уменьшения, среда выполнения выделяет новый стек размером в половину от текущего. Это геометрическое уменьшение предотвращает тряску, когда горутина колебалась немного выше и ниже порога, постоянно растет и сжимается. Новый размер ограничен платформой минимального размера стека, обычно 2 КБ на 64-битных системах. Память от старого стека возвращается в mheap среды выполнения, а не напрямую в операционную систему. Операционная система восстанавливает эту физическую память только если сборщик определяет, что куча была бездействующей и превышает цель, или если вызывается debug.FreeOSMemory().
Останавливается ли горутина во время уменьшения стека и как обновляются указатели?
Да, уменьшение требует остановки целевой горутины в безопасной точке, аналогично росту стека. Среда выполнения должна копировать живые фреймы в новое место памяти и обновлять все указатели, которые ссылаются на переменные, выделенные в стеке. Компилятор генерирует карты указателей, которые определяют, какие слова в каждом фрейме являются указателями. Во время уменьшения среда выполнения использует эти карты, чтобы найти и скорректировать внутренние указатели, чтобы они указывали на новые адреса стека. Эта операция не является конкурентной; горутина не может выполнять операции во время копирования, но другие горутины продолжают работать.