← назад к разделу

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

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

Горутина — это не поток операционной системы

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

Сто тысяч горутин — обычное дело. Разница в двух вещах.

Первое: у горутины маленький стек, и он растёт по мере надобности. Начинается с двух килобайт. Когда функция уходит вглубь и места не хватает, рантайм выделяет стек побольше и копирует туда старый. Поэтому глубокая рекурсия в Go не падает на ровном месте, а горутина, которая всего лишь ждёт ответа по сети, почти ничего не стоит.

Второе: горутины разложены по потокам, а не равны им. Рантайм держит небольшое число настоящих потоков — по умолчанию столько, сколько ядер у машины, — и раскладывает по ним горутины сам. Такое отношение «много к немногим» и называют планировщиком; переключение между горутинами при этом происходит внутри процесса, без обращения к ядру, и стоит десятки наносекунд вместо микросекунд.

Отсюда практическое следствие, которое ловят не сразу. Если горутина занята вычислением — крутит цикл и не делает системных вызовов, — она занимает поток целиком. Горутина, которая ждёт сеть, диск или канал, поток освобождает: рантайм снимает её и ставит другую. Поэтому тысяча горутин, ждущих HTTP-ответа, — это нормально, а тысяча горутин, перемножающих матрицы, — это очередь на ваши восемь ядер.

Стек или куча: кто это решает

В C программист решает сам: локальная переменная — на стеке, malloc — в куче. В Go такого выбора нет, и это часто удивляет:

func newCounter() *int {
	n := 0
	return &n
}

В C так писать нельзя — вернули бы адрес умершей переменной. В Go можно, и это безопасно. Компилятор видит, что адрес n убегает за пределы функции, и кладёт значение не на стек, а в кучу. Такой разбор называется escape analysis — анализ убегания.

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

Посмотреть решения компилятора можно прямо:

go build -gcflags=-m ./...

В выводе будут строки вида ./main.go:7:2: moved to heap: n — это и есть «уехало в кучу». Смотреть туда стоит не всегда, а когда профиль показал, что аллокаций подозрительно много: типичная находка — значение убегает из-за того, что его передали в interface{} или положили в замыкание, хотя по смыслу оно временное.

Сборка мусора: что именно происходит

Сборщик мусора в Go отвечает на один вопрос: до каких объектов в куче ещё можно дотянуться из работающей программы? Всё остальное — мусор.

Работает он так. Есть корни — глобальные переменные и стеки горутин. От них рантайм идёт по указателям и помечает всё, до чего дошёл. То, что осталось непомеченным, объявляется мусором, и его память возвращается в пул свободной. Этот приём называется mark-and-sweep: пометить и подмести.

Важное отличие Go от многих других сборщиков: он работает одновременно с программой, а не вместо неё. Целиком останавливать программу приходится дважды за цикл, и оба раза очень коротко — доли миллисекунды: один раз, чтобы зафиксировать начало разметки, второй — чтобы её завершить. Всё остальное время разметка идёт параллельно с вашим кодом.

Отсюда вырастает главная сложность: пока идёт разметка, программа продолжает менять указатели. Объект, уже помеченный как мусор, может внезапно снова стать нужным. Чтобы этого не случилось, компилятор вставляет барьер записи — незаметный кусочек кода при каждом присваивании указателя, который сообщает сборщику об изменении. Барьер стоит процессорного времени, и это одна из причин, почему сборка не бесплатна даже когда «пауз почти нет».

Чем такой подход плох

У mark-and-sweep есть три неприятных свойства, и про них честно стоит знать.

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

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

Паузы малы, но процессор тратится. «Стоп меньше миллисекунды» не значит «бесплатно»: разметка идёт параллельно и по умолчанию может занимать до четверти процессорного времени. На графике это видно как ровная надбавка к загрузке, а не как провал.

Когда рантайм решает собирать

По умолчанию — когда куча выросла вдвое с прошлой сборки. Это и настраивает переменная GOGC: её значение — прирост в процентах. GOGC=100 (по умолчанию) означает «собирать, когда живой кучи стало вдвое больше»; GOGC=200 — втрое, сборок будет реже, а памяти уйдёт больше; GOGC=off выключает сборку совсем.

Крутить GOGC вслепую бессмысленно, потому что он задаёт отношение, а не предел. Сервис, у которого живая куча выросла с 200 МБ до 2 ГБ, при том же GOGC начнёт собирать на четырёх гигабайтах — и будет убит контейнером за превышение лимита памяти.

