Утечки горутин и диагностика

Перед началом: модули 3, 4, 11, 15 (каналы, select, shutdown, кэш). Инструменты: Go 1.22+, go tool pprof, net/http/pprof, go.uber.org/goleak для тестов. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: 40 000 горутин ночью и «чистая» куча

Сервис стабильно работал днём и к утру раздувался: RSS рос на гигабайт за ночь, число горутин доходило до 40 000, к обеду контейнер перезапускался по OOM. Heap-профиль показывал норму — утечек выделенных объектов не было. GC отрабатывал, куча после него чистая.

Сняли pprof /debug/pprof/goroutine?debug=1 — и увидели 30 000 горутин с одинаковым стеком: chan receive в обработчике, который ждал результат от подсистемы, уже пересозданной при реконфигурации. Каждая такая горутина держала стек (от 2 до 8 КБ), ссылки на запрос и клиента, открытые соединения. Память «росла без утечек в heap», потому что горутина — это не объект в куче, а стек плюс корни, до которых GC не добирается, пока горутина жива.

Утечка была не в памяти, а в жизненном цикле: у горутины не было пути выхода. Диагностика началась не с heap, а с goroutine-профиля.

📖 Теория

Горутина утекает, когда навсегда блокируется на примитиве, который никто не разбудит. Планировщик не может её убить: только сама горутина решает выйти. Значит, у каждой горутины должен быть видимый путь к завершению — done, ctx.Done(), закрытый канал.

17.1 Типовые утечки: receive, send, таймер, слушатель

Четыре формы, которые встречаются в 90% случаев:

  1. Вечный receive. go func() { <-ch }() — канал не закрывают никогда. Классика: слушатель подсистемы, забытый при рестарте.
  2. Вечный send. go func() { ch <- result }() — читателя нет: результат никому не нужен, либо потребитель ушёл по таймауту.
  3. Забытый таймер. time.After в цикле создаёт новый таймер на каждой итерации; time.Tick без Stop живёт вечно. До Go 1.23 несостоявшийся time.After не собирался до срабатывания.
  4. Слушатель без done. Подписка на события без Unsubscribe или без <-ctx.Done(): подписчик живёт дольше подписки.
go
// Плохо: пути выхода нет — горутина живёт, пока жив процесс.
go func() { <-results }()

// Хорошо: владелец закрывает канал, горутина выходит.
go func() {
    defer wg.Done()
    for {
        select {
        case r, ok := <-results:
            if !ok {
                return
            }
            handle(r)
        case <-ctx.Done():
            return
        }
    }
}()

Правило: горутина не должна переживать функцию, которая её запустила, без явного контракта (и этот контракт — done/ctx).

17.2 pprof: goroutine, block, mutex

Три профиля закрывают почти всю диагностику:

  • goroutine — снимок стеков всех горутин. ?debug=1 группирует по стеку и показывает счётчики: тысячи одинаковых chan receive — это и есть утечка. ?debug=2 даёт полные стеки каждой горутины.
  • block — где горутины ждут на каналах и мьютексах; включается runtime.SetBlockProfileRate(1) (в проде — выборочно, это дорого).
  • mutex — конкуренция за мьютексы; SetMutexProfileFraction(n) семплирует блокировки.
bash
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -50

Смотрите на прирост между снимками, а не на абсолют: 200 горутин на select — норма для HTTP-сервера; те же 200, выросшие до 20 000 за час, — утечка. В debug=1 важен и корень стека: он показывает, кто именно запустил горутину и не дал ей выйти.

17.3 Метрики и leak-тест-паттерн

Постоянная метрика runtime.NumGoroutine() (или go_goroutines из Prometheus) — главный дашборд конкурентного сервиса. Тревога не на абсолютное значение, а на тренд: монотонный рост в течение часа при стабильной нагрузке.

В тестах тот же приём работает детерминированно:

go
func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()
    doWork(context.Background())
    for i := 0; i < 50; i++ { // дать горутинам шанс выйти
        if runtime.NumGoroutine() <= before {
            return
        }
        time.Sleep(10 * time.Millisecond)
    }
    buf := make([]byte, 1<<20)
    n := runtime.Stack(buf, true)
    t.Fatalf("утекло %d горутин:\n%s",
        runtime.NumGoroutine()-before, buf[:n])
}

goleak.VerifyNone(t) делает то же самое аккуратнее: игнорирует известные системные горутины и печатает стеки утёкших. Тест ловит утечку за секунды вместо ночного инцидента.

17.4 Дедлоки vs утечки

Это разные отказы, и лечатся они по-разному:

СимптомДедлокУтечка
Кто страдаетвесь процесс или подсистемапамять и ресурсы
Видно в pprofвсе горутины спятчасть висит, часть работает
Метрикатрафик упал до нулягорутины растут, трафик идёт
Причинацикл ожиданиянет пути выхода

