Я написал визуализатор сборщика мусора для Go — теперь GC не чёрный ящик
- четверг, 4 июня 2026 г. в 00:00:13
Сборщик мусора в Go обычно воспринимается как что-то, что просто работает. И это, в общем, хорошо: большую часть времени о нём действительно не хочется думать.
Но всё меняется, когда под нагрузкой начинают расти задержки, сервис отвечает медленнее, а потребление памяти резко увеличивается. В такой момент обычно лезешь проверять то, что первым приходит в голову: CPU, блокировки, работу сети, pprof, метрики приложения. Среди всего этого сборщик мусора обычно даже не вспоминают - хотя он вполне может быть причиной просадок производительности.

В Go уже есть способы посмотреть на работу сборщика мусора снаружи. Рантайм умеет выводить информацию о каждом GC-цикле через gctrace и gcpacertrace, а ещё есть структурированные метрики из пакета runtime/metrics.
Проблема в том, что в реальном запуске это быстро превращается в поток строк и чисел. Одну-две строки прочитать можно. А вот увидеть динамику, всплески, связь с нагрузкой и разницу между двумя разными запусками нашего приложения уже сложнее.
Мне хотелось видеть работу GC не как набор логов, а как картину целиком: как часто запускается сборка мусора, как меняются параметры GC, где появляются отклонения в STW-паузах и чем один запуск приложения отличается от другого.
Так появился gcscope - терминальный визуализатор для Go GC. Он собирает данные из gctrace, gcpacertrace и runtime/metrics, показывает их в виде графиков в реальном времени, позволяет сохранять снапшоты и сравнивать разные запуски между собой.

gcscope: в одном окне собраны основные метрики GC, графики и детали последних циклов сборки мусора.В статье расскажу:
как увидеть работу сборщика мусора в реальном времени
как понять, может ли GC быть связан с просадкой производительности
как заметить длинные STW-паузы
как разобраться, что происходит с кучей
как запустить визуализацию на своём Go-бинарнике без правок в коде
как устроен путь от логов рантайма до графиков в терминале
как сравнить поведение приложения до и после изменений
как использовать эти данные как отправную точку для pprof, trace и дальнейшего анализа производительности
После статьи вы будете лучше понимать, как наблюдать за работой сборщика мусора в Go, как визуализировать его поведение и быстрее замечать ситуации, где он может влиять на производительность приложения.

Я не хочу превращать статью в разбор всего пакета runtime, но несколько терминов всё-таки нужны.
GC-цикл: очередной проход сборщика мусора.
STW (Stop-The-World): кратковременная пауза во время выполнения программы, необходимая сборщику мусора для выполнения некоторых операций с памятью.
heap live: объём живых объектов в куче после завершения цикла GC, то есть объектов, которые всё ещё нужны программе.
heap goal: целевой размер кучи, при достижении которого рантайм планирует запустить следующий цикл GC.
gctrace: режим логирования GC, в котором рантайм выводит информацию о каждом цикле сборки мусора в поток вывода stderr. Формат вывода может различаться между версиями Go.
gcpacertrace: дополнительный режим логирования, показывающий работу pacer’а - механизма, который регулирует интенсивность работы GC и помогает удерживать размер кучи в целевых пределах.
runtime/metrics: пакет стандартной библиотеки Go, предоставляющий структурированные метрики рантайма без необходимости разбирать текстовые логи.
Дальше я буду говорить только о том, что можно наблюдать снаружи через эти источники. gcscope не лезет внутрь рантайма и не обещает показать все внутренности GC. Он помогает удобнее смотреть на те данные, которые Go уже умеет отдавать наружу.
Более глубокий разбор того, как анализировать работу GC и внутренние метрики рантайма, я вынесу в отдельную техническую статью. Здесь же сосредоточусь на том, что можно наблюдать снаружи и как это визуализировать.
Если включить gctrace, рантайм Go начнёт печатать информацию о каждом цикле сборки мусора: паузы, размеры кучи, загрузку GC и другие показатели.
Если добавить gcpacertrace, появятся ещё и данные о работе pacer - механизма, который регулирует интенсивность сборки мусора.
Но как только пытаешься использовать такой вывод на реальном запуске, появляются типичные проблемы.
Одна строка читается нормально. Сто или двести строк подряд уже превращаются в шум.

