Модуль: Runtime и память · Уровень: Senior+
TL;DR#
Garbage collector освобождает только то, на что нет живых ссылок — поэтому утечка в Go — это всегда «случайно удерживаемая ссылка» или незакрытый ресурс, а не ошибка аллокатора. Классика: растущие глобальные мапы, под-слайсы, держащие весь backing array, незакрытые response.Body/файлы, time.Ticker/time.After в циклах, кэши без эвикции, горутины-зомби. Ищут через pprof heap (inuse_space vs alloc_space), diff профилей и runtime.MemStats.
Теория#
Почему GC не спасает#
Go GC — tracing mark-and-sweep: живо то, что достижимо от корней (стеки горутин, глобальные переменные, регистры). Объект собирается, только когда на него нет ни одной ссылки. «Утечка» — это логически ненужный объект, который остаётся достижимым: его кто-то держит. Дополнительно есть феномен «память освобождена для рантайма, но не возвращена ОС» (RSS остаётся высоким) — это не утечка в строгом смысле, но выглядит похоже.
// runtime.MemStats: ключевые поля
var m runtime.MemStats
runtime.ReadMemStats(&m)
// HeapAlloc — байты живых объектов (примерно inuse)
// HeapInuse — байты в используемых spans
// HeapIdle — свободные spans (могут быть возвращены ОС)
// HeapReleased — возвращено ОС
// NextGC — порог следующего GC
// Mallocs/Frees — кумулятивно; растущая разница Mallocs-Frees = рост живых объектовГлобальные мапы: рост без удаления#
Самая частая утечка — мапа уровня пакета/синглтона, в которую только пишут.
var cache = map[string][]byte{} // живёт всё время работы процесса
func remember(k string, v []byte) {
cache[k] = v // никто никогда не удаляет -> неограниченный рост
}Map не отдаёт память после delete. Это критично для senior:
m := make(map[int]int)
for i := 0; i < 1_000_000; i++ {
m[i] = i
}
for i := 0; i < 1_000_000; i++ {
delete(m, i)
}
// len(m) == 0, НО backing-бакеты по-прежнему выделены под 1M элементов!Причина: delete помечает слот пустым, но рантайм не уменьшает число бакетов (нет shrink). Память бакетов вернётся, только если мапу полностью пересоздать:
m = make(map[int]int) // старая мапа целиком станет мусором и соберётсяДополнительный нюанс: если значения мапы — указатели/структуры с указателями, удержание бакетов держит и их (хотя сами значения после delete недостижимы и соберутся). Но если значение — []byte большого размера, важно реально delete, чтобы значение освободилось.
Незакрытые ресурсы#
http.Response.Body — самая частая утечка в backend-коде. Незакрытое тело держит соединение (нет переиспользования из пула, утечка goroutine/fd) и буферы.
// Плохо
resp, err := http.Get(url)
if err != nil { return err }
data, _ := io.ReadAll(resp.Body)
// забыли Close -> connection не возвращается в пул, fd/goroutine утекают
// Хорошо
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
// Чтобы соединение реально переиспользовалось — нужно ДОЧИТАТЬ тело:
io.Copy(io.Discard, resp.Body)Важно: Close() без полного чтения тела может не вернуть keep-alive соединение в пул. Идиома — io.Copy(io.Discard, resp.Body) перед Close().
Файлы, соединения, prepared statements, rows:
f, err := os.Open(path)
if err != nil { return err }
defer f.Close() // иначе утечка file descriptor (ulimit -> "too many open files")
rows, err := db.Query(q)
if err != nil { return err }
defer rows.Close() // *sql.Rows держит соединение из пула до Close/полного проходаСлайс-капканы#
Под-слайс держит весь backing array. Срез — это (ptr, len, cap) на общий массив. Маленький под-слайс не даёт собрать большой backing.
func leak() []byte {
huge := make([]byte, 10<<20) // 10 MB
return huge[:10] // вернули 10 байт, но держим 10 MB!
}
// Решение — скопировать нужную часть в свой массив:
func noLeak() []byte {
huge := make([]byte, 10<<20)
out := make([]byte, 10)
copy(out, huge[:10]) // huge собирается, держим 10 байт
return out
// Go 1.21+: return slices.Clone(huge[:10])
}Тот же эффект при парсинге: если из большого буфера вырезать []byte/строку под-слайсом и сохранить надолго — держится весь буфер.
Re-slice не освобождает, и truncate не зануляет элементы-указатели. При укорачивании слайса со срезанным len элементы за новым len остаются в backing array и не собираются GC, если это указатели.
type Conn struct{ /* большой объект */ }
func pop(s []*Conn) []*Conn {
n := len(s) - 1
// Плохо: s[n] всё ещё указывает на *Conn в backing -> не собрётся
return s[:n]
}
func popFixed(s []*Conn) []*Conn {
n := len(s) - 1
s[n] = nil // обнуляем указатель в backing array -> *Conn соберётся
return s[:n]
}Для удаления диапазона зануляйте «хвост»:
// Удаляем s[i:j], сдвигая остаток
copy(s[i:], s[j:])
for k := len(s) - (j - i); k < len(s); k++ {
s[k] = nil // обнуляем освободившиеся слоты
}
s = s[:len(s)-(j-i)]
// Go 1.21+: slices.Delete делает это правильно (зануляет хвост)Стандартная библиотека (slices.Delete, slices.Compact и т.д.) корректно зануляет освободившиеся элементы, если тип содержит указатели — используйте её.
time.Ticker / time.Timer#
time.NewTicker без Stop утекает: тикер держит внутренний таймер в куче рантайма и продолжает слать в канал (до Go 1.23 даже не собирался GC, пока не остановлен).
// Плохо
func worker() {
t := time.NewTicker(time.Second)
for range t.C {
do()
} // если из цикла выйти, t не остановлен
}
// Хорошо
func worker(ctx context.Context) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-t.C:
do()
case <-ctx.Done():
return
}
}
}time.After в for-select — классическая утечка: каждая итерация создаёт новый Timer, который живёт до своего срабатывания и не собирается раньше (до Go 1.23). При высокой частоте итераций — рост памяти.
// Плохо: новый таймер каждую итерацию
for {
select {
case v := <-in:
process(v)
case <-time.After(time.Minute): // утечка таймеров при частом in
return
}
}
// Хорошо: переиспользуем один Timer с Reset
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case v := <-in:
process(v)
if !t.Stop() {
select { case <-t.C: default: } // дренаж до Go 1.23
}
t.Reset(time.Minute)
case <-t.C:
return
}
}Замечание про Go 1.23+: таймеры стали обычными объектами кучи и собираются GC, даже если не остановлены; канал больше не буферизуется так, что нужен ручной дренаж. Но Stop()/корректное завершение горутин по-прежнему обязательны, и на старых версиях паттерн с time.After в цикле — реальная утечка.
Кэши без эвикции#
Любая структура «положили и забыли» без ограничения размера/TTL — потенциальная утечка. Решения:
- LRU/LFU с фиксированной ёмкостью (
hashicorp/golang-lru). - TTL-эвикция фоновой горутиной или ленивая при чтении.
sync.Mapсам по себе не имеет эвикции — это не кэш, а конкурентная мапа; те же проблемы роста.
// Утечка: sync.Map как «кэш» без вытеснения
var cache sync.Map
func put(k, v any) { cache.Store(k, v) } // растёт вечноЛовушки с указателями в структурах#
- Указатель на элемент большой структуры/слайса держит весь объект.
- Замыкания, захватывающие большой объект, держат его всё время жизни замыкания (например, переданного в долгоживущую горутину или таймер).
finalizer(runtime.SetFinalizer) может задерживать сборку на лишний цикл GC и создавать циклы удержания.- Горутина-зомби, заблокированная на канале/мьютексе навсегда, держит весь свой стек и всё, на что он ссылается, — частая «невидимая» утечка (виден рост
goroutineпрофиля).
// Утечка горутины: отправитель блокируется навсегда, если читателя нет
func leakGoroutine() {
ch := make(chan int) // небуферизованный
go func() {
ch <- compute() // навсегда заблокирован, если никто не читает
}()
// забыли прочитать ch / нет ctx -> горутина и её стек живут вечно
}Как искать утечки#
pprof heap: inuse_space vs alloc_space#
import _ "net/http/pprof" // регистрирует /debug/pprof/*
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()# Снять heap-профиль
go tool pprof http://localhost:6060/debug/pprof/heap
# Внутри pprof:
(pprof) top
(pprof) list <func>
(pprof) web # граф (нужен graphviz)
(pprof) png > heap.pngЧетыре метрики heap-профиля:
| Метрика | Что показывает | Когда использовать |
|---|---|---|
inuse_space | байты живых объектов сейчас | поиск утечки (что удерживается) |
inuse_objects | число живых объектов | утечка по количеству мелких объектов |
alloc_space | всего аллоцировано за время жизни | поиск давления на GC (где аллоцируем) |
alloc_objects | всего объектов аллоцировано | частота аллокаций |
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap # утечки
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap # GC pressureКлюч: утечка ищется в inuse_space — если он монотонно растёт между снимками, что-то удерживается. alloc_space высокий, но inuse стабильный — это не утечка, а просто много короткоживущих аллокаций (оптимизировать ради GC, но память не течёт).
Diff профилей (главный приём)#
# Снять два снимка с интервалом под нагрузкой
curl -s http://localhost:6060/debug/pprof/heap > heap1.out
# ... подождать / прогнать нагрузку ...
curl -s http://localhost:6060/debug/pprof/heap > heap2.out
# Сравнить: что ВЫРОСЛО между снимками
go tool pprof -inuse_space -base heap1.out heap2.out
(pprof) top # покажет дельту по функциям — прямо к источнику утечки-base (или -diff_base) показывает разницу — это самый надёжный способ найти, что именно накапливается со временем. Растущая строка в diff = кандидат на утечку.
runtime.MemStats и goroutine-профиль#
var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("HeapAlloc=%d MB, HeapObjects=%d, NumGoroutine=%d, NumGC=%d",
m.HeapAlloc>>20, m.HeapObjects, runtime.NumGoroutine(), m.NumGC)- Монотонно растущий
HeapAlloc/HeapObjectsпри стабильной нагрузке = утечка. - Растущий
runtime.NumGoroutine()= утечка горутин (часто первопричина утечки памяти). Профиль:http://localhost:6060/debug/pprof/goroutine?debug=2. HeapReleasedнизкий при высокомHeapIdle— память не возвращена ОС (можно подтолкнутьdebug.FreeOSMemory()/GODEBUG=madvdontneed=1, но это не лечит логическую утечку).
# Профиль горутин — найти, где они залипли
go tool pprof http://localhost:6060/debug/pprof/goroutine
# или человекочитаемо со стеками:
curl http://localhost:6060/debug/pprof/goroutine?debug=2Дополнительно#
GODEBUG=gctrace=1— лог каждого GC (рост live heap между циклами заметен).- Continuous profiling (Pyroscope, Datadog, Grafana) для prod.
testing+runtime.ReadMemStatsв тестах для регрессионных проверок аллокаций.
Подводные камни / gotchas#
deleteиз мапы не уменьшает число бакетов — память backing не возвращается; для реального освобождения нужноm = make(...).- Под-слайс
huge[:10]держит весь backing arrayhuge— копируйте нужную часть (slices.Clone/copy). s = s[:n]не зануляет элементы заn; если это указатели — они не соберутся. Используйтеslices.Deleteили ручное зануление хвоста.resp.Bodyнужно иClose(), и (для keep-alive) дочитать (io.Copy(io.Discard, body)).time.Afterвfor-selectсоздаёт таймер каждую итерацию — на Go <1.23 это утечка; используйте переиспользуемыйTimerсReset.time.NewTicker/NewTimerбезStop— утечка таймеров (и горутин на старых версиях).sync.Map— не кэш: нет эвикции, растёт неограниченно.- Заблокированная навсегда горутина держит весь стек и захваченные объекты; смотрите goroutine-профиль, а не только heap.
- Высокий
alloc_space≠ утечка; утечка видна в растущемinuse_space/diff. - Высокий RSS при стабильном
HeapAlloc— память не возвращена ОС, а не утечка (разные проблемы).
Вопросы на собеседовании#
В: Если есть GC, как в Go вообще возможны утечки памяти? О: GC собирает только недостижимые объекты. Утечка — это логически ненужный объект, который остаётся достижимым: глобальная мапа без удаления, под-слайс, держащий большой backing, заблокированная горутина с её стеком, незакрытый ресурс, кэш без эвикции. Плюс отдельный феномен — память, освобождённая рантаймом, но не возвращённая ОС (высокий RSS), что не утечка, но похоже.
В: Освобождается ли память мапы после delete всех ключей?
О: Нет. delete помечает слоты пустыми, но рантайм не уменьшает число бакетов (нет shrink), поэтому backing остаётся выделенным под прежний размер. Сами значения после delete становятся недостижимыми и собираются, но структура бакетов — нет. Чтобы вернуть память, мапу нужно пересоздать: m = make(map[K]V).
В: Почему return bigSlice[:10] может привести к утечке?
О: Срез — это (ptr, len, cap) на общий backing array. Под-слайс длиной 10 всё ещё указывает на весь массив, поэтому GC не может его собрать, пока жив под-слайс. Если нужна лишь малая часть надолго — скопировать в новый массив (slices.Clone/copy), тогда большой backing соберётся.
В: Почему при s = s[:n] могут не собираться объекты, и как чинить?
О: Усечение меняет только len; элементы между новым len и cap остаются в backing array. Если это указатели (или структуры с указателями), они достижимы и не собираются. Нужно занулить освободившиеся слоты (s[n] = nil) перед усечением. slices.Delete/slices.Compact делают это автоматически.
В: Чем опасен time.After в for-select и как сделать правильно?
О: time.After на каждой итерации создаёт новый Timer, который живёт до срабатывания. При частых итерациях накапливаются таймеры — утечка (особенно на Go <1.23, где они не собирались до срабатывания). Правильно — один time.NewTimer с Stop()+Reset() в цикле, либо контекст с одним таймаутом снаружи.
В: Что нужно сделать с http.Response.Body, кроме Close?
О: Для возврата keep-alive соединения в пул тело надо дочитать: io.Copy(io.Discard, resp.Body) перед Close(). Иначе соединение может не переиспользоваться (утечка соединений/fd/горутин транспорта), а не только память буферов. И Close() обязателен через defer сразу после проверки ошибки.
В: В чём разница inuse_space и alloc_space в pprof и какой использовать для поиска утечки?
О: inuse_space — байты живых объектов на момент снимка; alloc_space — всё, что аллоцировано за время жизни процесса (кумулятивно). Для утечки смотрят inuse_space: если он растёт между снимками — что-то удерживается. alloc_space показывает давление на GC (где много аллокаций), но высокий alloc_space при стабильном inuse_space — это не утечка.
В: Как практически локализовать растущую утечку в проде?
О: Снять два heap-снимка с интервалом под нагрузкой и сделать diff: go tool pprof -inuse_space -base heap1 heap2, затем top/list — растущие строки указывают на источник. Параллельно проверить runtime.NumGoroutine() и goroutine-профиль (?debug=2) на утечку горутин, и MemStats (HeapAlloc, HeapObjects) на тренд. Continuous profiling помогает увидеть тренд во времени.
В: Как обнаружить утечку горутин и почему она ведёт к утечке памяти?
О: Растущий runtime.NumGoroutine() при стабильной нагрузке и goroutine-профиль (/debug/pprof/goroutine?debug=2) со множеством горутин, залипших на одном стеке (chan receive, select, sync.Mutex.Lock). Каждая живая горутина держит свой стек и все объекты, на которые он ссылается (захваченные замыканием, переданные в канал и т.д.), поэтому утечка горутин почти всегда тянет за собой утечку памяти. Лечится context-отменой, таймаутами, корректным закрытием каналов.
На что копают на senior+#
- Чёткое понимание, что
deleteне shrink-ит мапу, и когда это критично (большие значения vs указатели в значениях). - Знание slice internals: под-слайс держит backing, усечение не зануляет хвост, full-slice expression
a[i:j:k], поведениеslices.Delete/Clone. - Различие
inuse_*иalloc_*, умение делать diff-профили (-base) и читатьlist/граф. - Связь утечки горутин с утечкой памяти; навык читать goroutine-профиль и находить точку блокировки.
- Нюансы таймеров: поведение
time.After/Tickerдо и после Go 1.23, необходимость дренажа канала на старых версиях. - HTTP-пул соединений: почему важно дочитывать тело, как незакрытый Body утекает fd/горутины.
- Различие «утечка» vs «RSS не возвращён ОС»:
HeapIdle/HeapReleased,debug.FreeOSMemory,GOMEMLIMIT, madvise-стратегии. - Проектирование кэшей с эвикцией (LRU/TTL) и понимание, что
sync.Map— не кэш. - Аккуратность с
runtime.SetFinalizer(задержка сборки, циклы удержания) и с замыканиями, захватывающими большие графы объектов.