В прошлой главе мы увидели адресное пространство
процесса: код, данные, куча, стек. Теперь вопрос практический: когда программа
создаёт значение, где оно оказывается и кто потом освобождает память?
Есть два ответа, и это не два разных вида памяти, а два разных правила управления.
Стек: память выделяется при входе в функцию и автоматически освобождается при
выходе из неё. Строгий порядок: последним выделили, первым освободили.
Куча: память выделяется по запросу и живёт, пока её явно не освободят (в C)
или пока на неё есть ссылки (в Go со сборщиком мусора).
Для Go-разработчика эта глава объясняет три вещи, которые постоянно всплывают в
профилировании: почему одни функции аллоцируют, а другие нет, откуда берутся утечки
памяти при наличии сборщика мусора и как это всё связано с ОС.
При каждом вызове функции на вершину стека кладётся кадр (stack frame): место
под её локальные переменные, аргументы и адрес возврата. При возврате кадр
снимается.
Вызовы: main() → handle() → parse() шаг 1 шаг 2 шаг 3 ┌────────┐ ┌────────┐ ┌────────┐ │ main │ │ main │ │ main │ └────────┘ ├────────┤ ├────────┤ │ handle │ │ handle │ └────────┘ ├────────┤ │ parse │ ← вершина стека (SP) └────────┘ parse() вернулась → её кадра больше нет: ┌────────┐ │ main │ ├────────┤ │ handle │ ← вершина снова здесь └────────┘
Вершину стека отмечает регистр SP (stack pointer). «Выделить кадр» — значит
сдвинуть SP на нужное число байт, «освободить» — сдвинуть обратно. Это одна
инструкция, поэтому стек почти бесплатен. Данные кадра лежат рядом, и кэш
процессора с ними работает хорошо.
Ограничение одно: данные кадра живут, только пока функция не вернулась. В C можно
вернуть из функции указатель на локальную переменную, и он будет указывать на
снятый кадр, где скоро окажутся чужие данные. Это висячий указатель (dangling
pointer), классическая ошибка C.
Стек ограничен. У потока ОС в Linux стек главного потока по умолчанию 8 МБ
(ulimit -s). В Go у каждой горутины свой стек: он начинается с 2 КБ и растёт по
мере надобности, но не больше 1 ГБ на 64-битных системах. Рекурсия без выхода
упирается в этот лимит:
Когда данные должны пережить функцию, их кладут в кучу. В C это делается вручную:
char *buf = malloc(100); // попросить 100 байт; вернётся адрес/* ... пользуемся buf ... */free(buf); // вернуть память, иначе утечка
malloc находит в куче свободный кусок нужного размера, free возвращает его для
повторного использования. Как именно аллокатор ищет куски и борется с
фрагментацией, разберём в главе управление свободной памятью.
Вся ответственность на программисте, и отсюда набор классических ошибок:
malloc и free — функции библиотеки, а не системные вызовы. Аллокатор раздаёт
память, которую заранее получил у ОС большими кусками. У ОС для этого два
системных вызова:
brk/sbrk сдвигает верхнюю границу области [heap], которую мы видели в
/proc/self/maps. Так растёт классическая куча.
mmap просит у ядра новую область адресов в любом свободном месте адресного
пространства. glibc отдаёт через mmap крупные запросы (по умолчанию от 128 КБ),
чтобы при free вернуть их ОС целиком.
Ни тот, ни другой вызов сразу не тратят физическую память. Ядро только запоминает,
что диапазон адресов теперь разрешён, а реальные страницы выдаёт при первом
обращении к ним. Механику разберём в главе
за пределами физической памяти.
Рантайм Go работает так же: резервирует у ОС через mmap большие куски (на
64-битном Linux по 64 МБ), сам нарезает их под объекты и стеки горутин, а
неиспользуемую память со временем возвращает ядру через madvise.
Правило: если компилятор может доказать, что значение не нужно после выхода из
функции, оно живёт на стеке. Если ссылка на значение может пережить функцию, оно
«убегает» в кучу. Убегание происходит, когда вы возвращаете указатель, сохраняете
его в глобальную переменную или долгоживущую структуру, отправляете в канал,
захватываете в замыкании, которое живёт дольше функции.
Проверим, сколько аллокаций в куче делает каждый вариант:
package mainimport ( "fmt" "testing")var sink *intfunc stays() int { x := 42 return x // возвращаем копию значения}func escapes() *int { y := 42 return &y // адрес y переживёт функцию}func main() { a := testing.AllocsPerRun(1000, func() { _ = stays() }) b := testing.AllocsPerRun(1000, func() { sink = escapes() }) fmt.Println("stays:", a, "аллокаций в куче") fmt.Println("escapes:", b, "аллокаций в куче")}
stays: 0 аллокаций в кучеescapes: 1 аллокаций в куче
testing.AllocsPerRun вызывает функцию много раз и считает среднее число
аллокаций в куче на вызов. stays не аллоцирует: x живёт в кадре. escapes
аллоцирует на каждый вызов: y должна существовать после возврата, поэтому
компилятор разместил её в куче.
Решения компилятора можно увидеть флагом -m:
go build -gcflags='-m' .
Среди прочих строк будут такие:
./main.go:16:2: moved to heap: y./main.go:22:57: moved to heap: y
Вторая строка появилась потому, что компилятор встроил (inline) escapes в
анонимную функцию в main, и у встроенной копии то же решение. Про x компилятор
ничего не пишет: он остался на стеке.
До возврата из escapes(): После возврата: СТЕК КУЧА СТЕК КУЧА ┌─────────┐ ┌─────────┐ ┌──────┐ │ escapes │ ┌──────┐ │ main │ │ y=42 │ │ &y ────┼─────▶│ y=42 │ │ sink ───┼──▶│ │ └─────────┘ └──────┘ └─────────┘ └──────┘ кадр escapes снят, y жива в куче
Поэтому в Go вернуть &y из функции нормально и безопасно: висячего указателя не
будет. Цена — аллокация в куче и работа для сборщика мусора. Одна из частых
оптимизаций горячего кода в Go — убрать лишние убегания, чтобы значения
оставались на стеке.
Стек
Куча
Кто выделяет
компилятор, сдвигом SP
в C — malloc; в Go — рантайм по решению escape analysis
Сборщик мусора освобождает только то, до чего нельзя добраться по ссылкам от
«корней»: стеков горутин и глобальных переменных. Если вы держите ссылку на данные,
которые логически уже не нужны, GC обязан их хранить. Это и есть утечка в языке со
сборщиком мусора: не потерянный указатель, а лишний удерживаемый.
корни (стеки, глобальные переменные) │ ▼ [ map-кэш ]──▶[ большой объект 1 ] никому не нужен, но ссылка есть → живёт │ └──────▶[ большой объект 2 ] то же самое [ объект 3 ] ссылок нет → GC его соберёт
Типичные источники утечек в Go:
Кэш без вытеснения.map, в которую кладут и никогда не удаляют.
Маленький срез большого массива.small := big[:10] ссылается на весь
исходный массив big, и он не будет освобождён, пока жив small. Лечится копией:
small := append([]T(nil), big[:10]...).
Зависшие горутины. Горутина, которая навсегда заблокирована на канале, живёт
вечно вместе со своим стеком и всем, на что ссылается.
Последний случай — самый частый на проде. Его легко воспроизвести:
package mainimport ( "fmt" "runtime" "time")func handle(id int) { ch := make(chan int) go func() { v := <-ch // никто не пишет в ch: горутина ждёт вечно fmt.Println(v) }() // handle вернулся, а горутина и её ch остались жить}func main() { fmt.Println("горутин в начале:", runtime.NumGoroutine()) for i := 0; i < 1000; i++ { handle(i) } time.Sleep(100 * time.Millisecond) fmt.Println("горутин после 1000 запросов:", runtime.NumGoroutine())}
горутин в начале: 1горутин после 1000 запросов: 1001
Каждый «запрос» оставил после себя горутину. На сервисе под нагрузкой это тысячи
горутин в минуту и медленно растущая память. Правильная остановка горутин через
context и закрытие каналов разобраны в курсе Go:
примитивы синхронизации.
В Go для этого есть профайлер pprof. Heap-профиль (/debug/pprof/heap)
показывает, какие места кода держат больше всего памяти. Профиль горутин
(/debug/pprof/goroutine) показывает, где они стоят. Признаки:
число горутин (runtime.NumGoroutine() или метрика) растёт и не возвращается —
утечка горутин;
куча растёт при постоянной нагрузке — забытая ссылка или кэш без вытеснения.
Если утечку не найти, история заканчивается так, как описано в главе
память в проде: процесс упирается в лимит контейнера, и
ядро его убивает.
Проверь себя· API памяти: стек vs куча
Где компилятор Go разместит значение, на которое останется ссылка после возврата функции?
Что происходит с кадром (stack frame) функции, когда она возвращает управление?
Почему в Go, в отличие от C, безопасно вернуть из функции указатель на локальную переменную?
Какие ситуации реально приводят к утечке памяти в Go?
Сколько байт примерно занимает стартовый стек новой горутины в Go?
Какой команды/флага достаточно, чтобы увидеть решения escape analysis компилятора Go?
Функция возвращает структуру по значению (return T{...}). Будет ли аллокация в
куче?
Функция кладёт &x в глобальную переменную. Где окажется x?
В профиле горутин 50 000 горутин стоят на <-ch в одной строке кода. Что это,
скорее всего?
Почему recover не спасает от бесконечной рекурсии в Go?
Показать ответы
Ответы. (1) Нет, если копия не убегает дальше; вернуть значение — значит
скопировать его в кадр вызывающего. (2) В куче: ссылка переживает функцию. (3)
Утечка горутин: никто не пишет в канал и не закрывает его. (4) Переполнение стека
в Go — фатальная ошибка рантайма, а не паника.