JavaRush /Курсы /Go SELF /Escape analysis

Escape analysis

Go SELF
19 уровень , 3 лекция
Открыта

1. Упрощённая модель памяти: стек и heap

Если вы когда‑нибудь слышали фразу “у тебя всё аллоцируется в heap, поэтому тормозит”, то вы уже знаете, что вокруг памяти в Go есть целая мифология. Новички обычно боятся двух вещей: “нельзя возвращать адрес локальной переменной” и “heap — это плохо”. На самом деле в Go обе идеи чаще всего ложные или, как минимум, слишком драматичные.

В Go нет ручного освобождения памяти. А значит, если язык разрешает вам написать return &x, он обязан сделать это безопасным. Здесь и появляется escape analysis — анализ компилятора, который решает: это значение может жить “локально” в рамках вызова функции или ему нужно жить дольше?

Чтобы комфортно жить в Go, достаточно очень простой модели. Мы сознательно не лезем в низкоуровневые детали, но возьмём “скелет” понятий.

Стек: “коробка для вещей на время разговора”

Когда вызывается функция, ей нужны локальные переменные: x, sum, какие‑то временные значения. Представьте, что каждый вызов функции — это разговор по телефону, а стек — это стол, на который вы кладёте бумажки “на время разговора”. Разговор закончился — бумажки можно выбросить.

В программировании это выглядит так: стековые данные живут пока выполняется функция (точнее, пока живёт её “кадр”/frame).

Heap: “склад для вещей, которые должны пережить разговор”

Heap (куча) — это область памяти для данных, которые могут жить дольше, чем текущий вызов функции. Например, если функция создала значение и вернула на него указатель, очевидно: это значение должно существовать и после завершения функции.

В Go за тем, чтобы мусор на этом “складе” не накапливался, следит сборщик мусора (GC). Он обходит граф объектов в heap, начиная от “корней” (globals, значения на стеке и т.п.), и удаляет то, что стало недостижимым. Такая идея — “heap как граф объектов и GC, который идёт от корней” — это базовая картина мира GC в Go.

Мини-таблица: чем стек отличается от heap

Свойство Стек (stack) Heap (куча)
Время жизни Обычно ограничено вызовом функции Может жить дольше функции
“Кто убирает” Автоматически при выходе из функции GC убирает недостижимое
Зачем нужен Локальные временные значения Всё, что должно “пережить” текущий кадр

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

3. Escape analysis на человеческом языке

Escape analysis (анализ “убегания”) — это идея компилятора: понять, “убегает” ли значение за границы текущего вызова функции.

Если значение не убегает, его можно разместить так, чтобы оно жило ровно столько, сколько надо (часто — на стеке).

Если значение убегает (например, мы возвращаем указатель на него или сохраняем этот указатель где‑то снаружи), компилятор размещает данные так, чтобы они жили дольше. В практическом объяснении обычно говорят: “значение уедет в heap”.

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

Интересный исторический факт: в старых версиях Go даже встречались баги в реализации escape analysis, и это могло приводить к очень неприятным последствиям. В release-заметках упоминается исправление “escape analysis bug”, который мог приводить к порче памяти — то есть тема не “косметическая”, а реально важная для корректности.

4. Почему return &x безопасен

Сейчас будет главный анти‑миф дня.

Во многих языках (особенно в C/C++) возвращать адрес локальной переменной нельзя: функция закончилась, стековый кадр исчез, указатель стал “висячим”.

В Go вы можете написать так — и это правильно:

package main

import "fmt"

func NewInt(v int) *int {
	x := v
	return &x
}

func main() {
	p := NewInt(10)
	fmt.Println(*p) // 10
}

Почему это работает? Потому что компилятор видит: “мы возвращаем адрес x наружу”. Значит, x должен жить дольше, чем вызов NewInt. Компилятор организует размещение так, чтобы указатель был валидным после return. Это и есть интуитивная суть escape analysis.