По логам сложно быстро понять динамику:
GC стал запускаться чаще или это разовый всплеск?
Где появляются длинные STW-паузы?
heap live стабилизировался или постепенно растет вверх?
изменилось ли поведение программы после внесения каких-то изменений?
Сами данные есть, но общей картины не видно.
Допустим, вы поменяли GOGC, добавили кэш, переписали участок кода или изменили нагрузку. После этого хочется понять: стало лучше или хуже?
По логам это быстро превращается в ручную работу: сохранить вывод первого запуска, сохранить вывод второго, найти сопоставимые участки, сравнить значения и не потеряться в строках.
Для разового вдумчивого анализа это возможно. Для регулярной отладки и быстрого просмотра - неудобно.
Отдельная STW-пауза в 300 микросекунд сама по себе мало что говорит. Важнее контекст:
это обычное значение или редкая длинна пауза?
как выглядит p50 - обычный уровень STW-пауз;
что происходит с p99 - показывает редкие длинные STW-паузы;
какой max - самая длинная STW-пауза в последнем окне наблюдений;
изменилось ли это после правки?
gctrace не плох. Наоборот, это один из самых полезных источников информации о работе GC. Просто лог хорошо подходит для детального разбора отдельных событий, но плохо помогает увидеть общую картину происходящего.
Установить gcscope можно через go install:
go install github.com/timur-developer/gcscope/cmd/gcscope@latest
После этого вы сможете использовать этот инструмент как обычный CLI в любом терминале.
Самый быстрый способ увидеть UI - запустить встроенную демо-нагрузку:
gcscope lab churn

lab churn.В режиме lab не нужно готовить свой сервис или поднимать тестовое окружение. Инструмент сам запускает синтетическую нагрузку, на которой удобно посмотреть, как выглядят графики, STW-паузы, изменения размера кучи и прочие метрики. Так вы получите первое знакомство с интерфейсом gcscope.
Если после запуска вы ничего не видите, это не обязательно баг. Скорее всего, скорее всего GC просто еще не запускался. В демо-режиме это обычно видно быстро, а в реальном приложении всё зависит от аллокаций и нагрузки.
В gcscope легко увлечься графиками и начать просто смотреть на них. Но полезнее начинать работу, задавая себе определённый вопрос.
Например:
почему GC стал срабатывать чаще?
есть ли редкие редкие длинные STW-паузы?
растёт ли heap live?
насколько heap live близок к heap goal?
изменилось ли поведение после новой версии кода?
Так UI превращается не просто в картинку, а в инструмент для анализа.

gcscope.В интерфейсе есть несколько основных зон:
Current Values - текущие значения: номер GC-цикла, последняя STW-пауза, heap live, heap goal.
Information - сводка по последним событиям: частота GC, max STW, thresholds, окружение и состояние snapshot.
STW per cycle - STW-паузы по отдельным GC-циклам.
Cycle Details - детали выбранного GC-события.
Heap live over time - как меняется объём живых объектов в куче во времени.
STW p50/p99/max over time - как меняются STW-статистики по окну последних событий.
За это отвечает блок Information на скриншоте выше. В нём отображается частота запусков GC и средний интервал между циклами.
Эти значения считаются по отрезку последних событий и помогают понять, действительно ли сборщик мусора стал работать чаще или это просто ощущение из-за случайных всплесков нагрузки.
Частый вызов GC сам по себе не всегда проблема. Но если вместе с этим растёт latency, появляется излишняя нагрузка на CPU или увеличиваются STW-паузы, это уже повод смотреть глубже: аллокации памяти, GOGC, GOMEMLIMIT, профиль нагрузки.
Обычно разработчик замечает не сами STW-паузы, а их симптомы: сервис иногда “дёргается”, т.е. работает с непонятными перерывами, отдельные запросы становятся медленнее, а очевидной причины сразу не видно.
В gcscope для этого полезны:
last STW (us) в блоке Current Values - сколько заняла STW-пауза в последнем GC-цикле;
график STW p50/p99/max over time (us) - как менялись типичные и редкие паузы во времени;
STW per-cycle bar chart - чтобы посмотреть отдельные события.