Поэтому в контейнере обычно нужен не GOGC, а GOMEMLIMIT — мягкий предел памяти:

GOMEMLIMIT=700MiB

Мягкий он потому, что рантайм не убивает программу при достижении предела, а начинает собирать мусор чаще, стараясь в него уложиться. Практика такая: выставить GOMEMLIMIT примерно в 80-90% от лимита контейнера и оставить GOGC по умолчанию. Тогда в спокойное время сборок мало, а при росте нагрузки рантайм сам поджимается вместо того, чтобы упереться в лимит и получить exit 137.

Посмотреть, что сборщик вообще делает, можно без всякого профилировщика:

GODEBUG=gctrace=1 ./myapp

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

Память, которая не освобождается

Сборщик убирает недостижимое. Значит, любая «утечка» в Go — это на самом деле что-то, до чего всё ещё можно дотянуться. Три типичных случая.

Срез держит весь массив. Из среза на миллион элементов взяли первые пять:

small := big[:5]

small — это окно в тот же массив. Пока живёт окно, живёт весь миллион. Лечится копированием: small := append([]int(nil), big[:5]...) — и большой массив становится недостижимым.

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

Глобальная карта, из которой не удаляют. Кэш без вытеснения — не утечка с точки зрения языка и самая настоящая с точки зрения эксплуатации.

Отдельно стоит sync.Pool — способ переиспользовать временные объекты вместо того, чтобы каждый раз выделять новые. Его берут, когда профиль показал, что горячий путь аллоцирует много однотипных буферов:

var buffers = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

func handle() {
	b := buffers.Get().(*bytes.Buffer)
	defer func() { b.Reset(); buffers.Put(b) }()
	// ... работаем с b
}

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

pprof: смотреть, а не гадать

Все рассуждения выше стоят немного, если непонятно, куда на самом деле уходят память и время. Для этого в стандартной библиотеке есть профилировщик, и включается он одной строкой:

import _ "net/http/pprof"

Этот импорт регистрирует обработчики на /debug/pprof/ в стандартном мультиплексоре. Поднимать его наружу нельзя — там видно всё внутреннее устройство сервиса; обычный способ — отдельный слушатель на служебном порту, закрытом от интернета.

Дальше смотрят тремя срезами:

go tool pprof http://localhost:6060/debug/pprof/heap        что занимает память сейчас
go tool pprof http://localhost:6060/debug/pprof/profile     на что уходит процессор (30 секунд)
go tool pprof http://localhost:6060/debug/pprof/goroutine   сколько горутин и где они стоят

Последний срез — первое, что надо открыть при подозрении на утечку: если горутин десятки тысяч и все они стоят на одной строке, причина найдена за минуту. В самом pprof полезны две команды: top — кто больше всех, и list имяФункции — построчно, с числами напротив строк.

Коротко

  • Горутина — не поток: стек начинается с двух килобайт и растёт, а рантайм раскладывает много горутин по немногим потокам сам.
  • Горутина, которая ждёт, поток не занимает; горутина, которая считает, — занимает.
  • Стек или куча решает компилятор через escape analysis; go build -gcflags=-m показывает его решения.
  • Сборщик — конкурентный mark-and-sweep: полные остановки короткие, но барьер записи и разметка стоят процессорного времени.
  • Память не уплотняется, а работа сборки пропорциональна объёму живых объектов, а не мусора.
  • GOGC задаёт отношение (по умолчанию собирать при удвоении кучи), GOMEMLIMIT — мягкий предел; в контейнере нужен второй.
  • GODEBUG=gctrace=1 показывает каждый цикл сборки без профилировщика.
  • Утечка в Go — это всегда достижимость: срез держит массив, горутина не завершается, из карты не удаляют.
  • sync.Pool — сглаживание пиков аллокаций после профиля, не кэш и не хранилище.
  • net/http/pprof плюс go tool pprof: heap, profile, goroutine — три среза, с которых начинают разбор.

Что почитать дальше

  • Горутины и каналы — как запускать и останавливать то, что раскладывает планировщик.
  • Указатели — что именно убегает в кучу и почему возврат &n безопасен.
  • Срезы и карты — окно в массив, из-за которого память держится дольше, чем кажется.
  • Инструменты Go — где живут go build, go tool и переменные окружения рантайма.