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

Garbage Collector и Runtime в Go

Кратко: Go runtime — это слой между твоей программой и ОС. Он управляет горутинами (планировщик), памятью (allocator), и автоматически очищает мусор (GC). Понимать GC и runtime нужно, чтобы писать код, который не тормозит из-за пауз, не утекает память и не аллоцирует впустую.

Зачем знать джуну: На собеседовании спросят “что такое escape analysis?”, “как работает GC?”, “почему моя программа жрёт память?”. В production к тебе придут с GOMEMLIMIT в k8s и pprof heap-профилем — нужно понимать, что показывают цифры.

  1. Базовое API: runtime и debug пакеты
  2. Под капотом: tri-color mark and sweep, write barrier, stack vs heap
  3. Gotchas: типичные ошибки с памятью
  4. Производительность: бенчмарки, sync.Pool, pprof
  5. Вопросы на собесе
  6. Practice
  7. Источники

В C/C++ ты сам вызываешь malloc/free. Это даёт контроль, но порождает баги:

  • Memory leak — забыл free, память течёт.
  • Double free — освободил дважды → undefined behavior.
  • Use-after-free — обратился к освобождённой памяти → crash или security hole.
  • Dangling pointer — указатель на освобождённую память.

Go решает это автоматически: ты пишешь x := &Foo{} и не думаешь про освобождение. GC сам найдёт, когда объект больше никем не используется (нет ссылок), и заберёт память.

Цена — паузы (STW, stop-the-world) и накладные расходы. Но в Go GC настолько оптимизирован, что паузы — субмиллисекундные.

package main
import (
"fmt"
"runtime"
)
func main() {
// Создали мусор
for i := 0; i < 1_000_000; i++ {
_ = make([]byte, 1024)
}
var m1, m2 runtime.MemStats
runtime.ReadMemStats(&m1)
fmt.Printf("До GC: HeapAlloc=%d KB\n", m1.HeapAlloc/1024)
runtime.GC() // блокирующий запуск GC
runtime.ReadMemStats(&m2)
fmt.Printf("После GC: HeapAlloc=%d KB\n", m2.HeapAlloc/1024)
}

⚠️ В production почти никогда не нужно. GC сам решит. Ручной вызов — для тестов, бенчмарков или редких сценариев (например, после массивной загрузки).

import "runtime/debug"
debug.FreeOSMemory()

Принудительно запускает GC и пытается вернуть память операционной системе. Без этого Go может удерживать освобождённую память “про запас” — для будущих аллокаций.

⚠️ Зачем нужно: В Kubernetes твой под показывает RSS=2GB, хотя живые объекты — 200MB. ОС не получает память обратно. FreeOSMemory() помогает в редких случаях (long-running batch jobs).

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc = %v MB\n", m.HeapAlloc/1024/1024) // живые объекты
fmt.Printf("HeapSys = %v MB\n", m.HeapSys/1024/1024) // выделено у ОС
fmt.Printf("HeapInuse = %v MB\n", m.HeapInuse/1024/1024) // используется
fmt.Printf("HeapIdle = %v MB\n", m.HeapIdle/1024/1024) // свободно, но не отдано ОС
fmt.Printf("HeapReleased = %v MB\n", m.HeapReleased/1024/1024) // отдано ОС
fmt.Printf("NumGC = %v\n", m.NumGC) // сколько раз GC бежал
fmt.Printf("PauseTotalNs = %v\n", m.PauseTotalNs) // суммарная пауза
fmt.Printf("Mallocs = %v\n", m.Mallocs) // всего аллокаций
fmt.Printf("Frees = %v\n", m.Frees) // всего освобождений

Это первое, что смотрят при подозрении на утечку памяти.

GOGC=100 (дефолт) — GC запустится, когда heap вырастет на 100% относительно “живого” после прошлого GC.

Примеры:

  • После GC живых объектов 100MB → следующий GC при heap=200MB.
  • GOGC=50 → следующий GC при heap=150MB (агрессивнее, больше CPU на GC, меньше памяти).
  • GOGC=200 → следующий GC при heap=300MB (реже, больше памяти, меньше CPU).
  • GOGC=off → GC отключён (только для бенчей, никогда в prod!).
