Сборщик мусора в 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, как визуализировать его поведение и быстрее замечать ситуации, где он может влиять на производительность приложения.
1) Немного терминов перед началом
Я не хочу превращать статью в разбор всего пакета 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 и внутренние метрики рантайма, я вынесу в отдельную техническую статью. Здесь же сосредоточусь на том, что можно наблюдать снаружи и как это визуализировать.
2) Почему gctrace и gcpacertrace полезны, но неудобны в динамике
Если включить gctrace, рантайм Go начнёт печатать информацию о каждом цикле сборки мусора: паузы, размеры кучи, загрузку GC и другие показатели.
Если добавить gcpacertrace, появятся ещё и данные о работе pacer — механизма, который регулирует интенсивность сборки мусора.
Но как только пытаешься использовать такой вывод на реальном запуске, появляются типичные проблемы.
Сложно увидеть тенденции во времени
Одна строка читается нормально. Сто или двести строк подряд уже превращаются в шум.
По логам сложно быстро понять динамику:
-
GC стал запускаться чаще или это разовый всплеск?
-
Где появляются длинные STW-паузы?
-
heap liveстабилизировался или постепенно растет вверх? -
изменилось ли поведение программы после внесения каких-то изменений?
Сами данные есть, но общей картины не видно.
Трудно сравнивать “до/после”
Допустим, вы поменяли GOGC, добавили кэш, переписали участок кода или изменили нагрузку. После этого хочется понять: стало лучше или хуже?
По логам это быстро превращается в ручную работу: сохранить вывод первого запуска, сохранить вывод второго, найти сопоставимые участки, сравнить значения и не потеряться в строках.
Для разового вдумчивого анализа это возможно. Для регулярной отладки и быстрого просмотра — неудобно.
Тяжело оценивать распределение значений
Отдельная STW-пауза в 300 микросекунд сама по себе мало что говорит. Важнее контекст:
-
это обычное значение или редкая длинна пауза?
-
как выглядит p50 — обычный уровень STW-пауз;
-
что происходит с p99 — показывает редкие длинные STW-паузы;
-
какой max — самая длинная STW-пауза в последнем окне наблюдений;
-
изменилось ли это после правки?
gctrace не плох. Наоборот, это один из самых полезных источников информации о работе GC. Просто лог хорошо подходит для детального разбора отдельных событий, но плохо помогает увидеть общую картину происходящего.
3) Как с помощью gcscope за минуту увидеть, как работает сборщик мусора
Установить gcscope можно через go install:
go install github.com/timur-developer/gcscope/cmd/gcscope@latest
После этого вы сможете использовать этот инструмент как обычный CLI в любом терминале.
Самый быстрый способ увидеть UI — запустить встроенную демо-нагрузку:
gcscope lab churn
lab churn.В режиме lab не нужно готовить свой сервис или поднимать тестовое окружение. Инструмент сам запускает синтетическую нагрузку, на которой удобно посмотреть, как выглядят графики, STW-паузы, изменения размера кучи и прочие метрики. Так вы получите первое знакомство с интерфейсом gcscope.
Если после запуска вы ничего не видите, это не обязательно баг. Скорее всего, скорее всего GC просто еще не запускался. В демо-режиме это обычно видно быстро, а в реальном приложении всё зависит от аллокаций и нагрузки.
4) Что показывает UI и как это читать
В 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-статистики по окну последних событий.
Как часто срабатывает GC
За это отвечает блок Information на скриншоте выше. В нём отображается частота запусков GC и средний интервал между циклами.
Эти значения считаются по отрезку последних событий и помогают понять, действительно ли сборщик мусора стал работать чаще или это просто ощущение из-за случайных всплесков нагрузки.
Частый вызов GC сам по себе не всегда проблема. Но если вместе с этим растёт latency, появляется излишняя нагрузка на CPU или увеличиваются STW-паузы, это уже повод смотреть глубже: аллокации памяти, GOGC, GOMEMLIMIT, профиль нагрузки.
Где появляются длинные STW-паузы
Обычно разработчик замечает не сами STW-паузы, а их симптомы: сервис иногда “дёргается”, т.е. работает с непонятными перерывами, отдельные запросы становятся медленнее, а очевидной причины сразу не видно.
В gcscope для этого полезны:
-
last STW (us)в блоке Current Values — сколько заняла STW-пауза в последнем GC-цикле; -
график
STW p50/p99/max over time (us)— как менялись типичные и редкие паузы во времени; -
per-cycle bar chart — чтобы посмотреть отдельные события.
Логика простая: p50 показывает обычный фон, а p99 и max помогают заметить редкие длинные паузы.
Если длинные паузы повторяются, важно смотреть не только на сам факт паузы, но и на момент, когда она появилась: совпадает ли это с увеличением количества аллокаций или изменением поведения приложения.
Как ведёт себя куча: heap live относительно heap goal
Размер кучи редко интересен сам по себе. Важнее динамика:
-
растёт ли
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.
5) Режим run: наблюдаем своё приложение без изменения кода
Для большинства ситуаций я бы начинал с режима 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 использует этот разделитель, чтобы понять, где заканчиваются его собственные аргументы и начинаются аргументы вашего приложения.
6) Архитектура: от stderr до TUI
Если упростить, gcscope работает одинаково с любым источником данных: получает информацию о работе GC, преобразует её в поток событий, строит поверх этих событий агрегаты и отдаёт всё это в UI.
Для режима run путь выглядит так:
Go-бинарник -> stderr (gctrace/gcpacertrace) -> парсер -> GC-события -> последние N событий -> статистика и данные для графиков -> интерфейс, snapshots и сравнение запусков
Почему run вообще видит GC
Чтобы режим run мог наблюдать за работой сборщика мусора, целевой процесс должен выводить данные gctrace и gcpacertrace.
Для этого gcscope автоматически настраивает переменную окружения GODEBUG, добавляя туда gctrace=1 и gcpacertrace=1.
При этом важно не затереть существующие настройки пользователя. Если в GODEBUG уже были другие параметры, их нужно сохранить и только добавить недостающие значения.
Код: как gcscope собирает строку для GODEBUG
// internal/source/runner/runner.gofunc NormalizeGODEBUG(value string) string {parts := strings.Split(value, ",")out := make([]string, 0, len(parts)+2)foundGctrace := falsefoundGcpacer := falsefor _, 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, а инструмент сам создаёт условия, при которых рантайм начинает отдавать нужные данные наружу.
Почему событие GC — это не просто одна строка
Проектируя такой инструмент, сначала кажется, что всё можно сделать очень просто: взять строку gctrace, распарсить её регулярным выражением и сразу отправить значения в UI.
Для минимального прототипа этого действительно достаточно. Но дальше быстро появляются ограничения.
Во-первых, самому UI почти никогда не нужна сама строка лога. Интерфейсу нужны значения: номер GC-цикла, время, STW-пауза, размеры кучи, heap live/heap goal, был ли GC запущен принудительно и другие данные.
Во-вторых, часть информации может приходить не из той же строки. Например, строка gc ... описывает сам GC-цикл, а строки pacer: ... добавляют информацию о работе pacer. Если показывать это в интерфейсе как одно событие, эти данные нужно связать между собой.
Код: сборка GC-события из строк gc и pacer
// internal/source/runner/parser.gofunc (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.currentp.current = nilreturn event}
В-третьих, поверх событий нужно считать агрегаты: p50/p99/max по окну, частоту GC, историю для графиков, snapshots и diff. Это неудобно делать поверх сырых строк логов.
Поэтому я не стал привязывать UI напрямую к строкам gctrace. Регулярные выражения могут использоваться внутри парсера, но наружу парсер должен отдавать нормальные события GC.
Так появилась простая схема: сначала собрать данные из логов и метрик в единый поток событий, затем посчитать по нему агрегаты, а уже потом рисовать графики, сохранять snapshots и делать diff.
Благодаря этому UI не знает, как именно выглядела исходная строка в stderr. Он работает с готовыми данными: GC-цикл, STW, heap live/goal и дополнительные поля pacer, если они есть.
Как данные попадают в UI
Для передачи данных в 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.gocase 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.gotype GCEvent struct { GCNum int TimeSinceStartS float64 GCCPUPercent float64 HeapStartMB int HeapEndMB int HeapLiveMB int HeapGoalMB int // ... // другие поля из gctrace/runtime metrics}
Это сильно упрощает дальнейшую логику: графики, окна, p50/p99/max, snapshots и diff строятся поверх одной модели данных.
7) attach: для уже работающего приложения
Режим 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 mainimport ( "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 находятся в репозитории проекта.
8) run vs attach: зачем нужен attach?
Режимы 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.
9) Хранение данных, снепшоты и diff
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-файл, который сохраняет текущее состояние окна наблюдений.
Пример snapshot-файла (сокращённо)
{ "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 и в какую сторону.
Пример вывода gcscope diff
A: gc_cycles_total: 16 heap_live_mb: 59 stw_max_us: 550 stw_p50_us: 0 stw_p99_us: 550B: gc_cycles_total: 56 heap_live_mb: 9 stw_max_us: 590 stw_p50_us: 0 stw_p99_us: 590Delta (B-A): heap_live_mb: -50 stw_max_us: +40 stw_p50_us: 0 stw_p99_us: +40
10) Где gcscope особенно полезен
Я вижу gcscope как быстрый первый шаг в диагностике проблем, которые могут быть связаны с GC.
Когда приложение начинает вести себя странно, не всегда понятно, куда смотреть: в настройки рантайма, профиль нагрузки, сеть, планировщик или код приложения. gcscope помогает быстро проверить одну из гипотез: как в этот момент ведёт себя сборщик мусора.
Он показывает поведение GC во времени:
-
как часто запускается GC;
-
что происходит с кучей;
-
появляются ли длинные STW-паузы;
-
изменилось ли что-то после правок в коде или настройках рантайма.
Если на графиках видно что-то подозрительное, дальше проще выбрать следующий инструмент: открыть pprof, посмотреть go tool trace, разобраться, на что аллоцируется память, или сопоставить данные с метриками в Prometheus и Grafana.
11) Как попробовать gсscope на своём проекте
Самый простой способ попробовать gcscope на своём проекте выглядит так:
-
Установить
gcscope. -
Запустить встроенную нагрузку
lab churn, чтобы понять, как выглядят графики и метрики. -
Собрать Go-бинарник своего приложения или сервиса.
-
Запустить его через
gcscope runпод типичной для него нагрузкой. -
Сохранить snapshot текущего запуска клавишей
s. -
Повторить запуск после изменения кода или настроек рантайма и сохранить второй snapshot.
-
Сравнить два snapshot-файла через
gcscope diff.
Минимальный набор команд:
go install github.com/timur-developer/gcscope/cmd/gcscope@latestgcscope lab churn# замените ./cmd/myapp на путь к main-пакету вашего приложенияgo build -o ./myapp ./cmd/myapp gcscope run ./myappgcscope diff ./before.json ./after.json
Код проекта, инструкция по установке и подробная документация лежат в репозитории:
https://github.com/timur-developer/gcscope
Если инструмент окажется полезным, буду рад звёздочке на GitHub, обратной связи в GitHub Issues или просто вашему мнению в комментариях.
А как вы обычно ищете причину, когда Go-сервис начинает тормозить под нагрузкой? Сначала смотрите в pprof, метрики, логи или есть свой порядок действий? И доходите ли в этом процессе до проверки GC?
ссылка на оригинал статьи https://habr.com/ru/articles/1043034/