Здесь важно не перепутать смысл: мы не просим Go “выдели мне heap”. Мы просим: “дай мне указатель, который будет жить”. А куда именно положить данные — решит компилятор.

5. new не означает “heap”, а & не означает “стек”

В голове новичка часто появляется правило‑страшилка:

  • “Если new — значит heap”
  • “Если локальная переменная — значит стек”
  • “Если &x — значит стек, но мы вернули, значит всё сломается”

Это всё не работает как надёжные правила.

В Go важно не “каким способом вы получили адрес”, а куда этот адрес попал и как долго он будет нужен.

Пример: new(int) даёт указатель, но не заставляет думать про размещение

package main

import "fmt"

func main() {
	p := new(int)     // *int
	*p = 7
	fmt.Println(*p)   // 7
}

Смысл new(int) — получить *int на zero value. А вот где окажется это значение (и оптимизируется ли оно) — не ваша забота на уровне начинающего. В нашей ментальной модели мы можем говорить “скорее всего heap”, но правильнее думать так: “компилятор сделает так, чтобы было корректно”.

Пример: &v внутри функции не обязано “убегать”

package main

import "fmt"

func addOne(v int) int {
	p := &v
	*p = *p + 1
	return v
}

func main() {
	fmt.Println(addOne(10)) // 11
}

Здесь мы взяли адрес параметра v, но не вернули p наружу и не сохранили где‑то глобально. Для компилятора это хороший кандидат на “не убегает”.

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

6. Практическая привязка

Сейчас мы сделаем очень прикладной кусок, который часто встречается в реальном коде: значение может быть, а может не быть. Например, пользователь добавляет товар в список покупок, и количество необязательно: можно написать milk 2, а можно просто milk -.

Мы не используем структуры (они будут позже), поэтому сделаем простую модель на двух параллельных слайсах:

  • names []string — названия
  • qtys []*int — количество (указатель на int), где nil означает “не задано”

Да, параллельные слайсы выглядят немного “олдскульно”, но это хороший тренажёр для указателей и nil.

Парсим “опциональное число” и возвращаем *int

Обратите внимание: функция создаёт локальную переменную v и возвращает её адрес. Именно тут включается escape analysis: значение должно пережить функцию.

package main

import (
	"fmt"
	"strconv"
)

func parseOptionalInt(s string) (*int, error) {
	if s == "-" {
		return nil, nil
	}

	v, err := strconv.Atoi(s)
	if err != nil {
		return nil, fmt.Errorf("bad number %q: %v", s, err)
	}

	return &v, nil
}

Если вы ловите внутренний крик “так нельзя же, это локальная переменная!” — поздравляю, вы только что нашли в себе C‑программиста, даже если не учили C. В Go это нормально: v “убегает” наружу, компилятор это видит и обеспечивает корректное время жизни.

Форматируем количество для печати

package main

import "fmt"

func formatQty(q *int) string {
	if q == nil {
		return "(без количества)"
	}
	return fmt.Sprintf("%d", *q)
}

Тут важная дисциплина: если тип *int, то nil — нормальное значение, и код должен уметь с ним жить, иначе вы получите panic.

Добавление записи в наши слайсы

package main

func addItem(names []string, qtys []*int, name string, qty *int) ([]string, []*int) {
	names = append(names, name)
	qtys = append(qtys, qty)
	return names, qtys
}

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

Мини-вывод списка

package main

import "fmt"

func printItems(names []string, qtys []*int) {
	for i := range names {
		fmt.Printf("%d) %s %s\n", i+1, names[i], formatQty(qtys[i]))
		// пример: "1) milk 2"
		// или:    "2) bread (без количества)"
	}
}

В этом месте полезно вспомнить, что слайсы и указатели легко образуют “связи” (aliasing). Мы сейчас сознательно делаем qtys как []*int, потому что хотим именно опциональность. Но чем больше указателей, тем больше дисциплины нужно.

7. Почему heap — это нормально

У новичков heap часто ассоциируется с “медленно” и “страшно”, как папка node_modules: вроде полезная, но лучше туда без фонарика не заходить.