debug.SetGCPercent(50) // программно
Окно терминала
GOGC=50 ./myapp

Очень важно для контейнеров (Kubernetes, Docker)!

Окно терминала
GOMEMLIMIT=512MiB ./myapp

Или программно:

debug.SetMemoryLimit(512 << 20) // 512 MB

Это soft limit — Go будет агрессивнее запускать GC при приближении к лимиту. Не гарантия, что не выйдешь за предел, но снижает риск OOM-kill.

⚠️ Без GOMEMLIMIT в k8s твой под может убиться OOMKilled, потому что Go не знает про cgroup-лимиты и тянет память, пока ОС не убьёт процесс.

Best practice: в k8s ставь GOMEMLIMIT чуть ниже limits.memory пода (например, 90%).

# k8s pod spec
env:
- name: GOMEMLIMIT
value: "450MiB"
resources:
limits:
memory: "512Mi"

Go использует concurrent tri-color mark and sweep GC. Объекты делятся на три цвета:

  • Белые — кандидаты на удаление (изначально все).
  • Серые — найдены, но их потомки ещё не проверены (worklist).
  • Чёрные — найдены и все их потомки тоже (живые).
Старт (после finding roots):
[ROOT]──→(A)──→(B)──→(C)
└──→(D)──→(E)
[F] (мусор, никем не достижим)
Цвета (начало mark phase):
Чёрный: ничего
Серый: A (root → A напрямую)
Белый: B, C, D, E, F
Шаг 1: обрабатываем A → серым становится B, A → чёрный
Чёрный: A
Серый: B
Белый: C, D, E, F
Шаг 2: обрабатываем B → серыми становятся C, D, B → чёрный
Чёрный: A, B
Серый: C, D
Белый: E, F
Шаг 3: обрабатываем C → C нет потомков, C → чёрный
Чёрный: A, B, C
Серый: D
Белый: E, F
Шаг 4: обрабатываем D → E серый, D → чёрный
Чёрный: A, B, C, D
Серый: E
Белый: F
Шаг 5: E без потомков, E → чёрный
Чёрный: A, B, C, D, E
Серый: ∅
Белый: F
Финал: F остался белым → удаляется в sweep phase.

Инвариант tri-color:

Чёрный объект не может иметь ссылку на белый напрямую (без серого посредника).

Если это правило нарушится во время concurrent GC (когда программа работает параллельно), GC может пропустить живой объект и удалить его. Чтобы этого не случилось — write barrier.

Когда программа делает a.field = b, и a чёрный, а b белый, write barrier “красит” b в серый (или ставит в worklist). Так инвариант сохраняется.

В Go 1.8+ используется hybrid write barrier (Dijkstra + Yuasa) — он работает быстро и снижает паузы.

// Это компилятор автоматически вставляет write barrier:
obj.field = newPtr
// Эквивалентно (упрощённо):
writeBarrier(obj, newPtr) // GC знает про изменение
obj.field = newPtr

Тебе не надо ничего делать — компилятор сам вставляет вызовы.

┌─────────────────────────────────────────────────────────────┐
│ GC cycle (упрощённо): │
│ │
│ 1. STW (sweep termination) ~10-50 µs │
│ - заканчиваем sweep предыдущего цикла │
│ │
│ 2. Mark phase (concurrent с программой) ~ms │
│ - находим roots (стек горутин, глобалы) │
│ - обходим граф объектов │
│ - write barrier защищает инвариант │
│ - программа работает! │
│ │
│ 3. STW (mark termination) ~10-100 µs │
│ - финализируем mark, проверяем worklist │
│ │
│ 4. Sweep phase (concurrent + lazy) │
│ - освобождаем белые объекты │
│ - lazy: при следующей аллокации │
│ │
└─────────────────────────────────────────────────────────────┘

