Модуль: Runtime и память · Уровень: Senior+
TL;DR#
runtime/trace (execution tracer) пишет поток событий планировщика, GC, syscalls и блокировок с наносекундными таймстампами — в отличие от pprof, который даёт агрегированную статистику. Он отвечает на вопрос «почему конкретная горутина простояла 8 мс именно в этот момент», а не «куда в среднем уходит CPU». С Go 1.21 трейсер переписан на низкооверхедный формат (snapshot-free, ~1-2% overhead), а Go 1.25 принёс trace.FlightRecorder — кольцевой буфер для post-mortem диагностики редких событий. Дополняется неинвазивной диагностикой через GODEBUG (gctrace, schedtrace, inittrace).
Теория#
Чем execution tracer отличается от pprof#
pprof — это семплирующий профайлер: раз в ~10 мс (CPU profile) snapshot стеков, потом агрегация. Он отвечает на «где горит CPU суммарно». Execution tracer — это event log: каждое значимое событие рантайма пишется в буфер с таймстампом, ничего не теряется и не усредняется.
| Аспект | pprof (CPU/heap/block/mutex) | execution tracer |
|---|---|---|
| Модель данных | агрегат / семплы | поток событий с таймстампами |
| Ось времени | нет (только суммы) | да — видно «когда» |
| Планировщик (G/M/P) | не видно | полностью виден |
| GC фазы (STW, mark assist) | косвенно | пофазно с таймингами |
| Syscalls / блокировки | block/mutex profile (агрегат) | конкретное событие на конкретной горутине |
| Latency одной горутины | нет | да — её жизненный цикл |
| Overhead | низкий (семплинг) | выше, но с 1.21 ~1-2% |
| Объём данных | небольшой | большой (МБ/сек под нагрузкой) |
Главное правило: pprof говорит ЧТО потребляет ресурс, tracer говорит ПОЧЕМУ и КОГДА что-то задержалось. Классический кейс для трейсера — хвостовые задержки (p99 latency), которые pprof не объясняет, потому что они вызваны не «горячим кодом», а планированием: горутина была runnable, но Р был занят, или попала под STW, или ждала syscall.
Сбор трейса: три способа#
1. Программно через runtime/trace:
package main
import (
"os"
"runtime/trace"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
// ... рабочая нагрузка ...
}2. Через тесты/бенчмарки:
go test -trace=trace.out -bench=. ./...3. Через HTTP-эндпоинт (самый частый в проде):
import (
"net/http"
_ "net/http/pprof" // регистрирует /debug/pprof/* включая /trace
)
func main() {
go func() {
// ВАЖНО: эндпоинт даёт полный доступ к диагностике — закрывайте его
// от внешнего мира (отдельный порт, mTLS, ACL).
http.ListenAndServe("localhost:6060", nil)
}()
// ...
}Снять трейс за 5 секунд:
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out # откроет браузер с UIgo tool trace: какие view и что в них искать#
go tool trace поднимает локальный веб-сервер. Ключевые разделы:
- View trace by proc / by thread — таймлайн событий по процессорам (P) или OS-потокам (M). Здесь видно, как горутины мигрируют между P, где STW-паузы (вся строка проваливается), где GC mark workers.
- Goroutine analysis — для каждого типа горутины разбивка времени: Execution, Scheduler wait (runnable, но не запущена — главный индикатор нехватки P/CPU), Sync block (каналы/мьютексы), Block syscall, GC sweeping, GC pause.
- Network blocking / Syscall blocking / Scheduler latency profiles — это flame-graph’ы, но построенные из событий трейса (можно скачать как pprof).
- User-defined tasks / regions — ваши собственные tasks/regions (см. ниже).
- Minimum mutator utilization (MMU) — кривая: какую долю CPU приложение реально отдаёт «полезной работе» (mutator) на окне заданной длины. Провал MMU = GC съедает CPU.
Что искать на senior-уровне:
- Длинные полосы Scheduler wait = недостаток P (
GOMAXPROCS) или CPU throttling (cgroups в k8s). - Регулярные узкие вертикальные провалы по всем P = STW GC паузы.
- Горутина блокируется на канале, в то время как producer спит = проблема backpressure.
- Много Syscall blocking + рост числа M = блокирующие syscalls плодят потоки.
User tasks / regions / log: связь домена с трейсом#
Трейсер сам по себе не знает про ваши «запросы». Аннотации связывают бизнес-логику с событиями рантайма.
import (
"context"
"runtime/trace"
)
func handleRequest(ctx context.Context, id string) error {
// Task — логическая операция, может пересекать горутины,
// привязана к context. Имеет начало и конец.
ctx, task := trace.NewTask(ctx, "handleRequest")
defer task.End()
trace.Logf(ctx, "request", "id=%s", id) // событие-метка с категорией
// Region — участок ВНУТРИ одной горутины (не пересекает границы).
region := trace.StartRegion(ctx, "db.query")
rows, err := queryDB(ctx)
region.End()
if err != nil {
return err
}
// Идиома: defer-friendly region
defer trace.StartRegion(ctx, "render").End()
return render(rows)
}Различия, которые любят спрашивать:
| Примитив | Границы | Назначение | Привязка |
|---|---|---|---|
Task | может охватывать много горутин | сквозная операция (запрос) | через context.Context |
Region | строго в одной горутине | фаза работы внутри горутины | через ctx (но локальна) |
Log/Logf | точечное событие | метка/значение в потоке | через ctx |
В go tool trace → User-defined tasks появляется распределение latency ваших задач, и можно кликнуть на самый медленный экземпляр, провалиться в его таймлайн и увидеть, на чём именно он стоял. trace.IsEnabled() позволяет не платить за форматирование, когда трейс выключен.
Overhead и переписанный трейсер (Go 1.21+)#
До 1.21 трейсер имел заметный overhead и неприятную особенность: при старте трейса делался STW-снапшот всех горутин, что давало паузу, растущую с числом горутин (проблема для сервисов с сотнями тысяч G). Формат был «монолитным» и плохо стримился/парсился.
Go 1.21 принёс переписанный трейсер (proposal #60773):
- Overhead снижен примерно до ~1-2% (раньше мог быть 10-20%+).
- Формат стал партиционированным (generations): трейс делится на самодостаточные куски, можно парсить потоково, без удержания всего файла в памяти.
- Убрана глобальная STW при старте на весь набор горутин — синхронизация per-P, паузы стали короткими и не зависящими от числа G.
- Появился публичный парсер
golang.org/x/exp/trace(позжеinternal/tracev2), на котором и построен FlightRecorder.
Практический вывод: на современном Go execution tracing достаточно дёшев, чтобы держать его наготове в проде (особенно через flight recorder), а не только включать «когда уже всё сломалось».
GODEBUG: неинвазивная диагностика без перекомпиляции#
GODEBUG — переменные рантайма, читаются при старте, выводят диагностику в stderr. Не требуют изменения кода.
GODEBUG=gctrace=1,schedtrace=1000,inittrace=1 ./appПолезные ключи:
| GODEBUG | Что делает |
|---|---|
gctrace=1 | строка на каждый GC цикл (разбор ниже) |
schedtrace=1000 | каждые 1000 мс — сводка планировщика |
scheddetail=1 | вместе со schedtrace — детально по каждому P/M/G |
allocfreetrace=1 | stacktrace на каждую аллокацию/освобождение (ОЧЕНЬ дорого, только дебаг; удалён в новых версиях) |
inittrace=1 | тайминг и аллокации каждого init() пакета |
madvdontneed=1 | возвращать память ОС через MADV_DONTNEED (агрессивнее, видно в RSS); на Linux по умолчанию MADV_FREE |
asyncpreemptoff=1 | выключить асинхронную вытеснимость горутин (диагностика зависаний) |
Разбор строки gctrace=1 по полям#
Пример строки:
gc 42 @8.301s 3%: 0.018+1.2+0.004 ms clock, 0.29+0.51/2.1/0+0.064 ms cpu, 41->42->22 MB, 43 MB goal, 0 MB stacks, 0 MB globals, 16 PРазбор каждого поля:
gc 42— порядковый номер цикла GC с момента старта программы.@8.301s— время от старта программы до начала этого цикла.3%— суммарная доля CPU, потраченная на GC с момента старта (накопительно). Это тот самый GC CPU overhead; устойчивый рост к 25%+ — тревога (GC borrows до 25% CPU как hard limit мягко, плюс mark assist).0.018+1.2+0.004 ms clock— wall-clock время по фазам:0.018— STW sweep termination (завершение sweep предыдущего цикла, мир остановлен).1.2— concurrent mark and scan (маркировка идёт параллельно с приложением).0.004— STW mark termination (финальная остановка мира). Вот эти два STW-числа (первое и последнее) — и есть ваши GC-паузы. Здесь паузы ~0.022 мс суммарно — отлично.
0.29+0.51/2.1/0+0.064 ms cpu— CPU-время (суммарно по всем ядрам) по тем же фазам:0.29— STW sweep termination CPU.0.51/2.1/0— concurrent mark CPU, разбитое на: assist (mutator-горутины принудительно помогают GC, когда аллоцируют быстрее, чем GC успевает), background (выделенные GC-воркеры), idle (воркеры на простаивающих P). Большойassist= приложение аллоцирует слишком агрессивно и тормозит само себя.0.064— STW mark termination CPU.
41->42->22 MB— heap в трёх точках: до GC (heap при старте mark, 41) → в пике на момент mark termination (42) → live heap после GC (22, выжившие объекты). Разница 42→22 = собранный мусор.43 MB goal— целевой размер heap для триггера следующего GC, вычисляется какlive * (1 + GOGC/100). ПриGOGC=100и live=22 цель ~44 MB.0 MB stacks— память под стеки горутин, просканированная GC.0 MB globals— размер просканированных глобальных переменных.16 P— число процессоров (GOMAXPROCS), участвовавших в этом GC.
Если строка заканчивается на (forced) — это GC, вызванный вручную через runtime.GC() или debug.FreeOSMemory().
Связь с GOGC и GOMEMLIMIT: goal напрямую зависит от GOGC. Если выставлен GOMEMLIMIT, рантайк может запускать GC чаще (goal искусственно снижается), чтобы не превысить лимит — в gctrace это видно как уменьшение goal и рост частоты gc#.
Разбор schedtrace#
SCHED 1003ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 idlethreads=4 runqueue=0 [0 0 1 0 0 0 0 0]gomaxprocs=8— число P.idleprocs=6— сколько P сейчас простаивают (нет работы). Много idle при «тормозит» = работа не параллелится.threads=12— всего M (OS threads), созданных рантаймом.spinningthreads— M, активно ищущие работу (spinning перед сном).runqueue=0— длина глобальной очереди runnable-горутин. Рост = голодание по P.[0 0 1 0 0 0 0 0]— длины локальных runqueue каждого P. Перекос (одна большая, остальные нули) = плохой work-stealing/локальность.
С scheddetail=1 каждая строка раскрывается до состояния каждого G/M/P (status, очереди, привязки).
Flight Recorder (Go 1.25): post-mortem трейсинг#
Проблема: редкое событие (раз в день p99.99 latency spike) поймать обычным трейсом нельзя — не знаешь, когда включать, а держать запись постоянно = терабайты. Решение — кольцевой буфер: трейсер пишет в память постоянно, но хранит только последние N секунд / M байт. Когда происходит «интересное» событие — снимаем содержимое буфера.
import (
"os"
"runtime/trace"
)
func main() {
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
MinAge: 5 * time.Second, // хранить минимум столько недавней истории
MaxBytes: 10 << 20, // ограничение буфера
})
if err := fr.Start(); err != nil {
panic(err)
}
defer fr.Stop()
// ... работа; где-то в обработчике детектим аномалию ...
if latency > slo {
f, _ := os.Create("incident.trace")
// WriteTo сбрасывает текущее содержимое кольцевого буфера на диск
if _, err := fr.WriteTo(f); err != nil {
log.Println(err)
}
f.Close()
}
}Ключевые свойства:
- Можно держать включённым в проде (overhead тот же ~1-2% низкооверхедного трейсера).
WriteToдаёт валидный трейс-файл, открываемый обычнымgo tool trace.MinAge/MaxBytes— компромисс глубины истории против памяти; реально буфер хранит>= MinAge, но не большеMaxBytes.- Идеален для записи «что было ДО» падения/спайка, чего обычный реактивный
trace.Startне даёт.
До 1.25 этот паттерн делали руками поверх golang.org/x/exp/trace.FlightRecorder (экспериментальный API с ~Go 1.22).
Подводные камни / gotchas#
/debug/pprof/traceбез защиты = дыра. Эндпоинт раскрывает структуру выполнения и даёт нагрузку на сбор. Не вешайтеnet/http/pprofна публичный listener; используйте отдельный internal-порт.- Объём трейса. Под высокой нагрузкой трейс растёт на мегабайты в секунду.
seconds=60на нагруженном сервисе = сотни МБ, которыеgo tool traceпарсит в память. Снимайте короткие окна (2-5 сек). go tool traceи старые трейсы. Формат менялся (особенно в 1.21). Трейс, снятый на одной версии Go, корректно открывается соответствующей версиейgo tool trace. Не смешивайте.- Regions не пересекают горутины. Если запустить
StartRegionв одной горутине, аEnd()вызвать в другой — поведение некорректно. Для сквозной семантики используйтеTask. allocfreetrace=1парализует приложение — stacktrace на каждую аллокацию. Только на игрушечных нагрузках; в свежих версиях Go удалён.gctraceпоказывает накопительный CPU %, а не мгновенный. Низкий процент в начале и рост со временем — нормально для прогрева; смотрите тренд.madvdontneedи метрики RSS. На Linux Go по умолчанию используетMADV_FREE— освобождённая память остаётся в RSS, пока ОС не понадобится. Мониторинг по RSS «врёт», пока не выставитеmadvdontneed=1(ценой производительности). Это частая причина ложных алертов «утечка памяти».- Трейс не заменяет pprof. Для «куда уходит CPU суммарно» трейс неудобен и дорог — это работа CPU-профайла. Инструменты комплементарны.
- Включённый трейсер влияет на тайминги. Хоть overhead и мал, в микробенчмарках он искажает результаты; не трейсьте то, что измеряете на наносекундах.
Вопросы на собеседовании#
В: Когда execution tracer нужен, а pprof бессилен? О: Когда вопрос содержит «когда» и «почему задержалось», а не «сколько суммарно». pprof агрегирует и не имеет оси времени — он покажет горячие функции, но не объяснит хвостовую latency, вызванную планированием: горутина была runnable, но все P заняты (Scheduler wait), попала под STW-паузу GC, ждала разблокировки канала/мьютекса или висела в syscall. Трейсер пишет каждое такое событие с таймстампом, поэтому можно взять конкретный медленный запрос и увидеть его полный жизненный цикл по времени. Также только трейсер показывает поведение планировщика (миграция G между P, work-stealing, рост числа M) и пофазные тайминги GC.
В: Разберите строку gctrace: gc 5 @0.1s 1%: 0.02+0.5+0.01 ms clock, ... 4->5->2 MB, 8 MB goal, ... 8 P.
О: Это 5-й цикл GC, начавшийся через 0.1с после старта; на GC ушёл 1% CPU накопительно. Тайминги clock — три фазы: STW sweep termination 0.02 мс, concurrent mark 0.5 мс, STW mark termination 0.01 мс; реальные паузы приложения — первое и последнее число (~0.03 мс суммарно). Heap: 4 МБ на старте mark, 5 МБ в пике на mark termination, 2 МБ живых объектов после сборки (собрано 3 МБ). Goal 8 МБ — порог триггера следующего GC, при GOGC=100 это примерно live*(1+GOGC/100) = 2*2 ≈ 4, но с учётом накладных и round-up рантайм взял 8. 8 P — столько процессоров участвовало.
В: Чем Task отличается от Region?
О: Task — логическая сквозная операция (например, HTTP-запрос), которая может пересекать границы горутин и привязана к context.Context; создаётся trace.NewTask, имеет начало и task.End(). Region — это участок работы строго внутри одной горутины (фаза вроде «db.query»), не пересекает горутины, создаётся trace.StartRegion(ctx, ...). В UI Task даёт распределение latency операции и связь событий, разбросанных по горутинам; Region детализирует, на что ушло время внутри одной горутины. Log/Logf — точечные метки в потоке.
В: Что изменилось в трейсере в Go 1.21? О: Трейсер переписали: overhead упал примерно до 1-2% (раньше десятки процентов в плохих случаях); убрали глобальную STW-паузу при старте трейса, которая раньше росла с числом горутин (синхронизация стала per-P); формат стал партиционированным на самодостаточные generations, что позволяет потоковый парсинг без загрузки всего файла в память, и появился стабильный публичный парсер. Практическое следствие — трейсинг стал достаточно дёшев для постоянного использования в проде, на чём построен flight recorder.
В: Что такое FlightRecorder и зачем он, если есть trace.Start?
О: trace.FlightRecorder (Go 1.25) — кольцевой буфер в памяти, в который трейсер пишет постоянно, храня только последние N секунд/M байт. trace.Start реактивен: чтобы поймать событие, надо знать момент заранее. Редкий спайк (p99.99 раз в сутки) так не поймать — а держать полную запись нельзя из-за объёма. FlightRecorder решает это: он всегда «крутится» с малым overhead, а когда код детектирует аномалию, вызывается WriteTo, сбрасывающий текущий буфер в валидный трейс-файл. Главное преимущество — он сохраняет то, что было ДО события, чего реактивный подход не даёт.
В: Сервис тормозит, idleprocs в schedtrace высокий, runqueue растёт. Диагноз?
О: Противоречие кажущееся: высокий idleprocs (P простаивают) при растущей runqueue (есть готовые горутины) обычно означает, что горутины не могут попасть на P — например, из-за блокировок на общем мьютексе/канале (готовы, но сериализованы), либо из-за того, что горутины часто уходят в блокирующие syscalls и рантайм не успевает раскидать работу, либо CPU throttling в cgroups (P формально есть, но ядро не даёт квоту — рантайм видит P свободными, а реального CPU нет). Проверяю трейсом: Goroutine analysis покажет, где время — Sync block (мьютекс), Syscall block, или Scheduler wait. Если в k8s — сверяю GOMAXPROCS с CPU limit.
В: Почему RSS не падает после освобождения памяти, и при чём тут madvdontneed?
О: На Linux рантайм Go по умолчанию отдаёт страницы ядру через MADV_FREE: страницы помечаются как освобождаемые, но ядро забирает их физически только под давлением памяти, поэтому RSS остаётся высоким. Это не утечка — память доступна ОС. Чтобы RSS отражал реальное потребление сразу, ставят GODEBUG=madvdontneed=1, переключая на MADV_DONTNEED (страницы возвращаются немедленно, ценой более дорогих будущих аллокаций). Частая причина ложных алертов про утечку при мониторинге по RSS.
В: Как снять трейс с прод-сервиса и на что смотреть в go tool trace?
О: Через net/http/pprof на закрытом порту: curl -o t.out 'http://localhost:6060/debug/pprof/trace?seconds=3', окно короткое из-за объёма. Открываю go tool trace t.out. Смотрю: Goroutine analysis — разбивку времени по типам горутин (Execution vs Scheduler wait vs Sync/Syscall block); View by proc — STW-паузы (синхронные провалы по всем P) и миграции; MMU — не съедает ли GC мутатор; User tasks — распределение latency моих операций, проваливаюсь в самый медленный экземпляр и смотрю его таймлайн. Network/Syscall blocking профили скачиваю как pprof для flame-graph.
В: Что показывает inittrace=1 и зачем это на проде?
О: Выводит по строке на каждый пакет: время выполнения его init() и сколько он нааллоцировал. Используется для диагностики медленного старта сервиса (важно для cold start, k8s readiness, FaaS) — часто оказывается, что какой-то пакет в init() читает конфиг/строит большие таблицы/коннектится к сети, раздувая время запуска. Это видно только так, потому что init выполняется до main и обычным профайлером не покрывается удобно.
На что копают на senior+#
- Модель данных трейсера. Понимаете ли вы, что это event log с per-P буферами, а не семплинг; почему отсюда берётся overhead и объём; что значит партиционирование на generations и почему оно убрало STW при старте.
- Фазы GC и их отражение в gctrace/трейсе. Sweep termination, concurrent mark, mark termination, что из них STW; что такое mark assist и почему рост assist — симптом избыточных аллокаций; связь goal с GOGC и GOMEMLIMIT.
- Pacer и тонкая настройка. Как
GOGC/GOMEMLIMITсдвигают goal и частоту GC, как это читать построчно в gctrace, когда GOMEMLIMIT приводит к death spiral (GC крутится постоянно у лимита). - Планировщик через призму schedtrace/трейса. Состояния G, work-stealing, spinning threads, рост M из-за блокирующих syscalls, влияние cgroups CPU limit на восприятие GOMAXPROCS (и почему automaxprocs/GOMAXPROCS-by-default в 1.25 важны).
- Latency-аналитика по конкретной горутине. Способность взять p99-запрос, через user tasks провалиться в его таймлайн и атрибутировать задержку к scheduler wait / GC / блокировке, а не к коду.
- FlightRecorder в проде. Когда применять, как выбирать MinAge/MaxBytes, чем отличается от continuous profiling, как интегрировать с алертами/триггерами на SLO-нарушение.
- Комплементарность инструментов. Чёткое понимание, что pprof, execution trace, GODEBUG и runtime/metrics решают разные задачи, и умение выбрать правильный для конкретного симптома, а не «включить всё».
- Overhead-осознанность. Где трейсер искажает измерения, почему нельзя трейсить наносекундные бенчи, как оценить стоимость постоянного трейсинга для конкретного сервиса.