---
title: "Горутины и память: свои сборщики"
description: "Метрики goroutine в Go: Gauge с runtime.NumGoroutine по тикеру, свои серии для пулов воркеров, утечка горутин на графике и память процесса до OOM."
course: "Мониторинг Go с Prometheus: метрики, Grafana, алерты"
url: "https://golangmaster.ru/courses/prometheus/lessons/16-metriki-gorutin/"
published: "2026-09-19"
updated: "2026-09-24"
minutes: 18
---

# Горутины и память: свои сборщики

*Курс «Мониторинг Go с Prometheus: метрики, Grafana, алерты», урок 16 из 25. ~18 минут. Обновлено 24.09.2026. Источник: https://golangmaster.ru/courses/prometheus/lessons/16-metriki-gorutin/*

Метрики goroutine и памяти в Go не обязаны ограничиваться встроенным набором: к go_goroutines добавляются собственные серии — датчик с runtime.NumGoroutine по тикеру, счётчик запусков и датчики пулов воркеров, на которых утечка горутин видна до OOM. Встроенные коллекторы из одиннадцатого урока описывают процесс целиком; здесь добавляются срезы, за которыми стоит конкретный код.

График числа горутин — самая дешёвая страховка в мониторинге Go-сервиса, и выбирать её стоит раньше панелей с процессором. Встроенная серия go_goroutines из [урока про метрики процесса](/courses/prometheus/lessons/11-default-metriki/) уже есть в /metrics без единой строки кода, но она отвечает на вопрос «сколько горутин у процесса», а не «сколько горутин у почтового пула» или «сколько воркеров конвертации живы прямо сейчас». Метрики goroutine собственных серий закрывают второй вопрос: датчик активных воркеров с лейблом пула, счётчик запусков, датчик на каждом фоне, чьё существование вы готовы обосновать. Практика дежурств показывает закономерность: сервисы, где горутины считаются по пулам, локализуют утечку за минуты, а сервисы с одним общим графиком — за часы, потому что стек приходится искать вручную. Добавлять серии стоит не заранее, а по списку фоновых задач: каждая строка кода с go func() перед типом — кандидат на строку с Set или Inc рядом.

### Метрики goroutine и утечка горутин: как выглядит график

Здоровый профиль стабилен и предсказуем: число горутин растёт в пик-час вместе с трафиком и возвращается к ночной отметке. Утечка горутин рисует другую картину — ступеньки, которые появляются после каждого релиза и никогда не схлопываются: какая-то горутина запускается, зависает и не завершается, а её копия рождается на каждом следующем запросе. Типичные виновники повторяются из проекта в проект: чтение из канала, который никто не закроет, запрос во внешний API без таймаута, вечный цикл с выходом по флагу, который никто не выставляет. Механика каналов разобрана в [уроке про каналы курса продвинутого Go](/courses/go-advanced/lessons/05-kanaly/), здесь важнее следствие: каждая зависшая горутина держит собственный стек и замыкания, а значит, память. Горутина занимает килобайты, поэтому тысячная утечка нужна, чтобы упасть по OOM, — зато сам счётчик выдаёт её задолго до падения, и это делает графику горутин роль раннего оповещения.

### Датчик по тикеру: runtime.NumGoroutine в своей серии

```go
var goroutinesGauge = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "app_goroutines",
        Help: "Живые горутины процесса, обновляется по тикеру",
})

func trackGoroutines(ctx context.Context) {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()
        for {
                select {
                case <-ctx.Done():
                        return
                case <-ticker.C:
                        goroutinesGauge.Set(float64(runtime.NumGoroutine()))
                }
        }
}
```

Фоновая горутина раз в пять секунд читает runtime.NumGoroutine и пишет в датчик методом Set. Выход устроен по контексту: при выключении сервиса тикер останавливается defer-ом, и фоновая горутина не становится первой утечкой, которую эта метрика должна была поймать. Честная деталь: Prometheus увидит значение последнего тика, а не момент скрейпа — как разделить эти режимы, разберём в уроке про свой Collector.

### Тикер против встроенной серии: когда своя серия оправдана

Спорный вопрос с ясной границей. Для всего процесса своя серия с NumGoroutine дублирует go_goroutines и не нужна: встроенный коллектор точнее, потому что отдаёт значение на момент скрейпа, а не по тикеру. Оправдание появляется там, где нужен срез по смыслу: датчик app_workers_active с лейблом pool показывает, что именно растёт — почтовая очередь или конвертация картинок, — тогда как общий график лишь подтверждает сам факт. Второй случай — счётчик запусков: датчик видит текущее число, но не показывает оборот, а сотня перезапусков короткоживущих горутин в минуту видна только в росте счётчика. Третий — фон, чьё существование само по себе состояние: датчик равен единице, пока воркер жив, и нулю после падения, что даёт алерт без чтения логов. Правило для ревью: каждый go func() в сервисе либо завершается гарантированно, либо получает свою серию, иначе он становится невидимым.