Concurrent — большая часть работы происходит параллельно с твоей программой. STW-паузы — только в начале и конце mark phase, обычно < 1 ms.

  • Каждая горутина имеет свой стек.
  • Старт: 2 KB. Растёт по необходимости до GBs.
  • Не управляется GC! Очищается автоматически при возврате из функции.
  • Аллокация очень дешёвая: просто двигаем указатель.
func foo() int {
x := 42 // на стеке
return x + 1
}
  • Общая для всех горутин.
  • Управляется GC.
  • Аллокация дороже: нужен mutex, поиск свободного блока, write barrier.
func bar() *int {
x := 42
return &x // x "escapes" на heap
}

Здесь x не может жить на стеке bar — мы возвращаем указатель, который переживёт фрейм. Компилятор перенесёт x на heap.

Компилятор анализирует, “убегает” ли значение за пределы текущей функции:

Окно терминала
go build -gcflags="-m" main.go

Пример:

package main
func makePtr() *int {
x := 42
return &x // ./main.go:5:9: moved to heap: x
}
func notEscape() int {
x := 42
return x // на стеке, всё ок
}
func sliceEscape() []int {
s := make([]int, 1000) // ./main.go:13:11: make([]int, 1000) escapes to heap
return s
}
  1. Возврат указателя/слайса/мапы из функции.
  2. Передача в интерфейс. fmt.Println(x) — x может escape, так как Println принимает interface{}.
  3. Захват замыканием по указателю.
  4. Слишком большой объект — компилятор может решить не класть на стек.
  5. Размер неизвестен в compile-timemake([]int, n) где n runtime-переменная.
func intoInterface(x int) {
fmt.Println(x) // x escape (boxing в interface{})
}

⚠️ Подвох: даже fmt.Println(42) делает аллокацию из-за бокса int в interface{}.

Раньше (до Go 1.4): split stacks — стек состоял из связанных кусков. При нехватке выделялся новый, при возврате — освобождался. Это было медленно из-за “hot split” проблемы (тонкая граница между фреймами, постоянное переключение).

С Go 1.4: contiguous stacks — один непрерывный кусок. При нехватке:

  1. Аллоцируется новый стек в 2 раза больше.
  2. Содержимое копируется.
  3. Все указатели в стеке обновляются (это возможно, потому что GC знает, где указатели).

Старт всегда 2 KB. Может вырасти до 1 GB (по умолчанию).

debug.SetMaxStack(1 << 30) // 1 GB лимит на стек горутины

big := make([]byte, 10_000_000)
small := big[:10] // small держит ссылку на весь массив big!
big = nil // не освободится, пока small жив

Решение: скопировать в новый слайс:

small := make([]byte, 10)
copy(small, big[:10])
big = nil // теперь освободится
func leak() {
ch := make(chan int) // unbuffered
go func() {
val := compute()
ch <- val // блокируется навсегда, если никто не читает
}()
// забыли прочитать из ch → горутина висит, держит память
}

Решение: context с таймаутом, buffered channel, или select с default.

m := make(map[int]int)
for i := 0; i < 1_000_000; i++ {
m[i] = i
}
for k := range m {
delete(m, k)
}
// m всё ещё держит выделенные buckets!
m = nil // или make новый map

Делец только помечает слот как пустой. Внутренние buckets не уменьшаются.

⚠️ 3.4 Финализаторы — не используй для критичных ресурсов

Заголовок раздела «⚠️ 3.4 Финализаторы — не используй для критичных ресурсов»
runtime.SetFinalizer(obj, func(o *MyObj) {
o.Close()
})

Финализатор может никогда не вызваться. Не закрывай в нём файлы или соединения — используй defer Close().

big := strings.Repeat("a", 1_000_000)
sub := big[:10] // sub держит весь big!

Аналогично слайсам. Решение: sub := string([]byte(big[:10])) или strings.Clone(big[:10]) (Go 1.18+).

Без automaxprocs или Go 1.25+ runtime может видеть все CPU хоста, а не лимит cgroup. Это приводит к чрезмерной параллельности и контеншну.

import _ "go.uber.org/automaxprocs"

