Справочник

Рантайм — справочник

Планировщик G-M-P, стеки горутин, аллокатор, сборка мусора, GOGC и GOMEMLIMIT, escape analysis, pprof и диагностика.

Все справочники

Собрано из A Guide to the Go Garbage Collector, Diagnostics, Profiling Go Programs, комментариев в исходниках рантайма Go 1.27 и release notes.

1. Бинарник и рантайм

go build собирает исполняемый файл, в который уже включён рантайм: виртуальной машины и JIT нет. Рантайм даёт планировщик горутин, сборщик мусора, аллокатор, реализацию каналов и мап, отражение, паники и трассировки.

Без cgo бинарник статический: его можно перенести на машину без Go и без системных библиотек. С cgo появляется динамическая зависимость от libc. Кросс-компиляция задаётся GOOS и GOARCH, например GOOS=linux GOARCH=amd64 go build.

Что входит в бинарникЗачем
код программыпакеты и main
рантаймпланировщик, GC, аллокатор, каналы, отражение
таблицы типов и метаданныеинтерфейсы, отражение, трассировки
отладочная информацияимена функций и строки; убирается -ldflags="-s -w"

Пустая программа на Go всё равно весит мегабайты: рантайм и метаданные входят в каждый бинарник. CGO_ENABLED=0 фиксирует статическую сборку.

2. Планировщик: G, M, P

Планировщик распределяет готовые к выполнению горутины по рабочим потокам. Три сущности: G — горутина, M — рабочий поток ОС, P — ресурс, необходимый для выполнения кода Go. Поток M выполняет код Go, только пока владеет P; в системном вызове он может отдать P другому потоку.

Дословно из runtime/proc.go: «G - goroutine. M - worker thread, or machine. P - processor, a resource that is required to execute Go code. M must have an associated P to execute Go code, however it can be blocked or in a syscall w/o an associated P.»

МеханизмЧто делает
локальная очередь Pготовые горутины, которые создал или разбудил этот P
глобальная очередьобщая очередь, когда локальная переполнена
work stealingпростаивающий P забирает часть работы у другого
sysmonфоновый монитор: таймеры, вытеснение, возврат занятых P
netpollerгорутины, ждущие сеть, паркуются и будятся по готовности
асинхронное вытеснениедолгая горутина прерывается сигналом, а не только в точках вызова

GOMAXPROCS задаёт число P, то есть сколько кода Go может выполняться одновременно. С Go 1.25 на Linux учитывается лимит CPU bandwidth у cgroup, и значение периодически пересчитывается; ручная установка это отключает.

fmt.Println(runtime.NumCPU())        // логические CPU
fmt.Println(runtime.GOMAXPROCS(0))   // текущее значение, без изменения
runtime.Gosched()                    // уступить процессор другим горутинам

Горутина, заблокированная на канале, мьютексе или сети, не занимает поток ОС: она паркуется, а M берёт другую работу. Число потоков ОС может превышать GOMAXPROCS, например из-за системных вызовов и cgo.

3. Стеки горутин

Горутина начинает с маленького стека: stackMin = 2048 байт в исходниках рантайма. Стек растёт по мере надобности: рантайм выделяет новый участок, копирует туда содержимое и правит указатели. Поэтому адрес локальной переменной не стабилен между вызовами, а указатели на стек не передаются наружу.

ПараметрЗначение
начальный стек2 КиБ
максимальный стек, 64-бит1 ГБ
максимальный стек, 32-бит250 МБ
изменение лимитаdebug.SetMaxStack
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, false) // стек текущей горутины
fmt.Println(string(buf[:n]))

n = runtime.Stack(buf, true)   // стеки всех горутин

Глубокую рекурсию в Go ограничивает не размер стека по умолчанию, а лимит в 1 ГБ: превышение даёт фатальную ошибку goroutine stack exceeds 1000000000-byte limit.

4. Аллокатор памяти

Аллокатор работает страницами по 8192 байта. Маленькие объекты (до 32 КиБ) округляются до одного из примерно 70 классов размеров; каждая страница делится на объекты одного класса и управляется битовой картой свободных мест.

СтруктураРоль
mspanнепрерывный участок страниц, нарезанный на объекты одного класса
mcacheкэш mspan у каждого P; мелкие аллокации идут без блокировки
mcentralсписки mspan нужного класса с свободными местами
mheapкуча: страницы и крупные объекты

Мелкий объект берётся из mcache текущего P. Если в mspan нет свободных мест, P получает новый mspan у mcentral; если и там пусто — у mheap; если куча пуста, рантайм запрашивает у ОС участок не меньше 1 МиБ. Крупные объекты идут напрямую в mheap, минуя кэши. Объекты без указателей и меньше 16 байт обслуживает tiny-аллокатор: несколько мелких значений пакуются в один блок.

С Go 1.27 компилятор генерирует вызовы специализированных по размеру аллокаторов: мелкие выделения (меньше 80 байт) ускорились до 30%, бинарник вырос примерно на 60 КиБ.

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Println(m.HeapAlloc, m.HeapSys, m.NumGC)

5. Сборка мусора

GC в Go — конкурентная mark-sweep сборка с write barrier. Она не перемещает объекты и не делит их на поколения; аллокация идёт через раздельные по P кэши.

Дословно из runtime/mgc.go: «It is a concurrent mark and sweep that uses a write barrier. It is non-generational and non-compacting. Allocation is done using size segregated per P allocation caches.»

ФазаЧто происходитОстанавливает мир
sweep terminationзавершается разметка предыдущего цикла, включается write barrierда, коротко
markобход достижимых объектов параллельно с программойнет
mark terminationдоразметка корней, выключение write barrierда, коротко
sweepосвобождение неотмеченных объектов, часто лениво при аллокациинет

