Стековые аллокации срезов в Go 1.25 и 1.26: как компилятор учился не мусорить
Предисловие
Это пересказ статьи разработчика компилятора Go Кита Ренделла. Не нужно воспринимать это как перевод. Я постарался упростить и обойтись без некоторых блоков текста из статьи, которые, по моему мнению, могут только запутать читателя.
Наивный подход
Рассмотрим ситуацию, где нам нужно заполнить срез через цикл.
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}Теперь давайте разберемся, что происходит при извлечении задач из канала.
При первой итерации для среза задач отсутствует резервное хранилище в куче, поэтому append должен выделить резервное хранилище. Поскольку append не знает итоговой емкости среза, он выделит место для одной ячейки.
При второй итерации для среза уже имеется резервное хранилище с ёмкостью один. Поскольку резервное хранилище заполнено, исполнителю нужно выделить новое место для двух задач и произвести аллокацию данных. Старое резервное хранилище становится мусором.
При третьей итерации резервное хранилище с ёмкостью два также является заполненным. append создает новое хранилище с удвоенной ёмкостью и теперь у нас массив с тремя элементами и ёмкостью четыре. Предыдущее резервное хранилище с ёмкостью два становится мусором.
При четвертой итерации хранилище обладает ёмкостью четыре, поэтому элемент вставляется без аллокации.
При пятой итерации у нас резервное хранилище заполнено. Снова удваивается ёмкость и производится аллокация. Опять остается мусор.
Срез только начал заполняться, а мы уже сделали столько действий и оставили после себя мусор! Конечно же, не стоит волноваться при дальнейших итерациях аллокация будет встречаться реже. Но не кажется ли вам это довольно затратным для такого маленького объёма данных. Для сравнения, для первых четырех итераций мы делаем три аллокации и для последующих 20 тоже три аллокации.
Оптимизации
И тут к нам приходит гениальная идея - заранее определить объём.
func process(c chan task) {
tasks := make([]task, 0, 4)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}Это действительно спасает нас от распределения памяти на начале заполнения.
Также угадайте: сколько аллокаций производит этот код? Правильный ответ - ноль. Так как компилятор заранее знает размер среза, он решит разместить резервное хранилище в стеке. Но это при условии, что внутри processAll срез не отправляется в кучу.
Также мы можем улучшить этот код, если мы будем передавать ёмкость извне.
func process(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}И тут мы уже поговорим о новой оптимизации. До версии 1.25 такое объявление через неизвестное значение длины привело бы компилятора к решению отправить срез в кучу. В версии 1.25 компилятор автоматически выделяет небольшое (в настоящее время 32-байтовое) резервное хранилище и использует это резервное хранилище для результата make, если запрашиваемый размер достаточно мал.
Версия 1.26 идет дальше и теперь мы можем не писать make для среза, так как при первой итерации append использует резервное хранилище в стеке.
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}Но у нас есть ситуация, при которой срез может убегать из функции.
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}И что же, в этом случае срез сразу отправляется в кучу и append, как и раньше, плодит мусор? Тут версия 1.26 тоже нас не подводит.
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks = runtime.move2heap(tasks)
return tasks
}На уровне компиляции вставляется функция runtime.move2heap. Это специальная функция, которую компилятор Go автоматически вставляет в конец функции, если из неё возвращается срез. Ее главная цель гарантировать, что срез всегда указывает на данные в куче, но при этом дать срезу воспользоваться резервным хранилищем стека, пока он не переполнится.