⚠️ 3.7 sync.Pool — pool может очиститься в любой момент

Заголовок раздела «⚠️ 3.7 sync.Pool — pool может очиститься в любой момент»
var pool = sync.Pool{
New: func() any { return new(MyObj) },
}
obj := pool.Get().(*MyObj)
// ... use ...
pool.Put(obj)

Pool не гарантирует, что объект ты получишь обратно. После каждого GC pool очищается (с Go 1.13 — частично, “victim cache” живёт один GC цикл). Не клади туда то, что должно жить долго.

for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // Go 1.22+: i свой; раньше — общая переменная!
}()
}

В Go 1.22 семантика переменных цикла изменена: каждая итерация имеет свою i. До 1.22 — общая, что приводило к классическому багу.

Если ты создаёшь миллионы коротко-живущих объектов в секунду, GC работает почаще. CPU съедается GC. Решение: sync.Pool, переиспользование буферов, меньше указателей.

После пика памяти RSS контейнера может не уменьшиться, хотя HeapAlloc упал. Go удерживает память “про запас”. В k8s это пугает мониторинг. Помогает GOMEMLIMIT + периодический debug.FreeOSMemory() (но осторожно — стоит CPU).


main_test.go
package main
import "testing"
func BenchmarkAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]byte, 1024)
_ = s
}
}
func BenchmarkNoAlloc(b *testing.B) {
buf := make([]byte, 1024)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buf
}
}
Окно терминала
go test -bench=. -benchmem

Вывод:

BenchmarkAlloc-8 3000000 400 ns/op 1024 B/op 1 allocs/op
BenchmarkNoAlloc-8 1000000000 0.3 ns/op 0 B/op 0 allocs/op
  • B/op — байт на операцию.
  • allocs/op — аллокаций на операцию.

Цель: уменьшить allocs/op до 0 в горячем пути.

import "sync"
var bufPool = sync.Pool{
New: func() any {
return make([]byte, 0, 1024)
},
}
func processRequest(data []byte) {
buf := bufPool.Get().([]byte)
buf = buf[:0] // сброс длины, capacity сохранён
defer bufPool.Put(buf)
// ... work with buf ...
buf = append(buf, data...)
// ...
}

Use case: хендлеры HTTP, JSON-encoding, парсеры. Тысячи аллокаций → десятки.

⚠️ Гочи:

  • Не клади указатели, которые могут escape’ить дальше.
  • Pool может вернуть объект в любом состоянии — не забывай сбрасывать (buf[:0]).
  • Не подходит для больших объектов с длинной жизнью.
import _ "net/http/pprof"
import "net/http"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... твоя программа ...
}
Окно терминала
# Снять heap profile
go tool pprof http://localhost:6060/debug/pprof/heap
# В интерактиве:
(pprof) top10
(pprof) list MyFunc
(pprof) web # граф в браузере

Или прямо в бенчмарке:

Окно терминала
go test -bench=. -memprofile=mem.out
go tool pprof mem.out
  • inuse_space (дефолт) — память живых объектов.
  • inuse_objects — кол-во живых объектов.
  • alloc_space — всего выделено за время работы.
  • alloc_objects — всего объектов выделено.

alloc_* хорош для понимания “горячих” аллокаций, inuse_* — для утечек.

Сценарии:

СценарийРекомендация
Latency-критичный APIGOGC=100, GOMEMLIMIT=80% от limits
Batch обработкаGOGC=200-500 (меньше GC, больше памяти)
Жёсткий лимит памяти в k8sGOMEMLIMIT + GOGC=100
Low memory edge deviceGOGC=50, GOMEMLIMIT=...

Для джуна важно понимать смысл переменных, не запоминать конкретные значения.

  1. Preallocate slices: make([]int, 0, n) вместо var s []int.
  2. string builder вместо +: strings.Builder для конкатенации.
  3. bytes.Buffer / bufio.Writer для I/O.
  4. Не передавай interface{} в hot path.
  5. Reuse через sync.Pool для коротко-живущих больших объектов.
  6. Структуры по значению vs указателю — иногда значение лучше (меньше указателей → меньше работы GC).
  7. strings.Builder vs []byte — Builder использует unsafe чтобы избежать копии.