В Go heap — обычная часть жизни программы. Да, за heap следит GC. Но GC как раз устроен так, чтобы быть практичным: он начинает обход от корней (в том числе от стека) и смотрит, какие объекты ещё достижимы, а какие уже можно освободить.

Из этого следует важная инженерная мысль: делать код корректным и читаемым важнее, чем панически избегать heap. Если вам нужен указатель, чтобы выразить смысл (например, “значение может отсутствовать”), то это хорошая причина.

Иногда heap‑аллокации действительно важны для производительности, но это тема, которая становится актуальной, когда у вас уже есть профилирование, метрики и понимание узких мест. На текущем уровне обучения “оптимизация через гадание по указателям” обычно заканчивается тем, что код становится запутанным, а быстрее не становится.

Как распознавать “убегание” глазами: типовые ситуации

Сейчас я опишу ситуации связным текстом (без чек-листов), потому что полезнее научиться видеть смысл, а не запоминать заклинания.

Если функция возвращает *T, то почти всегда внутри будет либо new(T), либо взятие адреса &x. В обоих случаях результат живёт после выхода из функции, значит значение должно “пережить” вызов.

Если вы кладёте &x в слайс указателей ([]*int) и этот слайс возвращается наружу или хранится где-то вне функции, то это тоже классический случай “убегания”: вы явно сохраняете адрес на будущее.

Если указатель остаётся внутри функции и нигде не сохраняется, то компилятор часто может оставить данные “локальными”. Но (и это ключевой момент) мы не строим код, опираясь на догадки о размещении. Мы строим код, опираясь на корректность и смысл API.

8. Типичные ошибки

Ошибка №1: “return &x нельзя, потому что x локальная”.
Это устойчивый миф из языков с ручным управлением памятью. В Go возвращать адрес локальной переменной можно: компилятор учитывает такие случаи и обеспечивает корректное время жизни значения через анализ “убегания”. Если вы запрещаете себе return &x, вы часто начинаете писать странные конструкции, которые лишь хуже читаются.

Ошибка №2: “Я буду избегать heap любой ценой, поэтому всё сделаю глобальным”.
Глобальные переменные действительно “живут долго”, но вместе с ними долго живут и баги: неожиданное разделение состояния, сложности тестирования, неявные зависимости. Это почти всегда хуже, чем аккуратный return &x там, где он уместен.

Ошибка №3: разыменование nil, потому что “ну там же должно быть число”.
Как только вы выбрали модель *int как “опциональное число”, вы подписали контракт: nil — это валидное состояние. Значит, любой вывод, любая арифметика и любое сравнение должны либо обрабатывать nil, либо явно запрещать его на границе (и проверять).

Ошибка №4: “new = heap, make = heap, & = стек”, и из этого строятся решения.
В Go важнее не “каким оператором вы получили значение”, а “сколько оно должно жить и куда попали ссылки на него”. new просто возвращает *T, make инициализирует внутреннюю структуру коллекций, а реальное размещение — это задача компилятора и рантайма.

Ошибка №5: чрезмерное усложнение ради микро-оптимизации.
Иногда студент, узнав про heap, начинает переписывать код так, чтобы “ничего не аллоцировалось”, но при этом теряет ясность: появляются лишние параметры, странные указатели на слайсы, много ручных инициализаций. На этом этапе курса правильнее держаться принципа: сначала ясный контракт и корректность, а производительность — только когда есть факты (измерения) и понятная цель.

1
Задача
Go SELF, 19 уровень, 3 лекция
Недоступна
Выживший указатель
Выживший указатель
1
Задача
Go SELF, 19 уровень, 3 лекция
Недоступна
Необязательное число
Необязательное число
1
Задача
Go SELF, 19 уровень, 3 лекция
Недоступна
Список покупок
Список покупок
1
Задача
Go SELF, 19 уровень, 3 лекция
Недоступна
Квадраты в памяти
Квадраты в памяти
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