### Обёртка запуска: счётчик и датчики пулов

```go
var (
        workersActive = prometheus.NewGaugeVec(
                prometheus.GaugeOpts{Name: "app_workers_active", Help: "Активные воркеры пула"},
                []string{"pool"},
        )
        goroutinesStarted = prometheus.NewCounter(
                prometheus.CounterOpts{Name: "app_goroutines_started_total", Help: "Горутин запущено за жизнь процесса"},
        )
)

func goTracked(pool string, fn func()) {
        goroutinesStarted.Inc()
        workersActive.WithLabelValues(pool).Inc()
        go func() {
                defer workersActive.WithLabelValues(pool).Dec()
                fn()
        }()
}
```

goTracked заменяет прямой запуск горутин: счётчик запусков растёт монотонно, датчик пула поднимается перед запуском и опускается defer-ом при любом выходе, включая панику внутри fn. Разрыв между started_total и суммой активных воркеров — это число завершившихся горутин; если оба растут, а активные стоят на месте, фон накапливается, и это формулировка утечки в числах.

### Память: alloc против RSS и растущий зазор

Вторая половина урока — память, и здесь главная пара серий уже была в одиннадцатом уроке: go_memstats_alloc_bytes показывает живые объекты кучи, process_resident_memory_bytes — память, которую процесс занял у операционной системы. Смотреть их нужно вместе, потому что по отдельности обе врут по-своему. alloc после работы ночного джоба падает: сборщик мусора подчистил, живых объектов мало. RSS при этом может остаться на пике: рантайм не всегда возвращает страницы системе сразу, и это нормальное поведение, а не утечка. Опасный рисунок другой: alloc стабилен неделями, а RSS ползёт линейно — значит, память съедает не куча, а что-то рядом: незакрытые соединения, буферы, растущие очереди в памяти, те же зависшие горутины со своими стеками. Серия go_memstats_sys_bytes добавляет третий штрих: сколько памяти рантайм взял у системы суммарно. Практическое правило: стабильный зазор между alloc и RSS — фон, растущий — сигнал лезть в профили, а не крутить настройки GC.

Когда график нашёл подозрение, счётчиками расследование не заканчивается: нужен стек. Подключите net/http/pprof к служебному порту и откройте /debug/pprof/goroutine?debug=1 — рантайм отдаст список всех живых горутин со стеками, отсортированный так, что самый частый стек виден сразу. Поиск сводится к одному вопросу: на чём стоят зависшие горутины — chan receive, read от клиента без дедлайна, select без ветки выхода. Сопоставьте моменты ступенек с графиком аптайма из [панелей тринадцатого урока](/courses/prometheus/lessons/13-grafana-obzor/): ступенька, совпавшая с релизом, указывает на новый код, совпавшая с пиком трафика — на условие, которое срабатывает под нагрузкой. Числа для калибровки ожиданий: пара сотен горутин у веб-сервера под умеренным трафиком — норма, тысячи — повод читать профиль, а десятки тысяч почти всегда означают, что утечка уже идёт часами. Формат debug=2 дополнительно сворачивает стеки по общим корням: тот же дамп, но сгруппированный так, что видно, кто кого породил, — удобно, когда зависших горутин сотни, а виновник один.

> **Типичная ошибка: запрос без таймаута в горутине.** Горутина отправляет уведомление через http.Get стандартным клиентом: у того нет таймаута, и при затычке внешнего сервиса горутина висит минутами и часами. Очередь событий копится, на каждый элемент рождается новая горутина, график app_workers_active растёт линейно, а error-логи молчат — ошибки нет, есть только ожидание. Лечение одно: клиент с Timeout и контекст с дедлайном на каждый исходящий вызов.

### Тест в CI и алерт от базовой линии: два предохранителя