1. Что такое GC и зачем он нужен? Garbage Collector — автоматическое управление памятью. Освобождает объекты, на которые нет ссылок. Избавляет от багов ручного управления (leak, use-after-free), но добавляет паузы и оверхед.

2. Какой алгоритм GC в Go? Concurrent tri-color mark and sweep с hybrid write barrier (Dijkstra + Yuasa). Concurrent — значит большая часть работы параллельно с программой.

3. Что такое STW (stop-the-world)? Пауза, когда GC останавливает все горутины. В Go только короткие STW в начале и конце mark phase — обычно <1 ms.

4. Расскажи про tri-color. Объекты — белые (мусор), серые (найдены, потомки не проверены), чёрные (живые). Mark phase обходит граф, перекрашивая. Белые в конце — удаляются.

5. Зачем write barrier? Чтобы инвариант “чёрный → белый напрямую” не нарушался при concurrent работе программы. Когда программа меняет ссылку, write barrier сообщает GC.

6. Stack vs heap — где аллоцируется? Решает компилятор через escape analysis. Если значение не “убегает” за пределы функции — на стек (дёшево). Если убегает — на heap (управляется GC).

7. Что такое escape analysis? Compile-time анализ: куда положить переменную — стек или heap. Запустить: go build -gcflags="-m".

8. Что такое GOGC? Переменная, контролирующая агрессивность GC. GOGC=100 (дефолт) — следующий GC при удвоении heap. GOGC=off — отключить (только для тестов).

9. Что такое GOMEMLIMIT? Soft memory limit (Go 1.19+). Go будет агрессивнее запускать GC, приближаясь к лимиту. Критично в Kubernetes — без него возможен OOMKilled.

10. Какой стартовый размер стека горутины? 2 KB. Растёт до 1 GB (по умолчанию) через копирование (contiguous stacks с Go 1.4+).

11. Что такое split stacks vs contiguous stacks? Split (до Go 1.4) — связанный список кусков стека, страдал от hot-split (медленно при пересечении границы). Contiguous (Go 1.4+) — один непрерывный кусок, при нехватке аллоцируется новый в 2x и копируется содержимое.

12. Почему fmt.Println(42) делает аллокацию? Println принимает interface{}. int упаковывается (boxing) в interface, что требует heap-аллокации.

13. Утечка памяти в Go возможна? Не в смысле C (забыл free). Но возможна через:

  • висящие горутины,
  • глобальные мапы/слайсы, куда добавляют и не удаляют,
  • ссылки на большой массив через slice[:10],
  • финализаторы, которые не запустились.

14. Что делает runtime.GC()? Принудительно запускает GC, блокируется до завершения. Почти не нужен в проде.

15. Что такое sync.Pool? Пул объектов для переиспользования. Уменьшает аллокации. Объекты могут быть удалены в любой момент (GC очищает pool). Не для критичных ресурсов!

16. Когда sync.Pool НЕ помогает?

  • Объекты долго живут (pool бесполезен).
  • Объекты слишком маленькие (overhead Pool > выгода).
  • Объекты разных размеров — Pool не различает.

17. Что такое hybrid write barrier? Комбинация Dijkstra (защита от чёрный → белый) и Yuasa (защита удалением ссылок). Позволяет не сканировать стеки повторно, уменьшая STW.

18. Как посмотреть escape analysis? go build -gcflags="-m" main.go. Покажет, что moved to heap.

19. Что показывает HeapAlloc vs HeapSys? HeapAlloc — живые объекты (то, что не освобождено). HeapSys — сколько Go запросил у ОС. HeapSys всегда >= HeapAlloc.

20. Почему RSS контейнера не падает после GC? Go удерживает память “про запас” для будущих аллокаций. debug.FreeOSMemory() или GOMEMLIMIT помогают вернуть память ОС.

