Модуль: 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 array huge — копируйте нужную часть (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 (задержка сборки, циклы удержания) и с замыканиями, захватывающими большие графы объектов.