Разметка трёхцветная: белые объекты ещё не встречены, серые поставлены в очередь, чёрные просканированы. Write barrier при записи указателя в чёрный объект «затеняет» новую цель, чтобы чёрный объект не начал ссылаться на белый и тот не был ошибочно освобождён.

Корни — стеки горутин, глобальные переменные и регистры. Пока цикл разметки идёт, горутина, которая много аллоцирует, помогает GC: это называется GC assist. Стоимость сборки — процессорное время и задержки, а не только паузы.

6. GOGC и GOMEMLIMIT

GOGC задаёт целевой размер кучи после цикла. При значении 100 цель — удвоить живую кучу с корнями; чем больше GOGC, тем реже сборки и тем больше памяти. Формула из руководства:

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

Пример: живая куча 8 МиБ, стеки 1 МиБ, глобальные указатели 1 МиБ. При GOGC=100 до следующей сборки можно выделить ещё 10 МиБ; при 50 — 5 МиБ; при 200 — 20 МиБ. Удвоение GOGC удваивает память и примерно вдвое снижает процессорную стоимость GC.

GOMEMLIMIT (Go 1.19) задаёт мягкий предел памяти рантайма: Sys - HeapReleased, или /memory/classes/total:bytes - /memory/classes/heap/released:bytes в терминах runtime/metrics. Это не жёсткий лимит: рантайм стремится уложиться, но при нехватке памяти программа всё равно может получить OOM.

НастройкаПеременнаяAPI
цель кучиGOGCdebug.SetGCPercent
отключить GCGOGC=offdebug.SetGCPercent(-1)
мягкий предел памятиGOMEMLIMITdebug.SetMemoryLimit
принудительный цикл—runtime.GC
вернуть память ОС—debug.FreeOSMemory
import "runtime/debug"

debug.SetGCPercent(200)                     // реже сборки, больше памяти
debug.SetMemoryLimit(512 << 20)              // мягкий предел 512 МиБ
debug.SetGCPercent(-1)                      // GC выключен

// в тестах и на старте удобно смотреть циклы:
// GODEBUG=gctrace=1 ./app

7. Escape analysis

Компилятор решает, где жить значению: на стеке или в куче. Если значение не выходит за пределы функции и его адрес не сохраняется, оно остаётся на стеке. Если «убегает» — компилятор выделяет его в куче, и дальше им занимается GC.

Проверить решение компилятора: go build -gcflags=-m ./.... Флаг -m -m печатает больше причин. Строка escapes to heap означает, что значение ушло в кучу.

type Point struct{ X, Y int }

func NewPoint() *Point {
    return &Point{} // escapes to heap: указатель возвращается наружу
}

func Sum() int {
    p := Point{X: 1, Y: 2} // не escapes: p не покидает функцию
    return p.X + p.Y
}
ПричинаПример
возврат указателяreturn &v
упаковка в интерфейсvar anyV any = v
захват замыканием, которое живёт дольшеfunc() { use(v) }, сохранённая в поле
отправка в каналch <- v
запись в глобальную переменнуюglobal = &v
слишком большой объектразмер известен, но превышает лимит стека
неизвестный размерmake([]T, n) с переменным n

Escape analysis — оптимизация, а не контракт: полагаться на конкретное решение компилятора нельзя. Если аллокаций слишком много, их ищут профилем кучи, а не догадками.

8. Профилирование

runtime/pprof отдаёт данные в формате, который понимает go tool pprof. Профиль можно снять в тесте, включить HTTP-эндпоинтами или собрать вручную.

ПрофильЧто показываетКак включить
cpuгде программа тратит процессорное времяgo test -cpuprofile, pprof.StartCPUProfile
heapвыделения и текущая памятьgo test -memprofile, pprof.WriteHeapProfile
goroutineстеки всех текущих горутинвсегда доступен
threadcreateсоздание потоков ОСвсегда доступен
blockблокировки на примитивах синхронизацииruntime.SetBlockProfileRate
mutexконкуренция за мьютексыruntime.SetMutexProfileFraction
goroutineleakгорутины, заблокированные навсегдаобщедоступен с Go 1.27
import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // ...
}

Снятие и разбор:

go test -bench . -cpuprofile cpu.out -memprofile mem.out
go tool pprof -http=:8080 cpu.out
go tool pprof -alloc_space mem.out   # все выделения
go tool pprof -inuse_space mem.out   # что живо сейчас

go tool trace показывает не агрегаты, а временную шкалу: когда горутины выполнялись, блокировались, ждали GC и сеть. Трассировка полезна для задержек, профиль — для стоимости.

go test -trace trace.out
go tool trace trace.out

Профилирование в production допустимо, но CPU-профиль добавляет накладные расходы. Block- и mutex-профили по умолчанию выключены, потому что сами стоят производительности.

9. Диагностика

ИнструментЧто даёт
GODEBUG=gctrace=1событие каждой сборки: объём, паузы
GODEBUG=schedtrace=Xсостояние планировщика каждые X мс
GODEBUG=inittrace=1время и аллокации инициализации пакетов
runtime/metricsстабильные именованные метрики рантайма
runtime.ReadMemStatsснимок памяти и числа сборок
expvarпубликация переменных через HTTP в JSON
go tool nm, go tool objdumpсимволы и дизассемблер бинарника
import "runtime/metrics"

samples := []metrics.Sample{
    {Name: "/memory/classes/heap/objects:bytes"},
    {Name: "/gc/cycles/total:gc-cycles"},
    {Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)

for _, s := range samples {
    fmt.Println(s.Name, s.Value.Uint64())
}

runtime/metrics — стабильный интерфейс: имена метрик не меняются от версии к версии так, как внутренние поля MemStats. Для дашбордов и алертов лучше он.

10. Первоисточники