21. Сколько примерно GC-пауз в типичном API? Обычно <1 ms. В тяжёлых сценариях (миллионы объектов) — единицы ms. STW редко превышает 10 ms.

22. Что произойдёт, если включить GOGC=off в production? GC не будет запускаться никогда → память будет расти бесконечно → OOM.

23. Расскажи про утечку через map. delete(m, k) помечает слот пустым, но buckets не уменьшаются. Чтобы освободить — m = nil или m = make(map[K]V).

24. Как уменьшить аллокации?

  • make([]T, 0, n) — preallocate capacity.
  • sync.Pool для reuse.
  • strings.Builder вместо +.
  • Избегать interface{} в hot path.
  • Передача по значению, если структура маленькая.

25. Можно ли отключить GC для performance? Можно (GOGC=off), но это диверсия. Лучше: уменьшить аллокации, использовать sync.Pool, тюнить GOGC.


package main
import "time"
type Server struct {
handlers map[string]func()
}
func (s *Server) AddHandler(name string, fn func()) {
s.handlers[name] = fn
}
func main() {
s := &Server{handlers: make(map[string]func())}
for i := 0; i < 1_000_000; i++ {
bigData := make([]byte, 10_000)
s.AddHandler("h", func() {
_ = bigData // замыкание держит bigData!
})
}
time.Sleep(10 * time.Second)
}

Что не так? Каждая итерация переписывает handler “h”, но 999999 замыканий с bigData удерживают данные через старые ссылки? Нет, в map один ключ. Но bigData каждой итерации сохраняется, пока handler “h” не перезапишется. Глянь внимательнее.

На самом деле в этом коде — map содержит один handler в конце, но в процессе цикла все bigData могут escape’ить на heap. Реальная проблема — что замыкания тяжёлые. Перепиши, чтобы не было утечки больших данных.

Напиши два варианта функции, конкатенирующей слайс строк, и сравни:

// Вариант 1: через +=
func ConcatPlus(parts []string) string {
s := ""
for _, p := range parts {
s += p
}
return s
}
// Вариант 2: через strings.Builder
func ConcatBuilder(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}

Запусти бенчмарк, посмотри B/op и allocs/op. Объясни разницу.

Напиши HTTP-хендлер, который читает JSON из body и эхует обратно. Сравни две версии:

  1. Без pool — каждый раз make([]byte, 4096).
  2. С sync.Pool буферов.

Прогони wrk или ab, посмотри разницу в allocs/req через pprof.

Запусти простой сервер с net/http/pprof. Создавай нагрузку. Сними heap-профиль:

Окно терминала
go tool pprof http://localhost:6060/debug/pprof/heap

Найди топ-3 функции по inuse_space.

Возьми любой свой код и запусти go build -gcflags="-m". Найди 3 строки, где значение moved to heap, и попробуй переписать, чтобы оно осталось на стеке.


  1. Go Memory ManagementGo Blog: Getting to Go — keynote Rick Hudson про GC.
  2. A Guide to the Go Garbage Collector — официальная документация: https://go.dev/doc/gc-guide
  3. Достаточно ли вы знаете про GOMEMLIMIThttps://weaviate.io/blog/gomemlimit-a-game-changer-for-high-memory-applications
  4. pprof tutorialhttps://github.com/google/pprof/blob/main/doc/README.md
  5. Effective Gohttps://go.dev/doc/effective_go (раздел про concurrency и память)
  6. Книга: Cox-Buday — Concurrency in Go (главы про runtime).
  7. Книга: 100 Go Mistakes and How to Avoid Them — Teiva Harsanyi (главы 12-13 про runtime и memory).

Чек-лист джуна:

  • Понимаю разницу stack vs heap.
  • Знаю, как посмотреть escape analysis.
  • Использую -benchmem в бенчмарках.
  • Знаю про GOGC и GOMEMLIMIT.
  • Умею снять heap-профиль через pprof.
  • Знаю про утечки слайсов/строк через подстроки.
  • Понимаю, что sync.Pool не гарантирует возврат объекта.
  • Знаю про concurrent tri-color mark and sweep на уровне идеи.