---
title: "Сборка мусора, память и утечки в Go: вопросы собеседования с ответами"
description: "Вопросы про GC и память на собеседовании Go: как работает сборщик мусора, стек и куча, escape analysis, GOGC и GOMEMLIMIT, утечки с GC, pprof — с ответами."
url: "https://golangmaster.ru/interview/gc-pamyat-i-utechki/"
published: "2026-09-19"
updated: "2026-09-21"
level: "middle"
questions: 10
---

# Сборка мусора, память и утечки в Go: вопросы собеседования с ответами

*Уровень: middle. ~21 минут, 10 вопросов. Источник: https://golangmaster.ru/interview/gc-pamyat-i-utechki/*

Тема памяти на собеседовании проверяет, понимаете ли вы, что «в Go есть GC» не равно «утечки невозможны». Вопросы идут по цепочке: как устроен сборщик → что попадает в кучу → почему память всё равно растёт → как найти.

Ниже — вопросы с ответами: трёхцветный параллельный GC, escape analysis, четыре типа утечек в Go, поиск утечки через pprof, GOGC и GOMEMLIMIT, sync.Pool, подстрока от длинной строки и измерение аллокаций бенчмарком. Плюс вопрос про memory ballast — приём, о котором полезно знать, чтобы не применять его слепо.

Порядок ответа прежний: сначала проблема, потом механизм, потом граница и пример. Главная мысль, которую стоит уметь произнести: утечка в Go — это ошибка жизненного цикла ссылок, а не сбой сборщика мусора. Практический вывод отсюда: память стоит измерять, а не угадывать — цифры из runtime/metrics и pprof говорят больше, чем любая интуиция.

Практическая часть (pprof, бенчмарки с -benchmem) разобрана в [продвинутом курсе Go](/courses/go-advanced/), основы указателей — в [базовом](/courses/go-basics/). Память стоит измерять на каждом профиле, а не списывать на «сложный код».

## Как работает сборщик мусора в Go?

Сборщик Go — параллельный, трёхцветный, маркирующе-убирающий (mark-and-sweep). Живые объекты помечаются от корней (стеки горутин, глобальные переменные) по ссылкам: белый — не просмотрено, серый — найдено, но дети не разобраны, чёрный — обработано. Маркировка идёт параллельно с работой программы при коротких stop-the-world паузах (доли миллисекунды), уборка — в фоне. Сборка запускается, когда живая память выросла относительно последнего сбора на долю GOGC (по умолчанию 100% — удвоение), либо по мягкому потолку GOMEMLIMIT (Go 1.19+). В ответе достаточно «трёхцветный, параллельный, паузы суб-миллисекундные, управляется GOGC/GOMEMLIMIT» — глубже лезть стоит, только если спросят.

## Что попадает в стек, а что в кучу? Что такое escape analysis?

По умолчанию локальные переменные живут в стеке горутины — выделение и освобождение почти бесплатны (сдвиг указателя). В кучу значение попадает, когда компилятор не может доказать, что оно не переживёт кадр стека: ссылка «утекла» наружу — в возвращаемое значение, глобальную переменную, другой горутин, интерфейс. Анализ этого — escape analysis, он на этапе компиляции (посмотреть решения: go build -gcflags='-m'). Вопрос-ловушка: указатель на локальную переменную НЕ обязательно значит кучу — если указатель не покидает кадр, компилятор оставляет значение в стеке. Куча дороже стека и по выделению, и по нагрузке на GC, поэтому уменьшение escape-ов — легальная оптимизация.

```go
func newUser() *User { // escape: указатель возвращается наружу
    u := User{Name: "Аня"}
    return &u // u в куче
}
func sum(p *int) int { // p не покидает функцию
    return *p + 1      // переменная вызывающего остаётся в его стеке
}
```

## Есть же сборщик мусора — откуда тогда утечки памяти в Go?

GC собирает недостижимое; утечка в Go — это достижимое, но ненужное. Типовой список, который ждут: (1) горутины, навсегда заблокированные на канале, — держат свои стеки и всё, на что ссылаются; (2) срез, взятый «окном» от гигантского массива, — держит весь массив (лечить — full slice expression или копия); (3) растущая без границ карта/кэш без вытеснения; (4) time.After в горячем цикле до Go 1.23 — таймер не соберётся до срабатывания; (5) забытые pprof-экспозиции, глобальные реестры колбэков, setFinalizer-зависимости. Формулировка-вывод: «утечка — это ошибка жизненного цикла ссылок, а не сбой GC».

