Утечки горутин и диагностика
Перед началом: модули 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% случаев:
- Вечный receive.
go func() { <-ch }()— канал не закрывают никогда. Классика: слушатель подсистемы, забытый при рестарте. - Вечный send.
go func() { ch <- result }()— читателя нет: результат никому не нужен, либо потребитель ушёл по таймауту. - Забытый таймер.
time.Afterв цикле создаёт новый таймер на каждой итерации;time.TickбезStopживёт вечно. До Go 1.23 несостоявшийсяtime.Afterне собирался до срабатывания. - Слушатель без 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)семплирует блокировки.
bashgo 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) — главный дашборд конкурентного
сервиса. Тревога не на абсолютное значение, а на тренд:
монотонный рост в течение часа при стабильной нагрузке.
В тестах тот же приём работает детерминированно:
gofunc 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:
- Снять два снимка goroutine-профиля с интервалом 5–10 минут.
- Сгруппировать по
debug=1и сравнить счётчики по стекам: растёт тот стек, который ищем. - Прочитать корень стека — функцию, запустившую горутину; часто
там видно подсистему (
watchConfig,runPoller,startListener). - Проверить владельца: кто должен закрыть канал или отменить
контекст и почему не сделал (ошибка в shutdown, потерянный
cancel, рестарт без остановки старой копии). - Подтвердить гипотезу на staging: воспроизвести сценарий (реконфигурация, таймаут, разрыв соединения) и увидеть утечку.
Отдельный приём — SIGQUIT: Go печатает стеки всех горутин
в stderr и завершается. В контейнере это спасает, когда pprof
недоступен: docker kill -s QUIT <id> или kill -QUIT <pid>.
17.6 Компромиссы
contextвезде vsdone-канал: контекст стандартен и композируется, но протаскивается через все сигнатуры;doneпроще локально, зато не отменяет вложенные вызовы.- Ловить в тестах vs в проде:
goleakловит раньше и дешевле, но требует аккуратных тестов; прод-метрики ловят реальные сценарии, зато после инцидента. - Профили всегда включены vs по требованию: постоянно включённый pprof — риск утечки информации и лишняя нагрузка; выключенный — недоступен в момент, когда нужен. Обычно: отдельный порт, ACL, включается на время.
- Агрессивные таймауты vs долгие операции: таймаут превращает утечку в ошибку, но может рубить легитимно долгую работу; нужен бюджет на весь запрос, а не на каждый шаг.
- Максимум горутин vs доверие коду: жёсткий лимит спасает от OOM, но превращает утечку в отказ (reject) — иногда это лучше, чем тихая деградация.
💻 Практика
- Поймайте четыре утечки. Напишите программу с вечным receive,
вечным send,
time.TickбезStopи слушателем безdone. Снимите?debug=1и найдите каждый стек; затем почините все четыре и докажите, что число горутин вернулось к исходному. - Добавьте leak-тест. Оберните функцию из задания 1 в
TestNoLeakиgoleak.VerifyNone; убедитесь, что тест падает на утечке и печатает стек виновника. - Сравните снимки. Запустите сервис с фоновым поллером, который
при каждом реконфиге запускает новую горутину и не останавливает
старую. Снимите профиль дважды с интервалом и покажите рост
по конкретному стеку; почините через
cancelстарого контекста. - Запустите сниппет модуля: две утечки и одна корректная
горутина с
done. Проверьте, что счётчик вырос ровно на 2, а корректная горутина завершилась.
Фактический вывод:
горутин: до 1, после 3 — утекло 2
утёкшие горутины держат стек, ссылки и соединения —
память «растёт без утечек в профиле», а в pprof /debug/pprof/goroutine
они висят тысячами на одном и том же chan receive
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Называть четыре типовые формы утечки горутин
- Читать goroutine-профиль и группировать стеки
- Отличать утечку от дедлока по симптомам и метрикам
- Добавлять
NumGoroutineв дашборд и тренд-алерт - Писать leak-тесты с
goleakили ручным снимком - Искать утёкшую горутину в проде по двум снимкам
- Останавливать таймеры и отменять контексты владельцем
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 18
Мы прошли весь путь: планировщик, примитивы, конвейеры, лимиты, микросервисные паттерны, брокеры и диагностику. Дальше — капстоун: краулер, в котором нет ни одного нового примитива, но все они собираются в одну систему с очередью, лимитами, отменой, shutdown и метриками. Именно там видно, что конкурентность — это не набор паттернов, а дисциплина владения жизненным циклом.