golang

Я написал визуализатор сборщика мусора для Go — теперь GC не чёрный ящик

  • четверг, 4 июня 2026 г. в 00:00:13
https://habr.com/ru/articles/1043034/

Сборщик мусора в Go обычно воспринимается как что-то, что просто работает. И это, в общем, хорошо: большую часть времени о нём действительно не хочется думать.

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

В Go уже есть способы посмотреть на работу сборщика мусора снаружи. Рантайм умеет выводить информацию о каждом GC-цикле через gctrace и gcpacertrace, а ещё есть структурированные метрики из пакета runtime/metrics.

Проблема в том, что в реальном запуске это быстро превращается в поток строк и чисел. Одну-две строки прочитать можно. А вот увидеть динамику, всплески, связь с нагрузкой и разницу между двумя разными запусками нашего приложения уже сложнее.

Мне хотелось видеть работу GC не как набор логов, а как картину целиком: как часто запускается сборка мусора, как меняются параметры GC, где появляются отклонения в STW-паузах и чем один запуск приложения отличается от другого.

Так появился gcscope - терминальный визуализатор для Go GC. Он собирает данные из gctrace, gcpacertrace и runtime/metrics, показывает их в виде графиков в реальном времени, позволяет сохранять снапшоты и сравнивать разные запуски между собой.

Так выглядит gcscope: в одном окне собраны основные метрики GC, графики и детали последних циклов сборки мусора.
Так выглядит 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 churn.

В режиме lab не нужно готовить свой сервис или поднимать тестовое окружение. Инструмент сам запускает синтетическую нагрузку, на которой удобно посмотреть, как выглядят графики, STW-паузы, изменения размера кучи и прочие метрики. Так вы получите первое знакомство с интерфейсом gcscope.

Если после запуска вы ничего не видите, это не обязательно баг. Скорее всего, скорее всего GC просто еще не запускался. В демо-режиме это обычно видно быстро, а в реальном приложении всё зависит от аллокаций и нагрузки.

4) Что показывает UI и как это читать

В gcscope легко увлечься графиками и начать просто смотреть на них. Но полезнее начинать работу, задавая себе определённый вопрос.

Например:

  • почему GC стал срабатывать чаще?

  • есть ли редкие редкие длинные STW-паузы?

  • растёт ли heap live?

  • насколько heap live близок к heap goal?

  • изменилось ли поведение после новой версии кода?

Так UI превращается не просто в картинку, а в инструмент для анализа.

 Общий вид интерфейса gcscope.
Общий вид интерфейса gcscope.

В интерфейсе есть несколько основных зон:

  1. Current Values - текущие значения: номер GC-цикла, последняя STW-пауза, heap live, heap goal.

  2. Information - сводка по последним событиям: частота GC, max STW, thresholds, окружение и состояние snapshot.

  3. STW per cycle - STW-паузы по отдельным GC-циклам.

  4. Cycle Details - детали выбранного GC-события.

  5. Heap live over time - как меняется объём живых объектов в куче во времени.

  6. STW p50/p99/max over time - как меняются STW-статистики по окну последних событий.

Как часто срабатывает GC

За это отвечает блок Information на скриншоте выше. В нём отображается частота запусков GC и средний интервал между циклами.

Эти значения считаются по отрезку последних событий и помогают понять, действительно ли сборщик мусора стал работать чаще или это просто ощущение из-за случайных всплесков нагрузки.

Частый вызов GC сам по себе не всегда проблема. Но если вместе с этим растёт latency, появляется излишняя нагрузка на CPU или увеличиваются STW-паузы, это уже повод смотреть глубже: аллокации памяти, GOGC, GOMEMLIMIT, профиль нагрузки.

Где появляются длинные STW-паузы

Обычно разработчик замечает не сами STW-паузы, а их симптомы: сервис иногда “дёргается”, т.е. работает с непонятными перерывами, отдельные запросы становятся медленнее, а очевидной причины сразу не видно.