```go
big := make([]byte, 100<<20)
head := big[:10]        // держит все 100 МБ
head = append([]byte(nil), big[:10]...) // копия — массив соберётся
```

## Как найти утечку памяти в работающем сервисе?

Стандартный маршрут. (1) runtime.ReadMemStats или экспорт метрик: HeapInuse растёт после снятия нагрузки — подозрение подтверждено; отдельно смотреть Sys (отданная ОС не возвращается мгновенно). (2) pprof heap-профиль: import _ "net/http/pprof", снять go tool pprof -http=: :/debug/pprof/heap — профиль покажет, какие аллокации живы, с сортировкой по inuse_space. (3) Горутинный профиль (/debug/pprof/goroutine) — если утечка через застрявшие горутины, их число и точки блокировки видны сразу. (4) Сравнение двух профилей в моменте «до и после» (pprof -base) показывает именно прирост. В ответе хороша связка «сначала факты (метрики), потом профили, потом гипотеза по коду».

```go
import _ "net/http/pprof"
http.Handle("/debug/pprof/", ...)
// далее:
// go tool pprof -http=:8081 http://svc/debug/pprof/heap
```

## Что такое GOGC и GOMEMLIMIT и как их настраивать?

GOGC — цель по росту живой кучи между сборками: GOGC=100 (по умолчанию) — собирать, когда куча удвоилась. Меньше значение — чаще сборки, ниже пик памяти, выше CPU на GC; больше — реже, просторнее, быстрее приложение. GOMEMLIMIT (Go 1.19+) — мягкий потолок всей памяти процесса: GC форсирует сборки при подходе к лимиту, что спасает в контейнерах с memory limit (раньше OOMKill случался из-за того, что GC «не успевал» среагировать на резкий рост). Практика в k8s: GOMEMLIMIT ставят ~85-90% лимита контейнера, GOGC оставляют или поднимают. Отдельно сказать про ошибка-чит: GOMEMLIMIT не жёсткий предел — при невозможности освободить память процесс всё равно превысит (живые данные не собрать), отсюда же забота про OOM не отменяется.

## Для чего нужен sync.Pool?

Пул переиспользуемых объектов, снижающий нагрузку на аллокатор и GC в горячих путях: буферы (bytes.Buffer), временные структуры кодирования. Get достаёт из пула (или New, если пусто), Put возвращает — важно класть объект в очищенном состоянии. Нюансы, за которые спрашивают: элементы пула не имеют гарантий жизни — GC может снять их при любой сборке (поэтому пул не «кэш»), Put с указателями, в которых застряли большие данные, сам становится утечкой (обнуляйте перед Put), и пул привязан к P-процессорам — между горутинами объекты гуляют. Измерять эффект — бенчмарком с -benchmem, а не «по ощущению».

```go
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}
func render(w io.Writer) {
    b := bufPool.Get().(*bytes.Buffer)
    defer func() { b.Reset(); bufPool.Put(b) }()
    b.WriteString("...")
    w.Write(b.Bytes())
}
```

## Подстрока от длинной строки — утечка? Как правильно?

Да, классика: s := big[:40] не копирует байты — подстрока держит весь исходный массив на строку в 40 байт. То же с срезом байт. Если длинная строка живёт в глобальном кэше, а вы сохранили от неё огрызок — весь миллион байт не соберётся. Лечение: явное копирование string([]byte) / []byte(s) или strings.Clone(s) (Go 1.18+) — «хочу отдельную память» теперь выражено идиоматично. Обратная идиома тоже спрашивается: string(b) при обходе без аллокации — unsafe.String/unsafe.Slice, но это уже с явной пометкой об осторожности.

```go
short := strings.Clone(long[:40]) // своя память, long соберётся
```

## Как измерить аллокации функции? Что означает -benchmem?

Бенчмарком: go test -bench=. -benchmem -.count=5 — колонки allocs/op (число аллокаций) и B/op (байты) показывают цену вызова, а сравнение веток — эффект оптимизации. Для поиска горячих мест — профили аллокаций: go test -memprofile и go tool pprof -alloc_space (сколько выделено за всё время) vs -inuse_space (сколько живо сейчас) — это разные вопросы: первое про нагрузку на GC, второе про утечки. В ответе стоит назвать compiler-оптимизации: строковая конкатенация в цикле — O(n²) аллокаций, сборка через strings.Builder; и что escape analysis виден флагом -gcflags='-m'.

