Утечка горутин в Go: как находить и чинить под нагрузкой
Сервис стабильно ест память, RSS растёт линейно, рестарт помогает на час. Виноваты забытые горутины, которые никогда не завершаются. Разбираем, как их найти через pprof и как чинить типичные причины.

Симптом: память растёт, а нагрузка — нет
Типичная картина: HTTP-сервис на Go принимает стабильные 200–300 rps, но за сутки RSS вырастает с 80 МБ до 1,2 ГБ. GC работает, heap после сборки не сильно растёт, а число горутин по метрике go_goroutines ползёт вверх линейно — 500, потом 5000, потом 40000. Это утечка горутин: код запускает go func(){...}, которые блокируются навсегда и никогда не выходят. Каждая держит свой стек (минимум 8 КБ) и всё, на что ссылается замыкание.
Речь пойдёт о поиске и устранении именно утечек — горутин, которые должны были завершиться, но не завершились. Настройка размера пулов воркеров и GOMAXPROCS — отдельная тема, здесь её не трогаем. Примеры проверены на Go 1.22–1.23.
Почему горутина не умирает
Горутина завершается, когда возвращается её функция. Не возвращается она в двух случаях: заблокирована на канале или мьютексе, которых никто не разблокирует, либо крутится в бесконечном цикле без условия выхода. Рантайм такие горутины не собирает — с точки зрения GC они живые, на них есть ссылка из планировщика.
Утечка горутины — это не баг рантайма и не проблема сборщика мусора. Это ваш код, который начал асинхронную работу и не предусмотрел её конца.
Как найти: три инструмента по возрастанию точности
1. Счётчик горутин — есть ли проблема вообще
Первое, что нужно понять: количество горутин действительно растёт монотонно или просто скачет под нагрузкой. Прометеевский экспортер отдаёт go_goroutines, но можно и без него — прямо в коде:
// Go 1.22, периодический лог числа горутин
package main
import (
"log"
"runtime"
"time"
)
func startGoroutineMonitor() {
ticker := time.NewTicker(10 * time.Second)
go func() {
for range ticker.C {
log.Printf("goroutines=%d", runtime.NumGoroutine())
}
}()
}Если goroutines после спада нагрузки не возвращается к базовому уровню, а остаётся на новой высоте — утечка есть. Ровное плато под постоянной нагрузкой это норма, ступеньки вверх без спуска — нет.
2. pprof — где именно горутины застряли
Профилировщик net/http/pprof показывает стек каждой активной горутины. Подключается одной строкой импорта с побочным эффектом:
// Go 1.22, поднимаем pprof на отдельном порту
package main
import (
"net/http"
_ "net/http/pprof" // регистрирует хендлеры на DefaultServeMux
)
func startPprof() {
// отдельный порт, не публичный — pprof наружу не выставляют
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
}Дальше — снимок с группировкой по стекам. Параметр debug=1 даёт агрегированный список: сколько горутин застряло в каждой точке кода.
curl "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" > goroutines.txtВ начале файла увидите строки вида N @ 0x... — это N горутин с одинаковым стеком. Ищите большие N в местах вашего кода. Если 39000 горутин висят на chan receive в одной функции — вот она, точка утечки.
3. Сравнение двух снимков
Один снимок показывает состояние, но не динамику. Сделайте два с интервалом в несколько минут под нагрузкой и сравните — растущая группа и есть протечка. Для машинной обработки берите профиль без debug (protobuf) и открывайте в go tool pprof:
# снимок 1
curl -s http://127.0.0.1:6060/debug/pprof/goroutine > g1.pb.gz
# ждём 5 минут под нагрузкой
curl -s http://127.0.0.1:6060/debug/pprof/goroutine > g2.pb.gz
# diff между снимками
go tool pprof -http=:8080 -diff_base=g1.pb.gz g2.pb.gzЧетыре типичные причины и как чинить
Причина 1: отправка в небуферизованный канал без читателя
Классика — воркер завершился по таймауту, а горутина пытается вернуть ему результат:
// Утечка: если вызывающий ушёл по таймауту, эта горутина зависнет навсегда
func fetch(url string) string {
ch := make(chan string) // небуферизованный
go func() {
result := doHTTP(url) // долгий запрос
ch <- result // некому читать после таймаута — блок навсегда
}()
select {
case r := <-ch:
return r
case <-time.After(2 * time.Second):
return "" // ушли, горутина осталась висеть на ch <- result
}
}Починка — буфер на один элемент, чтобы отправитель не блокировался, даже если читателя уже нет:
func fetch(url string) string {
ch := make(chan string, 1) // буфер 1 — отправка не заблокируется
go func() {
ch <- doHTTP(url) // положит в буфер и выйдет, даже если select ушёл
}()
select {
case r := <-ch:
return r
case <-time.After(2 * time.Second):
return ""
}
}Причина 2: горутина не слушает context.Done
Долгий цикл или блокирующая операция запущены без реакции на отмену контекста. Когда запрос отменён, работа продолжается.
// Правильно: цикл проверяет отмену и выходит
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return // контекст отменён — выходим
case job, ok := <-jobs:
if !ok {
return // канал закрыт — тоже выходим
}
process(job)
}
}
}Ключевое: у любой долгоживущей горутины должен быть явный путь выхода — либо ctx.Done(), либо закрытие входного канала.
Причина 3: забытый time.Ticker
time.NewTicker держит ресурс, пока не вызван Stop. Если горутина с тикером живёт вечно из-за причин 1 или 2, тикер тоже не собирается. Всегда закрывайте через defer:
ticker := time.NewTicker(time.Minute)
defer ticker.Stop() // без этого — утечка при выходе из функцииПричина 4: range по каналу, который никто не закрывает
Конструкция for v := range ch завершается только при close(ch). Если продюсер упал с паникой в другой горутине и до close не дошёл, потребитель повиснет. Закрывать канал должен тот, кто пишет, и делать это через defer сразу после запуска записи.
Таблица: что где искать в pprof
| Строка в стеке pprof | Вероятная причина | Что проверить |
|---|---|---|
| chan receive | ждёт данные, которых не будет | кто должен писать и закрывать канал |
| chan send | пишет в канал без читателя | буфер канала, ушёл ли читатель по таймауту |
| select (no cases) | select без default и живых веток | есть ли ветка ctx.Done() |
| semacquire | ждёт мьютекс или WaitGroup | парность Lock/Unlock, Add/Done |
| IO wait | висит на сети без дедлайна | SetDeadline на соединении, таймаут в http.Client |
Граничные случаи, о которых забывают
- Не всякая долгоживущая горутина — утечка. Пул воркеров, который живёт всё время работы процесса, в pprof выглядит как N застрявших горутин на
chan receive. Это норма, если N фиксировано. Утечка — когда N растёт. - WaitGroup с недобором Done. Если
wg.Add(3), аDone()вызывается только дважды из-за раннегоreturn, горутина наwg.Wait()зависнет навсегда. Ставьтеdefer wg.Done()первой строкой в теле. - http.Client без таймаута. Клиент по умолчанию (
http.DefaultClient) не имеет общего таймаута. Медленный ответ сервера держит горутину и соединение. ЗадавайтеTimeoutу клиента явно. - Тесты. Утечку ловят до прода. Пакет
go.uber.org/goleakпроверяет вTestMain, что после теста не осталось лишних горутин, и валит тест, если остались.
Что делать дальше
Первым делом добавьте в сервис метрику go_goroutines и алерт на её монотонный рост за час — это дешевле, чем ловить утечку по OOM в три часа ночи. Дальше подключите goleak в тесты пакетов, которые запускают горутины, чтобы регресс не проходил в мастер. В код-ревью держите один вопрос к каждому go func: как эта горутина завершится и кто гарантирует, что вход в неё закроется или контекст отменится. Если ответа нет — это будущая утечка.
Источники: Package pprof — net/http/pprof, документация Go, Diagnostics — Goroutine profiling, go.dev
Частые вопросы
У меня Go 1.20, а не 1.22 — примеры подойдут?
Число горутин растёт, но pprof показывает их в стандартной библиотеке, а не в моём коде
Может ли утечка горутин вызвать OOM, если heap стабилен?
Безопасно ли включать pprof в проде?
Добавил буфер в канал, а горутины всё равно копятся — почему?
Как отличить нормальный пул воркеров от утечки в pprof?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.