Логика простая: p50 показывает обычный фон, а p99 и max помогают заметить редкие длинные паузы.
Если длинные паузы повторяются, важно смотреть не только на сам факт паузы, но и на момент, когда она появилась: совпадает ли это с увеличением количества аллокаций или изменением поведения приложения.
Размер кучи редко интересен сам по себе. Важнее динамика:
растёт ли heap live со временем или стабилизируется;
насколько текущий объём живых объектов близок к heap goal;
как это меняется под разной нагрузкой;
что происходит после изменений в коде или настройках рантайма.
Связка heap live / heap goal помогает понять, насколько активно GC вынужден работать, чтобы удерживать кучу в целевых пределах.
В gcscope это видно в блоке Current Values и на графике Heap live over time.

Когда меняешь код, параметры рантайма или характер нагрузки, хочется быстро понять: изменение действительно помогло или стало только хуже.
Для этого в gcscope есть два варианта:
визуально сравнить поведение в UI;
сохранить snapshots и сравнить их через diff.
Про визуальный разбор мы говорили в этом разделе, к snapshots и diff вернёмся отдельно в разделе про сравнение запусков.
Для первого знакомства достаточно нескольких клавиш:
?, h или f1 - открыть помощь;
space - поставить обновление на паузу или продолжить;
left / right - листать историю, когда интерфейс на паузе;
s - сохранить snapshot;
q или ctrl+c - выйти.

На GIF выше показано базовое взаимодействие с gcscope: открытие справки, переключение режимов отображения, изменение масштаба графиков, пауза обновления UI, перемещение по истории событий и сохранение snapshot.
Для большинства ситуаций я бы начинал с режима run.
Он запускает ваш Go-бинарник под наблюдением и читает данные, которые рантайм пишет в stderr через gctrace и gcpacertrace.
Пример:
gcscope run ./path/to/your-binary
Есть два нюанса, о которых полезно помнить.
Во-первых, target - это путь к уже скомпилированному бинарнику, а не к .go файлу. То есть сначала нужно собрать ваше приложение:
# замените ./cmd/myapp на путь к main-пакету вашего приложения go build -o ./myapp ./cmd/myapp
А потом запустить его через gcscope:
gcscope run ./myapp
Во-вторых, если вашему приложению нужно передать аргументы, используйте разделитель --:
gcscope run ./myapp -- --config ./config.yaml --port 8080
Всё, что находится после --, передаётся целевой программе без изменений. Сам gcscope использует этот разделитель, чтобы понять, где заканчиваются его собственные аргументы и начинаются аргументы вашего приложения.
Если упростить, gcscope работает одинаково с любым источником данных: получает информацию о работе GC, преобразует её в поток событий, строит поверх этих событий агрегаты и отдаёт всё это в UI.
Для режима run путь выглядит так:

Чтобы режим run мог наблюдать за работой сборщика мусора, целевой процесс должен выводить данные gctrace и gcpacertrace.
Для этого gcscope автоматически настраивает переменную окружения GODEBUG, добавляя туда gctrace=1 и gcpacertrace=1.
При этом важно не затереть существующие настройки пользователя. Если в GODEBUG уже были другие параметры, их нужно сохранить и только добавить недостающие значения.
// internal/source/runner/runner.go func NormalizeGODEBUG(value string) string { parts := strings.Split(value, ",") out := make([]string, 0, len(parts)+2) foundGctrace := false foundGcpacer := false for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } switch { case strings.HasPrefix(part, "gctrace="): if !foundGctrace { out = append(out, "gctrace=1") foundGctrace = true } case strings.HasPrefix(part, "gcpacertrace="): if !foundGcpacer { out = append(out, "gcpacertrace=1") foundGcpacer = true } default: out = append(out, part) } } if !foundGctrace { out = append(out, "gctrace=1") } if !foundGcpacer { out = append(out, "gcpacertrace=1") } return strings.Join(out, ",") }
То есть пользователь запускает свой бинарник через gcscope, а инструмент сам создаёт условия, при которых рантайм начинает отдавать нужные данные наружу.
Проектируя такой инструмент, сначала кажется, что всё можно сделать очень просто: взять строку gctrace, распарсить её регулярным выражением и сразу отправить значения в UI.

Для минимального прототипа этого действительно достаточно. Но дальше быстро появляются ограничения.
Во-первых, самому UI почти никогда не нужна сама строка лога. Интерфейсу нужны значения: номер GC-цикла, время, STW-пауза, размеры кучи, heap live/heap goal, был ли GC запущен принудительно и другие данные.
Во-вторых, часть информации может приходить не из той же строки. Например, строка gc ... описывает сам GC-цикл, а строки pacer: ... добавляют информацию о работе pacer. Если показывать это в интерфейсе как одно событие, эти данные нужно связать между собой.
// internal/source/runner/parser.go func (p *Parser) ParseLine(line string) (*domain.GCEvent, error) { trimmed := strings.TrimSpace(line) if trimmed == "" { return nil, nil } if strings.HasPrefix(trimmed, "gc ") { return p.parseGCLine(trimmed) } if strings.HasPrefix(trimmed, "pacer:") { return nil, p.parsePacerLine(trimmed) } return nil, nil } func (p *Parser) Flush() *domain.GCEvent { if p.current == nil { return nil } event := p.current p.current = nil return event }
В-третьих, поверх событий нужно считать агрегаты: p50/p99/max по окну, частоту GC, историю для графиков, snapshots и diff. Это неудобно делать поверх сырых строк логов.
Поэтому я не стал привязывать UI напрямую к строкам gctrace. Регулярные выражения могут использоваться внутри парсера, но наружу парсер должен отдавать нормальные события GC.
Так появилась простая схема: сначала собрать данные из логов и метрик в единый поток событий, затем посчитать по нему агрегаты, а уже потом рисовать графики, сохранять snapshots и делать diff.
Благодаря этому UI не знает, как именно выглядела исходная строка в stderr. Он работает с готовыми данными: GC-цикл, STW, heap live/goal и дополнительные поля pacer, если они есть.
Для передачи данных в UI я использую модель сообщений Bubble Tea - библиотеки для создания TUI-приложений на Go.

