Утечка горутин в Go: где они прячутся и как найти
Горутина висит, пока не завершится или не вернётся из функции. Забытый канал, отменённый запрос без остановки фоновой работы — и число горутин растёт линейно с трафиком, пока сервис не съест память.

Сервис на Go держит ровно до момента, пока число горутин не начинает расти линейно с трафиком. В pprof на живом проде видно 40 000 горутин при 200 RPS, все в состоянии chan receive или select, память ползёт вверх, GC работает вхолостую — освобождать нечего, потому что каждая застрявшая горутина держит замыкание, буфер и, нередко, соединение. Это утечка горутин: функция запущена через go, но никогда не вернётся, потому что ждёт события, которое уже не произойдёт.
Разбираем на Go 1.22–1.23, где семантика каналов, context и цикла for range актуальна. Смотрим три конкретных источника утечки, как их обнаружить и как закрыть. Гонки данных и профилирование CPU — отдельные темы, здесь только застрявшие горутины и рост их числа.
Почему горутина не умирает сама
Рантайм Go не убивает горутины по таймауту и не собирает их сборщиком мусора. Горутина живёт, пока её функция не дойдёт до return или не запаникует. Если внутри стоит блокирующая операция без выхода — отправка в канал, который никто не читает, приём из канала, куда никто не пишет, или ожидание WaitGroup, которая не досчитается, — горутина застревает навсегда.
Опасность в том, что каждая такая горутина по отдельности почти ничего не весит: стек стартует с 8 КБ. Проблема — в накоплении. При утечке на каждый входящий запрос за сутки на нагруженном сервисе набегают сотни тысяч застрявших горутин, и вместе с ними — всё, что они держат в замыканиях.
Утечка горутин почти никогда не проявляется на тесте с одним запросом. Она видна только под потоком трафика и во времени — поэтому её ловят в проде, а не в CI.
Источник первый: запись в канал без читателя
Классика — функция запускает горутину-продюсер, а сама выходит раньше времени, например по таймауту. Продюсер пытается записать результат в небуферизованный канал, но получателя уже нет.
// Утечка: при таймауте никто не читает из ch,
// и горутина навсегда виснет на ch <- result
func fetch(ctx context.Context, url string) (string, error) {
ch := make(chan string)
go func() {
result := slowCall(url) // долгий сетевой вызов
ch <- result // блокируется, если получатель ушёл
}()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return "", ctx.Err() // вышли, горутина осталась висеть
}
}Когда сработал ctx.Done(), функция вернула ошибку, но горутина продолжает выполнять slowCall, а потом навсегда застрянет на ch <- result: читателя больше нет. При каждом таймауте — плюс одна вечная горутина.
Решение — буферизованный канал на один элемент. Тогда запись не блокируется, даже если получатель ушёл, а буфер вместе с горутиной уберёт сборщик мусора после её завершения.
func fetch(ctx context.Context, url string) (string, error) {
ch := make(chan string, 1) // буфер 1: запись не заблокируется
go func() {
ch <- slowCall(url)
}()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return "", ctx.Err() // горутина допишет в буфер и завершится
}
}Источник второй: фоновая работа игнорирует отмену
Горутина запускается на всё время жизни запроса или соединения, но не слушает ctx.Done(). Клиент отвалился, контекст отменён, а фоновый цикл продолжает крутиться и держать ресурсы.
// Утечка: тикер крутится вечно, даже когда ctx отменён
func watch(ctx context.Context, id string) {
ticker := time.NewTicker(time.Second)
for range ticker.C {
poll(id) // никто не смотрит на ctx
}
}Здесь два дефекта сразу: цикл не реагирует на отмену и ticker не останавливается — Stop нужен всегда, иначе таймер удерживает ресурсы рантайма. Правильный вариант слушает контекст и чистит за собой.
func watch(ctx context.Context, id string) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop() // освобождаем таймер при выходе
for {
select {
case <-ticker.C:
poll(id)
case <-ctx.Done():
return // отмена — выходим и завершаем горутину
}
}
}Правило: любая долгоживущая горутина должна иметь способ узнать, что пора остановиться. Обычно это context.Context, реже — отдельный канал done. Если такого способа нет, горутина будет жить вечно.
Источник третий: WaitGroup, которая не досчитается
Собираете результаты из нескольких горутин через sync.WaitGroup и wg.Wait(). Одна из горутин выходит по ошибке до вызова wg.Done() или вы вызвали Add не на то число — и Wait блокируется навсегда вместе со всеми, кто его ждёт.
Частая ошибка — вызвать wg.Add(1) внутри уже запущенной горутины, а не до go. Тогда Wait может проскочить до того, как счётчик увеличится, и наоборот — Done без парного Add уронит программу паникой. Add вызывается строго до go, а Done ставится через defer первой строкой горутины, чтобы он сработал при любом выходе, включая панику.
Где подход ломается на границах
- Небуферизованный канал в fan-out. Если писателей несколько, а читатель ушёл после первого результата, буфер на 1 спасёт только одного писателя. Под остальных нужен буфер по числу писателей либо отмена через контекст.
- Вложенные горутины. Внешняя завершилась корректно, но запустила внутреннюю без передачи контекста — и внутренняя утекает независимо. Контекст пробрасывается вниз по всей цепочке.
- time.After в цикле. Таймер из
time.Afterне собирается, пока не сработает. В горячем цикле сselectэто тоже утечка ресурсов; используйте переиспользуемыйTimerсReset.
Как поймать утечку в работающем сервисе
Ставите на диагностику число горутин и смотрите его во времени. Если оно растёт монотонно и не падает после спада трафика — утечка.
- Проверьте текущее число горутин:
runtime.NumGoroutine()вернёт его в любой момент. Логируйте это значение с интервалом и стройте график. - Подключите
net/http/pprof: анонимный импорт_ "net/http/pprof"регистрирует обработчики на стандартномhttp.DefaultServeMux. Убедитесь, что этот порт не торчит наружу — только внутренний доступ. - Снимите профиль горутин: откройте
/debug/pprof/goroutine?debug=1для сводки по состояниям или?debug=2для полных стеков каждой горутины. Ищите одинаковые стеки, застрявшие наchan send,chan receiveилиselect— тысячи горутин в одной точке кода и есть источник. - Сравните два профиля с интервалом в несколько минут под нагрузкой. Стек, число которого выросло, — ваш кандидат.
Для тестов есть goleak от Uber: вызов goleak.VerifyNone(t) в конце теста падает, если после теста остались лишние горутины. Это ловит утечку в CI на конкретном сценарии, но не заменяет наблюдение в проде — многие утечки видны только под потоком запросов.
Что и как мерить
| Показатель | Где взять | Тревожный признак |
|---|---|---|
| Число горутин | runtime.NumGoroutine(), метрика go_goroutines | Монотонный рост, не падает после спада трафика |
| Стеки горутин | /debug/pprof/goroutine?debug=2 | Тысячи одинаковых стеков в chan receive/select |
| Heap | /debug/pprof/heap | Рост живой памяти при ровном RPS |
Что делать дальше
Заведите метрику go_goroutines в вашей системе мониторинга (её отдаёт стандартный экспортёр Prometheus для Go) и поставьте алерт на устойчивый рост. Прогоните ключевые тесты с goleak.VerifyNone и добавьте это в CI для пакетов, где запускаются горутины. Дальше — правило на ревью: каждая go func в коде должна иметь ответ на вопрос «как эта горутина завершится», и если ответа нет, изменение не проходит.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Package pprof — Go standard library, Package context — Go standard library
Частые вопросы
Собирает ли сборщик мусора застрявшие горутины?
У меня Go 1.20, а не 1.22 — приёмы сработают?
Я добавил буфер на 1, а горутины всё равно растут — почему?
NumGoroutine показывает пару сотен горутин — это утечка?
Безопасно ли держать pprof включённым в проде?
goleak в тестах гарантирует, что утечек нет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.