Дедлок — это утечка, в которой участвовали все горутины: никто не может продолжить. Go runtime сам обнаруживает полный дедлок (all goroutines are asleep - deadlock!) и паникует; но частичный дедлок (подсистема ждёт сама себя, остальной сервис жив) runtime не видит — его находят по стекам и таймаутам.

Практическое следствие: таймаут — это лекарство от обоих. Любая операция ожидания с дедлайном превращает вечную утечку в ошибку, которую видно в метриках и логах.

17.5 Поиск в проде по стекам

Порядок действий при росте go_goroutines:

  1. Снять два снимка goroutine-профиля с интервалом 5–10 минут.
  2. Сгруппировать по debug=1 и сравнить счётчики по стекам: растёт тот стек, который ищем.
  3. Прочитать корень стека — функцию, запустившую горутину; часто там видно подсистему (watchConfig, runPoller, startListener).
  4. Проверить владельца: кто должен закрыть канал или отменить контекст и почему не сделал (ошибка в shutdown, потерянный cancel, рестарт без остановки старой копии).
  5. Подтвердить гипотезу на staging: воспроизвести сценарий (реконфигурация, таймаут, разрыв соединения) и увидеть утечку.

Отдельный приём — SIGQUIT: Go печатает стеки всех горутин в stderr и завершается. В контейнере это спасает, когда pprof недоступен: docker kill -s QUIT <id> или kill -QUIT <pid>.

17.6 Компромиссы

  • context везде vs done-канал: контекст стандартен и композируется, но протаскивается через все сигнатуры; done проще локально, зато не отменяет вложенные вызовы.
  • Ловить в тестах vs в проде: goleak ловит раньше и дешевле, но требует аккуратных тестов; прод-метрики ловят реальные сценарии, зато после инцидента.
  • Профили всегда включены vs по требованию: постоянно включённый pprof — риск утечки информации и лишняя нагрузка; выключенный — недоступен в момент, когда нужен. Обычно: отдельный порт, ACL, включается на время.
  • Агрессивные таймауты vs долгие операции: таймаут превращает утечку в ошибку, но может рубить легитимно долгую работу; нужен бюджет на весь запрос, а не на каждый шаг.
  • Максимум горутин vs доверие коду: жёсткий лимит спасает от OOM, но превращает утечку в отказ (reject) — иногда это лучше, чем тихая деградация.

💻 Практика

  1. Поймайте четыре утечки. Напишите программу с вечным receive, вечным send, time.Tick без Stop и слушателем без done. Снимите ?debug=1 и найдите каждый стек; затем почините все четыре и докажите, что число горутин вернулось к исходному.
  2. Добавьте leak-тест. Оберните функцию из задания 1 в TestNoLeak и goleak.VerifyNone; убедитесь, что тест падает на утечке и печатает стек виновника.
  3. Сравните снимки. Запустите сервис с фоновым поллером, который при каждом реконфиге запускает новую горутину и не останавливает старую. Снимите профиль дважды с интервалом и покажите рост по конкретному стеку; почините через cancel старого контекста.
  4. Запустите сниппет модуля: две утечки и одна корректная горутина с done. Проверьте, что счётчик вырос ровно на 2, а корректная горутина завершилась.

Фактический вывод:

горутин: до 1, после 3 — утекло 2
утёкшие горутины держат стек, ссылки и соединения —
память «растёт без утечек в профиле», а в pprof /debug/pprof/goroutine
они висят тысячами на одном и том же chan receive

🧩 Quiz

1. Чем дедлок отличается от утечки?
2. Какой профиль первым покажет утечку горутин?
3. Когда горутина считается утёкшей?
4. Что делает горутину безопасной от утечки?
5. Почему heap-профиль может быть «чистым» при утечке горутин?

✅ Итоги модуля

Студент должен:

  • Называть четыре типовые формы утечки горутин
  • Читать goroutine-профиль и группировать стеки
  • Отличать утечку от дедлока по симптомам и метрикам
  • Добавлять NumGoroutine в дашборд и тренд-алерт
  • Писать leak-тесты с goleak или ручным снимком
  • Искать утёкшую горутину в проде по двум снимкам
  • Останавливать таймеры и отменять контексты владельцем
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 18

Мы прошли весь путь: планировщик, примитивы, конвейеры, лимиты, микросервисные паттерны, брокеры и диагностику. Дальше — капстоун: краулер, в котором нет ни одного нового примитива, но все они собираются в одну систему с очередью, лимитами, отменой, shutdown и метриками. Именно там видно, что конкурентность — это не набор паттернов, а дисциплина владения жизненным циклом.