Метрика находит утечку в проде, но дешевле поймать её в CI, и для этого не нужен Prometheus — достаточно runtime.NumGoroutine. Паттерн теста из двух строк: запомните число горутин до запуска проверяемой функции, дождитесь её завершения, подождите короткий интервал на отложенные завершения и сравните. Рост при повторных запусках той же функции означает, что каждый вызов оставляет после себя фоновую горутину, и тест падает раньше, чем график в проде; такая проверка уместна для воркеров очередей, подписчиков и отправителей уведомлений — ровно тех мест, где появились свои серии. Второй предохранитель уже в проде, и это алерт. Абсолютные пороги по горутинам не работают: сто горутин для одного сервиса — течь, для другого — тишина, поэтому рабочая схема строится от базовой линии — удвоенная пиковая отметка за две недели, удерживаемая в течение получаса. Именно в течение получаса: без for-окна алерт сработает на короткий всплеск и приучит дежурного игнорировать канал, что уже обсуждалось в [уроке про алерты](/courses/prometheus/lessons/14-alerts-obzor/). Датчики пулов позволяют сузить порог до конкретной подсистемы: почтовый воркер, чьё значение выросло втрое и не падает, локализует проблему точнее, чем общий счётчик процесса.

### Серии про горутины и память: что смотреть

| Серия | Тип | Что отвечает |
| --- | --- | --- |
| go_goroutines | gauge | всё число горутин процесса на момент скрейпа |
| app_workers_active{pool} | gauge | активные воркеры конкретного пула, ваш срез |
| app_goroutines_started_total | counter | оборот горутин: сколько запущено за жизнь процесса |
| go_memstats_alloc_bytes | gauge | живые объекты кучи, то, что держит GC |
| process_resident_memory_bytes | gauge | память процесса у ОС, включая невозвращённые страницы |
| go_threads | gauge | потоки ОС под планировщик, растут на блокирующих вызовах |

> **Профиль объясняет то, что нашла метрика.** График находит сам факт накопления, но виновника называет только профиль: стек зависшей горутины в /debug/pprof/goroutine экономит часы гаданий. Держите pprof на служебном порту, закрытом от внешнего мира, — тогда в момент инцидента дамп делается одной командой curl, а не деплоем с диагностикой. Песочница сайта client_golang и pprof не исполняет: всё это проверяется на локальном Go, и это честная граница курса.

### Проверка: рисунок утечки на графике

**Вопрос:** Число горутин растёт ступеньками после каждого релиза и не возвращается к прежней отметке. О чём это говорит?

- ✅ Какая-то горутина не завершается, и утечка накапливается
- Трафик вырос, и это нормальное поведение
- Сборщик мусора перестал работать после релиза
- Метрика считается с ошибкой: gauge не может расти ступеньками

Ступенька после релиза без возврата — классический рисунок утечки: на каждый запрос рождается горутина, которая висит вечно. Нормальная нагрузка поднимает линию и опускает обратно; утечка только прибавляет.

### Проверка: alloc и RSS

**Вопрос:** go_memstats_alloc_bytes стоит на месте, а process_resident_memory_bytes вырос вдвое. Что это чаще всего значит?

- ✅ Рантайм не вернул освободившиеся страницы ОС — это часто норма
- Куча утекла, и GC не справляется
- Метрика RSS сломана и показывает мусор
- Сервис работает на двоих: второй процесс шарит память

alloc — живые объекты кучи, RSS — вся память процесса; рантайм возвращает страницы ОС не сразу. Опасен не зазор сам по себе, а его линейный рост при стабильном alloc: тогда виновник не куча, а соединения, буферы или стеки горутин.

### Что увидит Prometheus при скрейпе

```go
ticker := time.NewTicker(5 * time.Second)

go func() {
        for range ticker.C {
                goroutinesGauge.Set(float64(runtime.NumGoroutine()))
        }
}()

// Prometheus скрейпит через 2 секунды после старта
```

**Что выведет программа?**

- ✅ 0 — ни одного тика ещё не было
- Актуальное значение runtime.NumGoroutine
- Среднее число горутин за две секунды
- Значение поднимется к следующему скрейпу само

Датчик по тикеру меняется только на тике, а первый тик случится через пять секунд. На скрейпе в две секунды датчик ещё нулевой — цена подхода: значение свежести последнего тика, а не момента запроса. Collector, о котором пойдёт речь в восемнадцатом уроке, считает значение ровно на скрейпе.

### Соберите фоновый сборщик

Допишите тип таймера, остановку и метод датчика.

```go
func trackGoroutines() {
        ticker := time.New___(5 * time.Second)
        defer ticker.___()
        for range ticker.C {
                goroutinesGauge.___(float64(runtime.NumGoroutine()))
        }
}
```

Подсказка: Тикер создаётся конструктором NewTicker, останавливается методом Stop, а датчик меняет значение методом Set.

Правильные ответы в пропусках: `Ticker`, `Stop`, `Set`

### Расследование ступеньки на графике

Расставьте шаги от подозрения на графике до исправления.

