Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Middle ~21 мин 8 вопросов

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

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

Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».

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

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

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

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

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

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

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

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

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

Стандартный маршрут. (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) показывает именно прирост. В ответе хороша связка «сначала факты (метрики), потом профили, потом гипотеза по коду».

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

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

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

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

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

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())
}

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

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

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

8. Как измерить аллокации функции? Что означает -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 test -bench=Render -benchmem -count=5
// Render-8   123 ns/op   48 B/op   2 allocs/op

Прокачать тему в самоучителе

Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице: