Программа на Go собирается в один исполняемый файл, и кажется, что она просто выполняется процессором. На самом деле вместе с вашим кодом в этот файл попадает ещё один — рантайм: он раздаёт горутинам процессорное время, решает, где положить значение, и убирает то, что больше не нужно.
Обычно про рантайм не думают, и это правильно: он для того и сделан. Но однажды сервис начинает жрать память, в графиках появляются пики, а на вопрос «почему» ответить нечем. Разберём, что там внутри, чтобы в такой момент было куда смотреть.
Горутина — это не поток операционной системы
Попробуйте запустить сто тысяч потоков операционной системы. Не получится: у каждого потока свой стек, который ядро выделяет сразу и целиком — обычно один-два мегабайта. Сто тысяч потоков — это сотни гигабайт только под стеки, и это не считая того, что переключение между ними делает ядро, а это дорого.
Сто тысяч горутин — обычное дело. Разница в двух вещах.
Первое: у горутины маленький стек, и он растёт по мере надобности. Начинается с двух килобайт. Когда функция уходит вглубь и места не хватает, рантайм выделяет стек побольше и копирует туда старый. Поэтому глубокая рекурсия в Go не падает на ровном месте, а горутина, которая всего лишь ждёт ответа по сети, почти ничего не стоит. Предел у роста есть: на 64-битных системах стек одной горутины может дорасти до гигабайта, то есть стать больше стека системного потока, а дальше программа падает с fatal error: stack overflow; предел меняют через debug.SetMaxStack.
Второе: горутины разложены по потокам, а не равны им. Рантайм сам раскладывает горутины по настоящим потокам, и одновременно выполнять код Go могут не больше GOMAXPROCS из них — по умолчанию столько, сколько ядер видит процесс (с Go 1.25 учитывается и лимит процессора у контейнера). Потоков при этом может быть и больше: те, что застряли в системном вызове, в это число не входят. Такое отношение «много к немногим» и называют планировщиком; переключение между горутинами при этом происходит внутри процесса, без обращения к ядру, и стоит десятки наносекунд вместо микросекунд.
Отсюда практическое следствие, которое ловят не сразу. Если горутина занята вычислением — крутит цикл и не делает системных вызовов, — она держит поток, пока рантайм её не вытеснит (с Go 1.14 это происходит принудительно, примерно раз в 10 мс), и чем больше таких считающих горутин, тем меньше процессора достаётся каждой. Горутина, которая ждёт сеть, диск или канал, поток освобождает сразу: рантайм снимает её и ставит другую. Поэтому тысяча горутин, ждущих HTTP-ответа, — это нормально, а тысяча горутин, перемножающих матрицы, — это очередь на ваши восемь ядер.
Почувствовать разницу можно прямо в программе: runtime.NumGoroutine() считает живые горутины, и тысяча спящих почти ничего не стоит:
live example
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
fmt.Println("до:", runtime.NumGoroutine())
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
}()
}
fmt.Println("в разгаре:", runtime.NumGoroutine())
wg.Wait()
fmt.Println("после:", runtime.NumGoroutine())
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Тысяча горутин, каждая со своим стеком в пару килобайт, - это мегабайты, а не гигабайты, и ни один системный поток не создан под них специально. С потоками операционной системы такой фокус не прошёл бы.
Стек или куча: кто это решает
В C программист решает сам: локальная переменная — на стеке, malloc — в куче. В Go такого выбора нет, и это часто удивляет:
func newCounter() *int {
n := 0
return &n
}
В C так писать нельзя — вернули бы адрес умершей переменной. В Go можно, и это безопасно. Компилятор видит, что адрес n убегает за пределы функции, и кладёт значение не на стек, а в кучу. Такой разбор называется escape analysis — анализ убегания.
Правило простое: если компилятор может доказать, что значение не переживёт функцию, оно остаётся на стеке, и его уборка бесплатна — стек просто сдвигается назад. Если доказать нельзя, значение едет в кучу, и за ним потом придёт сборщик мусора.
Программа с тем же newCounter - значение честно живёт и после выхода из функции:
live example
package main
import "fmt"
func newCounter() *int {
n := 0
return &n // адрес убегает из функции: значение поедет в кучу
}
func main() {
p := newCounter()
*p = 42
fmt.Println(*p) // 42: значение живёт и после выхода из newCounter
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Посмотреть решения компилятора можно прямо:
go build -gcflags=-m ./...
В выводе будут строки вида ./main.go:7:2: moved to heap: n — это и есть «уехало в кучу». Смотреть туда стоит не всегда, а когда профиль показал, что аллокаций подозрительно много: типичная находка — значение убегает из-за того, что его передали в interface{} или положили в замыкание, хотя по смыслу оно временное.
Сборка мусора: что именно происходит
Сборщик мусора в Go отвечает на один вопрос: до каких объектов в куче ещё можно дотянуться из работающей программы? Всё остальное — мусор.
Работает он так. Есть корни — глобальные переменные и стеки горутин. От них рантайм идёт по указателям и помечает всё, до чего дошёл. То, что осталось непомеченным, объявляется мусором, и его память возвращается в пул свободной. Этот приём называется mark-and-sweep: пометить и подмести.
Важное отличие Go от многих других сборщиков: он работает одновременно с программой, а не вместо неё. Целиком останавливать программу приходится дважды за цикл, и оба раза очень коротко — доли миллисекунды: один раз, чтобы зафиксировать начало разметки, второй — чтобы её завершить. Всё остальное время разметка идёт параллельно с вашим кодом.
Миллион ненадолго живущих строк - дёшево: мусор подметается, и куча не растёт бесконечно. Посмотреть на это можно через runtime.ReadMemStats:
live example
package main
import (
"fmt"
"runtime"
)
func main() {
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 1_000_000; i++ {
s := fmt.Sprintf("кусок %d", i)
_ = s
}
runtime.ReadMemStats(&after)
fmt.Println("аллокаций до:", before.Mallocs, "после:", after.Mallocs)
runtime.GC()
fmt.Println("сборщик прошёлся: мусор вернулся в пул свободной памяти")
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Число аллокаций у каждого запуска своё - важна не точная цифра, а порядок: миллион мелких строк прошли через программу и не остались в памяти, потому что ссылки на них умерли сразу.
Отсюда вырастает главная сложность: пока идёт разметка, программа продолжает менять указатели. Объект, уже помеченный как мусор, может внезапно снова стать нужным. Чтобы этого не случилось, компилятор вставляет барьер записи — незаметный кусочек кода при каждом присваивании указателя, который сообщает сборщику об изменении. Барьер стоит процессорного времени, и это одна из причин, почему сборка не бесплатна даже когда «пауз почти нет».
Чем такой подход плох
У 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 имяФункции — построчно, с числами напротив строк.
Глубже: планировщик по именам: G, M, P и сетевой поллеррасширенное
Выше планировщик описан словами «много горутин на немногих потоках». На собеседовании и в выводе pprof он встречается под тремя буквами, и их стоит знать. G — горутина: стек, место, где её остановили, и состояние. M — поток операционной системы, тот самый, который видит ядро. P — «процессор», но не железный: это право выполнять код Go, и таких прав ровно GOMAXPROCS. У каждого P своя очередь готовых горутин; поток M, чтобы запустить горутину, обязан держать P. Поэтому одновременно работают не больше GOMAXPROCS горутин, сколько бы потоков ни было создано.
Зачем нужен P, если есть M? Чтобы блокировка потока не стоила процессора. Горутина ушла в системный вызов, который может надолго застрять, например в чтение файла: поток M уходит вместе с ней, а P от него отцепляется и достаётся другому потоку, который продолжает крутить очередь. За этим следит служебный поток sysmon: он же раз в 10 мс вытесняет горутину, которая слишком долго считает и не отдаёт P сама.
С сетью проще. Сокеты в Go неблокирующие, и горутина, ждущая данных, в системном вызове не висит: её снимают с P и регистрируют в сетевом поллере поверх epoll в Linux или kqueue в macOS. Когда данные пришли, поллер возвращает горутину в очередь. Так десять тысяч открытых соединений держат десять тысяч горутин и несколько потоков, а не десять тысяч потоков. Когда своя очередь у P опустела, он берёт горутины из общей очереди или крадёт половину очереди у соседа, чтобы ядра не простаивали.
Глубже: сборщик по именам: трёхцветная разметка и stop-the-worldрасширенное
Разметку из раздела про сборку мусора описывают как трёхцветный алгоритм. В начале цикла все объекты белые. Корни красят в серый: это «нашли, но внутрь ещё не смотрели». Сборщик берёт серый объект, красит серым всё, на что тот ссылается, и перекрашивает его в чёрный: «нашли и осмотрели». Когда серых не осталось, всё белое недостижимо, и его можно освобождать. Барьер записи из основного раздела нужен ровно для того, чтобы программа не спрятала белый объект за чёрным, пока разметка идёт одновременно с ней.
Полные остановки программы, те самые stop-the-world, за цикл случаются дважды: включить барьер записи и начать разметку, затем убедиться, что серых не осталось, и выключить барьер. Обе длятся доли миллисекунды, а вот сама разметка занимает до четверти процессорного времени, и горутина, которая выделяет память слишком быстро, получает «помощь»: её заставляют часть разметки сделать самой. Освобождение, подметание, идёт лениво: ячейку чистят, когда её снова просят.
Управлять этим из кода можно, но редко нужно: runtime.GC() запускает полный цикл сборки и ждёт его конца, debug.SetGCPercent и debug.SetMemoryLimit делают то же, что переменные GOGC и GOMEMLIMIT, а debug.FreeOSMemory возвращает свободную память операционной системе. Обычный повод звать runtime.GC() руками один: тест или замер, которому нужна чистая куча перед измерением.
Коротко
- Горутина — не поток: стек от двух килобайт растёт, а планировщик (G — горутина, M — поток, P — право выполнять код,
GOMAXPROCSштук) раскладывает много горутин по немногим потокам; блокирующий syscall уводит M, P достаётся другому потоку. - Горутина, которая ждёт, поток не занимает; горутина, которая считает, держит его до вытеснения и ест процессор.
- Стек или куча решает компилятор через 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 — три среза, с которых начинают разбор.- Сборщик: трёхцветная разметка с барьером записи, два коротких stop-the-world за цикл,
runtime.GC()для принудительного цикла в тестах.
Что почитать дальше
- Горутины и каналы — как запускать и останавливать то, что раскладывает планировщик.
- Указатели — что именно убегает в кучу и почему возврат
&nбезопасен. - Срезы и карты — окно в массив, из-за которого память держится дольше, чем кажется.
- Инструменты Go — где живут
go build,go toolи переменные окружения рантайма.