Правильный порядок строк:

1. `Заметили ступеньку на графике go_goroutines`
2. `Сверьте моменты роста с графиком аптайма и релизами`
3. `Откройте /debug/pprof/goroutine?debug=1 на сервисе`
4. `Найдите самый частый стек в дампе горутин`
5. `Проверьте этот стек: канал без закрытия или запрос без таймаута`
6. `Почините выход из горутины и проверьте график после релиза`

### Карточки: горутины и память

- **go_goroutines** — Встроенный gauge из GoCollector: число живых горутин на момент скрейпа. Первая линия обороны против утечек, бесплатно в /metrics.
- **Рисунок утечки горутин** — Ступеньки после релизов без возврата к базовой линии. Нормальный профиль колеблется в пик-час и возвращается к ночной отметке.
- **alloc против RSS** — alloc — живая куча под контролем GC, RSS — память у ОС. Стабильный зазор норма, линейный рост зазора при стабильном alloc — ищите не-кучевые утечки.
- **goTracked и датчики пулов** — Обёртка запуска: счётчик запусков плюс датчик активных воркеров с лейблом pool. Dec ставится defer-ом, чтобы закрывать и панику.
- **Порог алерта по горутинам** — От базовой линии, а не от абсолюта: удвоенная пиковая отметка, удерживаемая полчаса. Абсолютные числа у каждого сервиса свои.

Практика требует локального Go: песочница сайта не исполняет ни client_golang, ни pprof, поэтому фоновый сборщик и дамп горутин проверяются на своей машине. Возьмите сервис из прошлых уроков, добавьте goTracked к одному пулу и уроните его специально: горутина, читающая из никогда не закрываемого канала, как в [уроке про каналы](/courses/go-advanced/lessons/05-kanaly/), поднимет датчик пула и оставит его там. Следующий [урок про кардинальность](/courses/prometheus/lessons/17-kardinalnost/) — о том, почему лейблы этих новых серий опаснее самих метрик, а полный маршрут курса лежит на [странице Prometheus](/courses/prometheus/). Таймеры точечных замеров уже разобраны в [пятнадцатом уроке](/courses/prometheus/lessons/15-tajmery-v-kode/), и его правило «метрика рождается при старте» действует здесь без изменений: датчики и счётчики из этого урока регистрируются один раз, а goTracked только двигает их значения.

## Коротко: что запомнить

- go_goroutines закрывает вопрос о процессе целиком; свои серии нужны для срезов по пулам и фонам.
- Утечка горутин выглядит ступеньками после релизов без возврата к базовой линии.
- Датчик по тикеру показывает значение последнего тика; счётчик запусков добавляет оборот.
- alloc — куча под контролем GC, RSS — память у ОС; растущий зазор при стабильном alloc — сигнал.
- Профиль /debug/pprof/goroutine называет виновника, который график только нашёл.
- Порог алерта строится от базовой линии и держится окном, а не абсолютным числом.
- Каждый go func() либо гарантированно завершается, либо получает свою серию.

## Домашнее задание

Добавьте в сервис из прошлых уроков обёртку goTracked со счётчиком запусков и датчиком app_workers_active{pool} для одного пула. Затем специально создайте утечку: горутина, читающая из канала, который никогда не закрывается, — и посмотрите на график датчика пула и общий go_goroutines. Снимите дамп /debug/pprof/goroutine?debug=1 и найдите стек утечки, после чего почините выход из горутины через закрытие канала или контекст. В конце постройте алерт от базовой линии: удвоенная пиковая отметка за окном в тридцать минут.

## Вопросы и ответы по уроку

### Нужна ли своя серия с NumGoroutine, если есть go_goroutines?

Для всего процесса — нет: встроенная серия точнее, потому что читается на скрейпе. Своя серия оправдана для срезов: датчики пулов с лейблом, счётчик запусков, индикатор живости конкретного фона. Общий график после этого остаётся сводкой, а виновника показывает срез.

### Сколько горутин — это много?

Порог индивидуален: пара сотен у веб-сервера под умеренным трафиком — норма, тысячам нужен профиль, десятки тысяч почти всегда утечка. Смотрите не на абсолют, а на форму: линия, которая возвращается после пика, здоровее линии с монотонным ростом при спокойном трафике.

### Почему alloc падает, а память контейнера нет?

GC очистил живые объекты, но рантайм не обязан немедленно возвращать страницы операционной системе, поэтому RSS держится на пике. Стабильный зазор — фон; растущий зазор при стабильном alloc — повод для профилей: там виноваты буферы, соединения или стеки зависших горутин.
