Содержание урока · 8 разделов
- Метрики goroutine и утечка горутин: как выглядит график
- Тикер против встроенной серии: когда своя серия оправдана
- Память: alloc против RSS и растущий зазор
- Тест в CI и алерт от базовой линии: два предохранителя
- Серии про горутины и память: что смотреть
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
График числа горутин — самая дешёвая страховка в мониторинге Go-сервиса, и выбирать её стоит раньше панелей с процессором. Встроенная серия go_goroutines из урока про метрики процесса уже есть в /metrics без единой строки кода, но она отвечает на вопрос «сколько горутин у процесса», а не «сколько горутин у почтового пула» или «сколько воркеров конвертации живы прямо сейчас». Метрики goroutine собственных серий закрывают второй вопрос: датчик активных воркеров с лейблом пула, счётчик запусков, датчик на каждом фоне, чьё существование вы готовы обосновать. Практика дежурств показывает закономерность: сервисы, где горутины считаются по пулам, локализуют утечку за минуты, а сервисы с одним общим графиком — за часы, потому что стек приходится искать вручную. Добавлять серии стоит не заранее, а по списку фоновых задач: каждая строка кода с go func() перед типом — кандидат на строку с Set или Inc рядом.
Метрики goroutine и утечка горутин: как выглядит график#
Здоровый профиль стабилен и предсказуем: число горутин растёт в пик-час вместе с трафиком и возвращается к ночной отметке. Утечка горутин рисует другую картину — ступеньки, которые появляются после каждого релиза и никогда не схлопываются: какая-то горутина запускается, зависает и не завершается, а её копия рождается на каждом следующем запросе. Типичные виновники повторяются из проекта в проект: чтение из канала, который никто не закроет, запрос во внешний API без таймаута, вечный цикл с выходом по флагу, который никто не выставляет. Механика каналов разобрана в уроке про каналы курса продвинутого Go, здесь важнее следствие: каждая зависшая горутина держит собственный стек и замыкания, а значит, память. Горутина занимает килобайты, поэтому тысячная утечка нужна, чтобы упасть по OOM, — зато сам счётчик выдаёт её задолго до падения, и это делает графику горутин роль раннего оповещения.
Датчик по тикеру: runtime.NumGoroutine в своей серии
GOvar 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() в сервисе либо завершается гарантированно, либо получает свою серию, иначе он становится невидимым.
Обёртка запуска: счётчик и датчики пулов
GOvar (
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 без ветки выхода. Сопоставьте моменты ступенек с графиком аптайма из панелей тринадцатого урока: ступенька, совпавшая с релизом, указывает на новый код, совпавшая с пиком трафика — на условие, которое срабатывает под нагрузкой. Числа для калибровки ожиданий: пара сотен горутин у веб-сервера под умеренным трафиком — норма, тысячи — повод читать профиль, а десятки тысяч почти всегда означают, что утечка уже идёт часами. Формат debug=2 дополнительно сворачивает стеки по общим корням: тот же дамп, но сгруппированный так, что видно, кто кого породил, — удобно, когда зависших горутин сотни, а виновник один.
Горутина отправляет уведомление через http.Get стандартным клиентом: у того нет таймаута, и при затычке внешнего сервиса горутина висит минутами и часами. Очередь событий копится, на каждый элемент рождается новая горутина, график app_workers_active растёт линейно, а error-логи молчат — ошибки нет, есть только ожидание. Лечение одно: клиент с Timeout и контекст с дедлайном на каждый исходящий вызов.
Тест в CI и алерт от базовой линии: два предохранителя#
Метрика находит утечку в проде, но дешевле поймать её в CI, и для этого не нужен Prometheus — достаточно runtime.NumGoroutine. Паттерн теста из двух строк: запомните число горутин до запуска проверяемой функции, дождитесь её завершения, подождите короткий интервал на отложенные завершения и сравните. Рост при повторных запусках той же функции означает, что каждый вызов оставляет после себя фоновую горутину, и тест падает раньше, чем график в проде; такая проверка уместна для воркеров очередей, подписчиков и отправителей уведомлений — ровно тех мест, где появились свои серии. Второй предохранитель уже в проде, и это алерт. Абсолютные пороги по горутинам не работают: сто горутин для одного сервиса — течь, для другого — тишина, поэтому рабочая схема строится от базовой линии — удвоенная пиковая отметка за две недели, удерживаемая в течение получаса. Именно в течение получаса: без for-окна алерт сработает на короткий всплеск и приучит дежурного игнорировать канал, что уже обсуждалось в уроке про алерты. Датчики пулов позволяют сузить порог до конкретной подсистемы: почтовый воркер, чьё значение выросло втрое и не падает, локализует проблему точнее, чем общий счётчик процесса.
Серии про горутины и память: что смотреть#
| Серия | Тип | Что отвечает |
|---|---|---|
| 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, и это честная граница курса.
Проверка: рисунок утечки на графике
Число горутин растёт ступеньками после каждого релиза и не возвращается к прежней отметке. О чём это говорит?
Проверка: alloc и RSS
go_memstats_alloc_bytes стоит на месте, а process_resident_memory_bytes вырос вдвое. Что это чаще всего значит?
Что увидит Prometheus при скрейпе
Прочитайте код и выберите, что он выведет:
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C {
goroutinesGauge.Set(float64(runtime.NumGoroutine()))
}
}()
// Prometheus скрейпит через 2 секунды после стартаСоберите фоновый сборщик
Допишите тип таймера, остановку и метод датчика.
Расследование ступеньки на графике
Расставьте шаги от подозрения на графике до исправления.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: горутины и память
Практика требует локального Go: песочница сайта не исполняет ни client_golang, ни pprof, поэтому фоновый сборщик и дамп горутин проверяются на своей машине. Возьмите сервис из прошлых уроков, добавьте goTracked к одному пулу и уроните его специально: горутина, читающая из никогда не закрываемого канала, как в уроке про каналы, поднимет датчик пула и оставит его там. Следующий урок про кардинальность — о том, почему лейблы этих новых серий опаснее самих метрик, а полный маршрут курса лежит на странице Prometheus. Таймеры точечных замеров уже разобраны в пятнадцатом уроке, и его правило «метрика рождается при старте» действует здесь без изменений: датчики и счётчики из этого урока регистрируются один раз, а goTracked только двигает их значения.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 — повод для профилей: там виноваты буферы, соединения или стеки зависших горутин.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
WebSocket в Go: real-time чаты и уведомления
Курс WebSocket на Go: gorilla/websocket, эхо-сервер, hub-паттерн, комнаты, авторизация соединений, ping/pong, reconnect и масштабирование. 25 уроков.
- Поднимать WS-сервер и апгрейдить HTTP-соединения
- Писать readPump/writePump без гонок на каждое соединение
- Строить hub: регистрацию, рассылку и комнаты
- Авторизовывать соединения и ограничивать сообщения