В gcscope для этого полезны:

  1. last STW (us) в блоке Current Values - сколько заняла STW-пауза в последнем GC-цикле;

  2. график STW p50/p99/max over time (us) - как менялись типичные и редкие паузы во времени;

  3. STW 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 путь выглядит так:

Схема работы сервиса
Схема работы сервиса

Почему run вообще видит GC

Чтобы режим run мог наблюдать за работой сборщика мусора, целевой процесс должен выводить данные gctrace и gcpacertrace.

Для этого gcscope автоматически настраивает переменную окружения GODEBUG, добавляя туда gctrace=1 и gcpacertrace=1.

При этом важно не затереть существующие настройки пользователя. Если в GODEBUG уже были другие параметры, их нужно сохранить и только добавить недостающие значения.

Код: как gcscope собирает строку для 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, а инструмент сам создаёт условия, при которых рантайм начинает отдавать нужные данные наружу.

Почему событие GC - это не просто одна строка

Проектируя такой инструмент, сначала кажется, что всё можно сделать очень просто: взять строку gctrace, распарсить её регулярным выражением и сразу отправить значения в UI.

Для минимального прототипа этого действительно достаточно. Но дальше быстро появляются ограничения.

Во-первых, самому UI почти никогда не нужна сама строка лога. Интерфейсу нужны значения: номер GC-цикла, время, STW-пауза, размеры кучи, heap live/heap goal, был ли GC запущен принудительно и другие данные.

Во-вторых, часть информации может приходить не из той же строки. Например, строка gc ... описывает сам GC-цикл, а строки pacer: ... добавляют информацию о работе pacer. Если показывать это в интерфейсе как одно событие, эти данные нужно связать между собой.

Код: сборка GC-события из строк gc и 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

Для передачи данных в 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 строятся поверх одной модели данных.

7) attach: для уже работающего приложения

Режим attach полезен, когда мы хотим наблюдать за уже запущенным приложением через HTTP endpoint: ваш сервис отдаёт метрики рантайма, а gcscope периодически забирает их, превращает в события и показывает в UI.

Схема получается довольно простой:

  1. В сервис добавляется HTTP endpoint из pkg/reporter - небольшого пакета внутри gcscope, который отдаёт данные из runtime/metrics в JSON.

  2. gcscope периодически опрашивает этот endpoint и преобразует полученные метрики в события.

  3. Дальше 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 находятся в репозитории проекта.

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:      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

10) Где gcscope особенно полезен

Я вижу gcscope как быстрый первый шаг в диагностике проблем, которые могут быть связаны с GC.

Когда приложение начинает вести себя странно, не всегда понятно, куда смотреть: в настройки рантайма, профиль нагрузки, сеть, планировщик или код приложения. gcscope помогает быстро проверить одну из гипотез: как в этот момент ведёт себя сборщик мусора.

Он показывает поведение GC во времени:

  • как часто запускается GC;

  • что происходит с кучей;

  • появляются ли длинные STW-паузы;

  • изменилось ли что-то после правок в коде или настройках рантайма.

Если на графиках видно что-то подозрительное, дальше проще выбрать следующий инструмент: открыть pprof, посмотреть go tool trace, разобраться, на что аллоцируется память, или сопоставить данные с метриками в Prometheus и Grafana.

11) Как попробовать gсscope на своём проекте

Самый простой способ попробовать gcscope на своём проекте выглядит так:

  1. Установить gcscope.

  2. Запустить встроенную нагрузку lab churn, чтобы понять, как выглядят графики и метрики.

  3. Собрать Go-бинарник своего приложения или сервиса.

  4. Запустить его через gcscope run под типичной для него нагрузкой.

  5. Сохранить snapshot текущего запуска клавишей s.

  6. Повторить запуск после изменения кода или настроек рантайма и сохранить второй snapshot.

  7. Сравнить два 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?