События GC отправляются в модель:
// cmd/gcscope/lab.go (аналогично в run.go) go func() { for ev := range r.Events() { prog.Send(ui.GCEventMsg{Event: ev, At: time.Now()}) } }()
Модель хранит последние N событий, пересчитывает агрегаты и обновляет данные для отображения графиков.
// internal/ui/model_update.go case GCEventMsg: m.lastUpdate = msg.At m.now = msg.At m.store.Add(msg.Event) // окно последних N событий m.agg = domain.ComputeAggregates(m.store.Recent()) // подсчёт агрегатов m.pushHistory(msg.At) // история для графиков if !m.paused { m.cursor = m.currentWindowLen() - 1 } return m, nil
UI работает уже не со строками логов, а с готовыми событиями.
// internal/domain/events.go type GCEvent struct { GCNum int TimeSinceStartS float64 GCCPUPercent float64 HeapStartMB int HeapEndMB int HeapLiveMB int HeapGoalMB int // ... // другие поля из gctrace/runtime metrics }
Это сильно упрощает дальнейшую логику: графики, окна, p50/p99/max, snapshots и diff строятся поверх одной модели данных.
Режим attach полезен, когда мы хотим наблюдать за уже запущенным приложением через HTTP endpoint: ваш сервис отдаёт метрики рантайма, а gcscope периодически забирает их, превращает в события и показывает в UI.
Схема получается довольно простой:
В сервис добавляется HTTP endpoint из pkg/reporter - небольшого пакета внутри gcscope, который отдаёт данные из runtime/metrics в JSON.
gcscope периодически опрашивает этот endpoint и преобразует полученные метрики в события.
Дальше UI работает с этими событиями так же, как и в других режимах.
Минимальный пример ниже использует обычный http.ServeMux из стандартной библиотеки Go. Это не обязательное требование: в своём проекте вы можете зарегистрировать handler из pkg/reporter в любом роутере, который уже используете (chi, gorilla/mux или любой другой на ваш вкус).
reporter.New()возвращает объект, у которого есть два метода:
Path() — путь endpoint’а, по умолчанию /gcscope/metrics;
Handler() — HTTP handler, который отдаёт данные из runtime/metrics в JSON-формате.
Минимальный пример на http.ServeMux:
package main import ( "log" "net/http" "github.com/timur-developer/gcscope/pkg/reporter" ) func main() { rep := reporter.New() mux := http.NewServeMux() mux.Handle(rep.Path(), rep.Handler()) log.Fatal(http.ListenAndServe(":8080", mux)) }
После этого gcscope можно подключить к адресу, на котором ваш сервис отдаёт endpoint с метриками. Например, если сервис работает локально:
gcscope attach http://127.0.0.1:8080/gcscope/metrics
Подробности JSON-контракта в рамках этой статьи не так важны. Достаточно помнить, что формат данных задаёт pkg/reporter, а первоисточником остаётся runtime/metrics.
Если хочется посмотреть глубже: реализация и README пакета pkg/reporter находятся в репозитории проекта.
Режимы run и attach решают похожую задачу - помогают наблюдать за работой сборщика мусора. Но данные они получают по-разному.
run анализирует вывод gctrace/gcpacertrace из stderr целевого процесса;
attach читает runtime/metrics через HTTP endpoint.
Из этого следуют два важных отличия.
Во-первых, в attach нет доступа к окружению процесса. Поэтому значения GOGC, GOMEMLIMIT и GODEBUG недоступны и отображаются в UI как n/a.
Во-вторых, значения в attach и run не обязаны совпадать один в один. Это разные источники данных, с разной точностью и разной семантикой.

