Как Go сохраняет корректность указателей на локальные данные при переносе растущего стека горутины?

Как Go сохраняет корректность указателей на локальные данные при переносе растущего стека горутины?

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

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

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

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

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

Такой подход устраняет необходимость заранее резервировать много памяти под каждую горутину, но требует безопасного перемещения стека. Если бы рантайм не мог корректно обновлять ссылки, копирование стека делало бы указатели на локальные данные недействительными.

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

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

Ошибки особенно опасны при использовании unsafe, преобразовании указателей в uintptr или ручном хранении адресов без информации о типе. Такие значения могут не распознаваться как указатели, а рантайм не обязан исправлять их при перемещении стека.

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

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

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

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

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

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

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

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

Рассматривались два варианта. Можно было вручную закреплять выполнение вокруг участка с unsafe, но это усложняло код и не исправляло проблему времени жизни данных. Другой вариант — передавать буфер как обычный срез или типизированный указатель; он сохранял информацию о ссылке и позволял компилятору и рантайму корректно обработать стек.

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

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

  1. Обновляет ли Go все указатели на перемещённый стек, находящиеся в куче?

Нет, корректная модель Go не предполагает произвольных указателей из кучи в стек. Если значение, размещённое в куче, должно пережить текущую функцию и ссылаться на локальные данные, escape analysis обычно вынуждает сами данные переместиться в кучу. Ручное создание такой ссылки через unsafe нарушает нормальные гарантии языка и может привести к неопределённому или непредсказуемому поведению.

  1. Почему указатель на локальную переменную не становится автоматически указателем на объект в куче при росте стека?

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

  1. Может ли перенос стека происходить в произвольный момент посреди машинной инструкции?

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