Go использует конкурентный сборщик мусора (GC) -- одно из ключевых инженерных достижений runtime. Понимание его работы позволяет писать приложения с предсказуемой латентностью и эффективным использованием памяти.
Обзор GC
Go GC -- это конкурентный, трёхцветный mark-and-sweep сборщик мусора:
- Конкурентный: GC работает параллельно с вашей программой (мутатором)
- Трёхцветный: объекты классифицируются на белые, серые и чёрные
- Mark-and-sweep: фаза маркировки находит живые объекты, фаза очистки освобождает мёртвые
- Non-generational: нет поколений (в отличие от Java/C#/.NET)
- Non-compacting: объекты не перемещаются в памяти
Цели GC Go:
- Низкая латентность (паузы < 1ms) -- приоритет
- Высокая пропускная способность -- вторичная цель
- Простота -- минимум ручек настройки
Фазы GC
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────┐
───────>│ Mark Setup │────>│ Concurrent Mark │────>│ Mark Termination │────>│ Sweep │──>
mutator │ (STW ~10μs) │ │ (concurrent) │ │ (STW ~10μs) │ │(concur.)│
└─────────────┘ └──────────────────┘ └──────────────────┘ └─────────┘
1. Mark Setup (STW -- Stop The World)
- Включает write barrier (барьер записи)
- Все горутины останавливаются (STW пауза ~10-50 мкс)
- Подготавливает корни маркировки (goroutine stacks, globals)
2. Concurrent Mark (конкурентная фаза)
- GC worker горутины маркируют живые объекты
- Приложение продолжает работать параллельно
- Write barrier отслеживает изменения указателей
3. Mark Termination (STW)
- Вторая STW пауза (~10-50 мкс)
- Завершает маркировку, обрабатывает оставшуюся работу
- Выключает write barrier
- Вычисляет триггер для следующего GC цикла
4. Sweep (конкурентная очистка)
- Освобождает белые (неотмеченные) объекты
- Работает конкурентно с приложением
- Память возвращается аллокатору, не ОС (обычно)
Трёхцветный алгоритм
Белый (white): Потенциально мусор. Если после маркировки объект белый -- он мёртв.
Серый (grey): Обнаружен как живой, но его ссылки ещё не проверены.
Чёрный (black): Живой, все его ссылки проверены.
Алгоритм:
- Все объекты начинают белыми
- Корни (стеки, глобалы) помечаются серыми
- Берётся серый объект, его ссылки помечаются серыми, сам становится чёрным
- Повторять пока серых не останется
- Все белые объекты -- мусор
Инвариант: чёрный объект никогда не указывает на белый
Write barrier обеспечивает этот инвариант: при записи указателя в чёрный объект, целевой объект помечается серым.
Write Barrier (барьер записи)
Write barrier -- это код, вставленный компилятором перед каждой записью указателя. Go использует hybrid write barrier (Go 1.8+):
Shade(*slot) // Mark old referent grey
Shade(ptr) // Mark new referent grey
*slot = ptr // Perform the actual write
Гибридный барьер позволяет:
- Не перескканировать стеки горутин (stack re-scanning eliminated)
- Меньшие STW паузы
Write barrier работает только во время GC mark phase. Вне GC -- нулевой overhead.
GOGC -- управление частотой GC
GOGC (по умолчанию 100) определяет, когда запускать следующий цикл GC:
Next GC trigger = Live heap after last GC * (1 + GOGC/100)
Примеры:
GOGC=100(default): GC при удвоении heap (live * 2)GOGC=200: GC при утроении heap (live * 3) -- реже, больше памятиGOGC=50: GC при 1.5x heap -- чаще, меньше памятиGOGC=off: GC отключён полностью
# Set via environment variable
GOGC=200 ./myapp
# Set programmatically
import "runtime/debug"
debug.SetGCPercent(200)
Trade-off GOGC
| GOGC | Частота GC | Использование памяти | CPU на GC |
|---|---|---|---|
| 50 | Часто | Низкое | Высокое |
| 100 | Средне | Среднее | Среднее |
| 200 | Редко | Высокое | Низкое |
| off | Никогда | Растёт | Нулевое |
GOMEMLIMIT (Go 1.19+): мягкий лимит памяти
GOMEMLIMIT устанавливает soft memory limit для Go runtime:
# Set memory limit to 1 GB
GOMEMLIMIT=1GiB ./myapp
# Other formats
GOMEMLIMIT=512MiB ./myapp
GOMEMLIMIT=4294967296 ./myapp # bytes
import "runtime/debug"
// Set programmatically
debug.SetMemoryLimit(1 << 30) // 1 GiB
Как работает GOMEMLIMIT
Когда heap приближается к лимиту:
- GC запускается чаще (игнорирует GOGC)
- Агрессивнее возвращает память ОС
- Если лимит превышен -- GC продолжает работать, но может замедлить приложение
Не является жёстким лимитом! Приложение не будет OOM-killed из-за GOMEMLIMIT. Это подсказка для GC.
Рекомендуемая конфигурация
# For container with 4GB memory limit:
# Leave ~10-20% for non-Go memory (goroutine stacks, cgo, OS cache)
GOMEMLIMIT=3200MiB
# Aggressive: disable GOGC, rely only on GOMEMLIMIT
GOGC=off GOMEMLIMIT=3200MiB ./myapp
# GC runs only when approaching memory limit -- maximum throughput
Паттерн Go 1.19+:
GOGC=off+GOMEMLIMITдаёт максимальную пропускную способность в контейнерах с известным лимитом памяти.
runtime.GC() и runtime.ReadMemStats
import "runtime"
// Force garbage collection
runtime.GC()
// Read memory statistics
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
fmt.Printf("Alloc: %d MiB\n", ms.Alloc/1024/1024) // Current heap allocation
fmt.Printf("TotalAlloc: %d MiB\n", ms.TotalAlloc/1024/1024) // Cumulative allocation
fmt.Printf("Sys: %d MiB\n", ms.Sys/1024/1024) // Memory obtained from OS
fmt.Printf("NumGC: %d\n", ms.NumGC) // Number of GC cycles
fmt.Printf("GCPause: %v\n", time.Duration(ms.PauseNs[(ms.NumGC+255)%256])) // Last GC pause
fmt.Printf("HeapObjects: %d\n", ms.HeapObjects) // Number of heap objects
fmt.Printf("HeapInuse: %d MiB\n", ms.HeapInuse/1024/1024) // Heap in use
fmt.Printf("HeapIdle: %d MiB\n", ms.HeapIdle/1024/1024) // Heap idle (returned to OS or cached)
fmt.Printf("StackInuse: %d MiB\n", ms.StackInuse/1024/1024) // Stack memory in use
GC tracing
# Detailed GC logging
GODEBUG=gctrace=1 ./myapp
# Output:
# gc 1 @0.012s 2%: 0.024+0.94+0.017 ms clock, 0.096+0.78/0.80/0+0.070 ms cpu, 4->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 4 P
# 2% = percentage of CPU used by GC
# 4->4->1 MB = heap before -> heap after → live data
# 4 MB goal = target heap size for next GC
GC Pacing Algorithm
GC pacing определяет, когда запускать GC и сколько ресурсов выделить:
- Trigger: запуск GC при достижении целевого размера heap
- Assist: если аллокация обгоняет маркировку, горутины помогают GC (GC assist)
- Background: 25% CPU выделяется на GC worker горутины
- Feedback: pacing корректируется на основе предыдущих циклов
Heap ┃ ╱
Growth ┃ ╱──── Allocation rate
┃ ╱
┃ GC ──╱
┃ trigger╱
┃ ╱
┃ ╱
┃ ╱
Live data ───┃╱──────────────────────────
┗━━━━━━━━━━━━━━━━━━━━━━━━━━ Time
Управление стеком
Contiguous Stacks (с Go 1.4)
Каждая горутина начинает с маленького стека (2-8 КБ). При необходимости стек удваивается и содержимое копируется:
Initial: [2KB stack]
After grow: [4KB stack] ← data copied
After grow: [8KB stack] ← data copied
After grow: [16KB stack] ← data copied
...
Копирование стека возможно потому, что:
- Go обновляет все указатели на стек (runtime знает их расположение)
- Unsafe.Pointer на стековые данные может сломаться!
Сжатие стеков
GC также сжимает стеки, если горутина использует < 1/4 выделенного стека. Это предотвращает утечку стековой памяти.
Escape Analysis (анализ убегания)
Компилятор Go определяет, должна ли переменная располагаться на стеке или на heap:
# Show escape analysis decisions
go build -gcflags="-m" ./...
go build -gcflags="-m -m" ./... # More detailed
Что "убегает" на heap
// 1. Pointer escapes via return
func newUser() *User {
u := User{Name: "Alice"} // Escapes to heap (returned pointer)
return &u
}
// 2. Interface conversion (sometimes)
func printValue(v any) {
fmt.Println(v) // v may escape
}
func main() {
x := 42
printValue(x) // x escapes to heap (boxed into interface)
}
// 3. Closure captures variable
func counter() func() int {
count := 0 // Escapes (captured by closure)
return func() int {
count++
return count
}
}
// 4. Slice too large for stack
func bigSlice() {
s := make([]byte, 64*1024) // May escape (too big for stack)
_ = s
}
// 5. Variable size (unknown at compile time)
func dynamicSlice(n int) {
s := make([]byte, n) // Escapes (size unknown at compile time)
_ = s
}
Что остаётся на стеке
// 1. Local variables that don't escape
func add(a, b int) int {
result := a + b // Stays on stack
return result // Value, not pointer
}
// 2. Small fixed-size slices
func process() {
buf := make([]byte, 256) // May stay on stack (small, fixed size)
_ = buf
}
// 3. Struct without escaping pointers
func createUser() User { // Returns value, not pointer
return User{Name: "Alice"}
}
Green Tea GC (Go 1.26)
Примечание: Green Tea GC -- это экспериментальная оптимизация, анонсированная для Go 1.26.
Ключевые улучшения:
- Reduced tail latency: более предсказуемые паузы GC
- Arena-like optimization: переиспользование памяти для короткоживущих объектов
- Better pacing: улучшенный алгоритм определения момента запуска GC
- Lower CPU overhead: менее агрессивное использование CPU для маркировки
Green Tea нацелен на:
- Уменьшение p99 латентности GC пауз
- Более эффективное управление памятью для request-scoped аллокаций
- Сохранение обратной совместимости
Аллокатор памяти Go
Go использует иерархический аллокатор, вдохновлённый TCMalloc (Thread-Caching Malloc):
┌─────────────────────────────────────────────────┐
│ mheap │
│ Global heap. Manages spans and large allocations│
├─────────────────────────────────────────────────┤
│ mcentral (per size class) │
│ Central cache. Provides spans to mcache │
├──────────┬──────────┬──────────┬────────────────┤
│ mcache P0│ mcache P1│ mcache P2│ mcache P3 │
│ Per-P │ Per-P │ Per-P │ Per-P │
│ local │ local │ local │ local │
│ cache │ cache │ cache │ cache │
└──────────┴──────────┴──────────┴────────────────┘
mcache (per-P local cache)
- Один mcache на каждый логический процессор (P)
- Аллокации < 32KB берутся из mcache без блокировок
- Содержит spans для каждого size class (68 size classes)
mcentral (central cache)
- Один mcentral на каждый size class
- Когда mcache исчерпан, берёт span из mcentral
- Требует блокировку (но по одному size class)
mheap (global heap)
- Управляет виртуальной памятью
- Получает память от ОС через
mmap - Аллокации > 32KB идут напрямую через mheap
Spans (mspan)
Span -- это непрерывный блок страниц (8KB каждая), разделённый на объекты одного size class:
Size classes (примеры):
8 bytes, 16 bytes, 24 bytes, 32 bytes, 48 bytes, 64 bytes,
80 bytes, 96 bytes, 112 bytes, 128 bytes, ..., 32768 bytes
Span for 32-byte objects (1 page = 8KB):
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬─...
│ obj1 │ obj2 │ obj3 │ obj4 │ obj5 │ obj6 │ obj7 │ obj8 │
│ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴─...
Настройка GC для разных нагрузок
Web-сервис (low latency)
# Default GOGC is usually fine
# Use GOMEMLIMIT to prevent OOM in containers
GOGC=100 GOMEMLIMIT=3GiB ./webserver
Batch processing (high throughput)
# Disable GOGC-based triggering, rely on memory limit
# Less GC = more throughput
GOGC=off GOMEMLIMIT=8GiB ./batchprocessor
Memory-constrained environment
# Lower GOGC = more frequent GC = less memory
GOGC=50 GOMEMLIMIT=512MiB ./microservice
Real-time-ish application
# GC tuning + manual control
GOGC=100 GOMEMLIMIT=2GiB ./realtime
// Pre-allocate and reuse with sync.Pool
var bufPool = sync.Pool{
New: func() any {
buf := make([]byte, 0, 4096)
return &buf
},
}
// Reduce allocations in hot path
func handleRequest(data []byte) []byte {
bufPtr := bufPool.Get().(*[]byte)
buf := (*bufPtr)[:0]
defer func() {
*bufPtr = buf
bufPool.Put(bufPtr)
}()
// Use buf instead of allocating...
buf = append(buf, data...)
return process(buf)
}
Профилирование GC
import (
"runtime"
"runtime/debug"
)
// Print GC stats
func printGCStats() {
var stats debug.GCStats
debug.ReadGCStats(&stats)
fmt.Printf("LastGC: %s\n", stats.LastGC)
fmt.Printf("NumGC: %d\n", stats.NumGC)
fmt.Printf("PauseTotal: %v\n", stats.PauseTotal)
if len(stats.Pause) > 0 {
fmt.Printf("LastPause: %v\n", stats.Pause[0])
}
// Recent pauses
for i, p := range stats.Pause {
if i >= 5 {
break
}
fmt.Printf(" Pause[%d]: %v\n", i, p)
}
}
// Monitor GC in production
func monitorGC(ctx context.Context) {
var lastNumGC uint32
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
if ms.NumGC > lastNumGC {
fmt.Printf("GC cycles: %d, Heap: %d MiB, Pause: %v\n",
ms.NumGC-lastNumGC,
ms.HeapAlloc/1024/1024,
time.Duration(ms.PauseNs[(ms.NumGC+255)%256]),
)
lastNumGC = ms.NumGC
}
case <-ctx.Done():
return
}
}
}
# Enable GC trace
GODEBUG=gctrace=1 ./myapp
# Memory allocator trace
GODEBUG=allocfreetrace=1 ./myapp
# Schedule trace (shows GC events)
GODEBUG=schedtrace=1000 ./myapp