```go
go test -bench=Render -benchmem -count=5
// Render-8   123 ns/op   48 B/op   2 allocs/op
```

## Что такое memory ballast и работает ли он в современном Go?

Идея memory ballast появилась, когда в Go не было лимита памяти: приложение держало в памяти большой ballast — фиктивный массив байтов, к которому ссылается маленький живой объект, — чтобы «раздуть» кучу. Считалось, что так GC будет реже срабатывать: цель роста считается от последней отметки, а отметка сдвигается на размер живого набора.

Механика была в том, что ballast — живой объект, поэтому он не собирается и постоянно увеличивает размер кучи, от которого считается процент роста. При GOGC=100 отметка откладывалась вдвое дальше, и сборки становились реже.

Современный Go этот приём сделал бесполезным. Во-первых, появился GOMEMLIMIT — мягкий предел памяти, который работает напрямую, а не через подгонку GOGC. Во-вторых, ballast больше не помогает экономить: он занимает память постоянно, а современный GC всё равно запускает сборки по своему pacer'у, а не только по порогу роста кучи. В-третьих, ballast — это удержание лишних мегабайт, что в контейнере с лимитом памяти просто выдавит процесс за пределы лимита.

Что из этого стоит вынести: настройка памяти в Go сегодня — это GOGC плюс GOMEMLIMIT плюс снижение числа аллокаций, а не Ballast-трюки из статей десятилетней давности. Если на собеседовании спросят про ballast, стоит сказать именно это: механизм был рабочим для своего времени, но его вытеснили прямые инструменты контроля.

Дополнительно стоит упомянуть связку: если выставляется GOMEMLIMIT, GOGC обычно оставляют по умолчанию — pacer сам подстроится, чтобы уложиться в предел.

```go
// Старый приём: держать в памяти ballast, чтобы реже срабатывал GC
//var ballast = make([]byte, 512<<20)
//func init() { runtime.KeepAlive(ballast) }

// Современный подход: прямой лимит памяти процесса
// GOMEMLIMIT=440MiB  GOGC=100
```

## Что такое OOMKill и как настроить Go, чтобы его избежать?

Проблема выглядит так: приложение работает сутки, потом в логах появляется запись о том, что процесс убит, без паники и без стектрейса. Это OOMKill — ядро убило процесс, потому что он превысил лимит памяти контейнера или хоста. Со стороны Go это выглядит как «сервис просто исчез», и найти причину по логам почти невозможно.

Отличить OOMKill от других аварий помогает код выхода: процесс, убитый ядром, обычно завершается с сигналом SIGKILL, а не с кодом 1 или 2. В Kubernetes это видно в описании пода — контейнер завершён с причиной OOMKilled.

Что с этим делать со стороны приложения. Первое — выставить GOMEMLIMIT ниже лимита контейнера: тогда рантайм заранее начнёт сжимать кучу, вместо того чтобы упереться в стену. Второе — найти, что именно растёт. Здесь важно различать три сценария. Утечка в коде: память монотонно растёт, GC не может ничего собрать — лечится правкой кода, а лимит лишь откладывает падение. Давление настройки: живых данных много, но они осмысленные — лечится разбором, что именно держим в памяти, и переносом холодного на диск. Слишком низкий GOGC: сборки идут часто, CPU уходит в GC — лечится подъёмом GOGC при поднятом GOMEMLIMIT.

Третье, что стоит проверить, — возвращается ли память ОС. RSS после пиковой нагрузки может не уменьшаться, хотя куча сдулась: свободные страницы остаются в процессе, и контейнер их считает занятыми. Проверяется метриками RSS и HeapInuse в сравнении.

Четвёртое — стеки горутин. Десять тысяч горутин по восемь килобайт стека — это около восьмидесяти мегабайт вне кучи, и они не учитываются в HeapInuse. При большом числе горутин это заметная часть лимита.

```go
// Метрики, которые показывают картину при OOMKill:
// runtime/metrics: /memory/classes/heap/objects:bytes — живая куча
// /memory/classes/total/bytes — всё, включая стеки и рантайм
// RSS процесса — сколько реально заняла ОС

// Настройка, дающая рантайму шанс сжаться до лимита:
// GOMEMLIMIT=440MiB  (при лимите контейнера 512Mi)
```