На практике выбор режима зависит от задачи:
если нужен максимально близкий к gctrace взгляд на отдельные GC-циклы и STW-паузы, удобнее начать с run;
если нужно подключиться к уже работающему процессу через эндпоинт, полезнее окажется attach.
gcscope хранит в памяти последние N событий сборщика мусора. По умолчанию окно равно 200 событиям, но его можно изменить через --window-size.
// cmd/gcscope/run.go: прокидываем размер окна из конфига (флаг --window-size) model := ui.NewModel(ctx, cancel, cfg.WindowSize, snapshotDir, writer, stwTh, envInfo) // internal/ui/model_types.go: внутри модели создается хранилище с окном последних N событий store: domain.NewStore(windowSize),
Это сделано осознанно.
GC - это поток однотипных событий. Для интерактивного анализа чаще важнее не вся история процесса с момента запуска, а последние минуты или последние N циклов.
Окно помогает:
держать UI отзывчивым;
быстро пересчитывать p50/p99/max;
показывать, что происходит с GC в последние моменты работы приложения.
Snapshot в gcscope - это JSON-файл, который сохраняет текущее состояние окна наблюдений.
{ "version": 1, "current": { "gc_cycles_total": 16, "last_stw_us": 0, "heap_live_mb": 59, "heap_goal_mb": 166 }, "window": { "stw_p50_us": 0, "stw_p99_us": 550, "stw_max_us": 550 }, "events": [ { "gc_num": 1, "time_since_start_s": 0.08, "heap_live_mb": 3, "heap_goal_mb": 4 } ] }
В него попадают:
текущие значения вроде gc_cycles_total, last_stw_us, heap_live_mb, heap_goal_mb;
статистики вроде stw_p50_us, stw_p99_us, stw_max_us;
список последних GC-событий из того же окна, по которому строится UI.
На практике snapshot удобно сохранять после каждого запуска, который вы хотите сравнить с другим:
до и после оптимизации;
до и после изменения GOGC;
до и после релиза новой версии вашего сервиса;
при разных сценариях нагрузки.
Сравнение делается командой gcscope diff: первым аргументом передаётся snapshot “до”, вторым — snapshot “после”.
gcscope diff ./before.json ./after.json
Например:
gcscope diff gcscope/tmp/snapshots/gcscope-snapshot-2026-05-28T15-14-22.json \ gcscope/tmp/snapshots/gcscope-snapshot-2026-05-28T15-16-58.json
diff сравнивает основные показатели состояния кучи и оконные STW-статистики, а затем выводит разницу в формате B - A.
Это не автоматический поиск утечек и не магический оптимизатор. Но это быстрый способ ответить на практический вопрос: повлияли ли изменения на поведение GC и в какую сторону.

A: gc_cycles_total: 16 heap_live_mb: 59 stw_max_us: 550 stw_p50_us: 0 stw_p99_us: 550 B: gc_cycles_total: 56 heap_live_mb: 9 stw_max_us: 590 stw_p50_us: 0 stw_p99_us: 590 Delta (B-A): heap_live_mb: -50 stw_max_us: +40 stw_p50_us: 0 stw_p99_us: +40
Я вижу gcscope как быстрый первый шаг в диагностике проблем, которые могут быть связаны с GC.
Когда приложение начинает вести себя странно, не всегда понятно, куда смотреть: в настройки рантайма, профиль нагрузки, сеть, планировщик или код приложения. gcscope помогает быстро проверить одну из гипотез: как в этот момент ведёт себя сборщик мусора.
Он показывает поведение GC во времени:
как часто запускается GC;
что происходит с кучей;
появляются ли длинные STW-паузы;
изменилось ли что-то после правок в коде или настройках рантайма.
Если на графиках видно что-то подозрительное, дальше проще выбрать следующий инструмент: открыть pprof, посмотреть go tool trace, разобраться, на что аллоцируется память, или сопоставить данные с метриками в Prometheus и Grafana.
Самый простой способ попробовать gcscope на своём проекте выглядит так:
Установить gcscope.
Запустить встроенную нагрузку lab churn, чтобы понять, как выглядят графики и метрики.
Собрать Go-бинарник своего приложения или сервиса.
Запустить его через gcscope run под типичной для него нагрузкой.
Сохранить snapshot текущего запуска клавишей s.
Повторить запуск после изменения кода или настроек рантайма и сохранить второй snapshot.
Сравнить два snapshot-файла через gcscope diff.
Минимальный набор команд:
go install github.com/timur-developer/gcscope/cmd/gcscope@latest gcscope lab churn # замените ./cmd/myapp на путь к main-пакету вашего приложения go build -o ./myapp ./cmd/myapp gcscope run ./myapp gcscope diff ./before.json ./after.json
Код проекта, инструкция по установке и подробная документация лежат в репозитории:
https://github.com/timur-developer/gcscope
Если инструмент окажется полезным, буду рад звёздочке на GitHub, обратной связи в GitHub Issues или просто вашему мнению в комментариях.
А как вы обычно ищете причину, когда Go-сервис начинает тормозить под нагрузкой? Сначала смотрите в pprof, метрики, логи или есть свой порядок действий? И доходите ли в этом процессе до проверки GC?