Утёкшие горутины теперь ищет сборщик мусора. Проверил, где он молчит
- вторник, 1 сентября 2026 г. в 00:00:13
Пятьдесят из шестидесяти трёх горутин в моём тестовом сервисе не проснутся никогда. Я узнал это одной командой, без чтения дампов и без гадания по стекам.
19 августа вышел Go 1.27, и в нём из эксперимента вышел профиль goroutineleak. В релиз-ноутах ему отведено полтора абзаца, что для такой штуки маловато. Я собрал тулчейн, написал под него десяток мелких программ и полез в runtime смотреть, как это устроено внутри. Ниже то, что получилось.
Коротко для тех, кто спешит:
Профиль показывает только те горутины, про которые рантайм доказал, что разбудить их некому. Ложных срабатываний в нём нет по построению.
Ловит блокировки на каналах, мьютексах, WaitGroup и sync.Cond. Не ловит сон, сеть, сисколлы и всё, где висит таймер.
Молчит, когда канал или контекст остался достижимым из живого кода. Реестр подписчиков в глобальной переменной ослепляет детектор полностью.
Стоит одного принудительного цикла GC. На ста тысячах горутин STW-пауза вырастает до 21 мс, так что в скрейп его лучше не ставить.
Ничего не чинит: горутины остаются, память остаётся.
Все примеры прогнаны на go1.27.0 linux/amd64, GOMAXPROCS=2. Код можно копировать и запускать.
Обработчик, который считает что-то в фоне и ждёт результат с таймаутом. Такой код я видел в каждом втором сервисе, писал его сам и ревьюил десятки раз:
func handler(w http.ResponseWriter, r *http.Request) { result := make(chan string) go func() { time.Sleep(20 * time.Millisecond) // работа идёт дольше таймаута result <- "done" }() select { case s := <-result: w.Write([]byte(s)) case <-time.After(time.Millisecond): w.Write([]byte("timeout")) } }
Канал небуферизованный. Таймаут сработал, обработчик вернулся, читателя у канала больше нет, фоновая горутина остаётся на строке отправки навсегда. Рядом крутится пул из восьми воркеров, каждый честно висит на for job := range p.jobs.
Пятьдесят запросов, и картина такая:
NumGoroutine(): 63 профиль goroutine: 63 профиль goroutineleak: 50 goroutineleak profile: total 50 50 @ 0x48860a 0x41705c 0x416c57 0x691716 0x48f4a1 # 0x691715 main.handler.func1+0x35 /app/main.go:32
Один стек, один номер строки. Воркеры пула, внутренности net/http и служебные горутины рантайма в отчёт не попали, хотя половина из них тоже стоит на chan receive.
Вот эта разница между 63 и 50 и есть весь смысл затеи.
Возьмите воркер пула и утёкшую горутину, посмотрите на них в дампе. Оба выглядят так:
goroutine 24 [chan receive]:
Для планировщика они неразличимы. Оба в _Gwaiting, оба висят через sudog в очереди канала, оба не жгут CPU. Вся разница снаружи: у одного канала есть писатель, который однажды до него доберётся, у второго писателя нет и не будет.
Поэтому все инструменты до сих пор работали по косвенным признакам. NumGoroutine() растёт, значит что-то течёт, а где именно, разбирайтесь сами. goleak сравнивает снимки до и после теста и требует, чтобы после теста не осталось ни одной горутины: в юнит-тестах помогает, к живому сервису с пулами неприменим совсем. SIGQUIT вываливает всё разом и оставляет разбор человеку.
Она формулируется одной фразой:
Если горутина заблокирована на примитиве синхронизации, и до этого примитива нельзя добраться ни от одной горутины, способной когда-либо выполниться, то разбудить её некому.
Дальше очевидное. Разбудить может только код, который выполнит операцию над тем же каналом или мьютексом. Чтобы выполнить операцию, нужна ссылка. Нет ссылки ни у кого из тех, кто ещё способен работать, значит операции не будет никогда.
А вопрос «есть ли путь по указателям до объекта» сборщик мусора решает каждый цикл. Ему для этого ничего доделывать не надо, надо только запустить обход от других корней.
Красивое сведение задачи, и именно оно позволило встроить детектор в рантайм малой кровью.
Обычная маркировка берёт корнями стеки всех горутин. Детектор стартует иначе.
Сначала в корни попадают только те горутины, которые рантайм считает способными выполниться: всё, что не в _Gwaiting, плюс те, кто ждёт по внутренним причинам вроде netpoller или таймера. Стеки горутин, заблокированных на каналах и sync, в корни не идут.
Дальше обычная маркировка. Когда она сходится, рантайм смотрит на заблокированные горутины: если примитив, на котором висит горутина, оказался помечен, значит до него кто-то дотягивается, и горутина возвращается в корни. Маркировка идёт снова, и так до неподвижной точки. Что осталось непомеченным, объявляется утечкой и получает свежий статус _Gleaked.
Последним шагом маркировка проходит ещё раз, теперь уже с утёкшими горутинами в корнях, чтобы куча была помечена целиком и цикл сборки закончился нормально.
Итерации существуют ради точности, и это самое интересное место. Без них отчёт был бы завален ложными срабатываниями:
c1 := make(chan int) c2 := make(chan int) // G1 ждёт c1, который держит main, и держит на своём стеке c2 go func() { <-c1 c2 <- 1 }() // G2 ждёт c2, который виден только со стека G1 go func() { <-c2 }()
На первом проходе помечено то, что достижимо от main. Значит c1 помечен, а c2 нет: единственная ссылка на него лежит в кадре G1, чей стек в корни не попал. Наивная проверка объявила бы G2 утечкой и была бы неправа. Рантайм сначала возвращает в корни G1, потому что её канал помечен, маркировка от стека G1 помечает c2, и на следующей итерации G2 признаётся живой. Профиль на этом коде честно показывает ноль.
В обратную сторону работает так же аккуратно. Две горутины, замкнутые друг на друга и отрезанные от программы, уезжают в отчёт вместе:
func leakChain() { a := make(chan int) b := make(chan int) go func() { <-a }() go func() { <-b; a <- 1 }() }
Ни a, ни b не достижимы, обе горутины остаются непомеченными до конца.
Отсюда главное свойство: ложное срабатывание тут невозможно. Горутина попадает в отчёт, только когда доказано отсутствие пути до её примитива от любого исполняемого кода. Обратное, увы, неверно.
Кандидатов ровно одиннадцать. Они лежат непрерывным диапазоном констант waitReason в runtime/runtime2.go, а решение принимает функция isMaybeRunnable в runtime/mgc.go:
Причина ожидания | Что это |
|---|---|
| чтение из nil-канала |
| запись в nil-канал |
| пустой |
| обычный select |
| чтение из канала |
| запись в канал |
| ожидание на условной переменной |
| захват мьютекса |
| захват на чтение |
| захват на запись |
| ожидание группы |
Неочевидные строки я проверил отдельно. sync.Cond.Wait, которую никто не разбудит, апгрейд RLock под собственным Lock, чтение из nil-канала и голый select {} попадают в профиль все четыре.
Остальные причины рантайм считает внутренними, и такие горутины сразу идут в корни. Практически это значит вот что:
time.Sleep любой длительности профиль не заметит. Горутина проснётся, формально всё честно.
select с веткой <-time.After(...) тоже не заметит. Таймер сработает, горутина поедет дальше.
Горутина, повисшая на чтении из TCP-соединения, которое никто не закроет, детектору не видна вообще.
Cgo и бесконечные циклы без блокировок мимо.
Второй пункт стоит перечитать. Таймаут внутри горутины выводит её из-под наблюдения профиля целиком, а таймауты мы ставим как раз в подозрительных местах. Пустой отчёт поэтому не означает, что утечек нет.
Ложных срабатываний нет, зато пропуски есть, и все они одинаковые: примитив остался достижимым, хотя работать с ним никто не собирается.
type Service struct { ctx context.Context cancel context.CancelFunc } var svc *Service func ctxNeverCancelled() { ctx, cancel := context.WithCancel(context.Background()) svc = &Service{ctx: ctx, cancel: cancel} go func() { <-ctx.Done() }() }
Горутина ждёт отмены, которой не будет. Контекст лежит в глобальной переменной, путь до него есть, значит с точки зрения детектора горутина в любой момент может проснуться. В отчёт она не попадает.
Тот же результат с каналами в полях долгоживущих структур и с реестрами вида map[string]chan Event, из которых забыли убрать запись. Чем больше примитивов синхронизации живёт в глобальном состоянии, тем слепее детектор. Это, пожалуй, главная новость статьи для тех, кто пишет сервисы: инструмент видит ровно то, что вы ему разрешили видеть своей архитектурой.
Точность детектора совпадает с точностью анализа живости в компиляторе, вплоть до мелочей. Вот эксперимент, который меня озадачил минут на пятнадцать:
park := make(chan int) go func() { ch := make(chan int) go func() { <-ch }() <-park }() time.Sleep(100 * time.Millisecond) fmt.Println("утечек:", leakCount())
Если после снятия профиля park в main больше нигде не используется, отчёт показывает две утечки. Если добавить работу с park после профиля, отчёт показывает одну. Переменная, до которой код уже не дотянется, для сборщика мертва, и детектор это наследует. Обе цифры правильные.
Дочерняя горутина при этом попадает в отчёт в обоих вариантах, и вот это уже тонко: ch в кадре внешней горутины после go-выражения тоже мёртв, маркировка до него не доходит.
Пункт, который стоит проговорить вслух, потому что название профиля намекает на большее. Пять тысяч горутин, каждая держит буфер на 64 КБ:
куча до профиля: 315 МБ найдено утечек: 5000 куча после: 315 МБ горутин осталось: 5001
Детектор их пометил и на этом закончил. Стеки на месте, память на месте, горутины по-прежнему в allgs. В дизайне это записано явно: утёкшие горутины остаются корнями маркировки, чтобы куча оставалась консистентной. Убивать заблокированные горутины рантайм не станет, и правильно сделает.
Снятие профиля запускает отдельный цикл GC с изменёнными корнями, а потом обычный goroutine-профиль. В gctrace этот цикл видно по пометке:
gc 10 @0.829s 11% (checking for goroutine leaks): 21+104+0.005 ms clock, ...
Замеры на двух конфигурациях:
5 000 горутин, куча 207 МБ | 100 000 горутин, куча 518 МБ | |
|---|---|---|
| 21 мс | 126 мс |
профиль | 8 мс | 186 мс |
профиль | 28 мс | 335 мс |
STW-фаза цикла детекции | 0,78 мс | 21 мс |
STW обычного forced GC | 0,06 мс | 0,06 мс |
По общему времени всё предсказуемо: цикл GC плюс снятие goroutine-профиля. Честно говоря, я ожидал худшего.
Смотреть надо на предпоследнюю строку. Stop-the-world растёт вместе с числом горутин, потому что перед маркировкой рантайм перебирает весь allgs. На ста тысячах горутин это 21 мс паузы вместо привычных десятков микросекунд, и такое на латентности видно невооружённым глазом.
Отсюда правила эксплуатации, которые я бы соблюдал. Эндпоинт держать как ручку для ручной диагностики. Периодическое снятие раз в несколько минут допустимо. В прометеевский скрейп на 10 секунд не ставить, особенно на сервисе с большой кучей. Параллельные запросы сериализуются мьютексом внутри runtime/pprof, так что три одновременных скрейпа выстроятся в очередь из трёх принудительных сборок.
Из кода:
p := pprof.Lookup("goroutineleak") p.WriteTo(os.Stdout, 1) fmt.Println(p.Count())
Count() возвращает число от последнего цикла детекции, поэтому дёргать его имеет смысл после WriteTo.
По HTTP всё регистрируется само, если импортирован net/http/pprof:
/debug/pprof/goroutineleak?debug=1
debug=1 печатает агрегированные стеки, debug=0 отдаёт бинарный формат, debug=2 ведёт себя не как у остальных профилей: он дампит стеки всех горутин процесса и помечает утёкшие прямо в заголовке.
goroutine 7 [chan receive]: main.ctxNeverCancelled.func1() /app/main.go:25 +0x25 goroutine 8 [chan send (leaked)]: main.blockedSend.func1() /app/main.go:31 +0x1e goroutine 9 [select (leaked)]: main.blockedSelect.func1() /app/main.go:38 +0x4c goroutine 10 [select]: main.selectWithTimeout.func1() /app/main.go:49 +0x65
Пометка (leaked) приезжает из traceback.go и появляется у горутин в статусе _Gleaked. Статус пересчитывается каждый раз: перед новым проходом все _Gleaked возвращаются в _Gwaiting, так что вы всегда видите свежую картину.
Бинарный профиль жуётся штатным go tool pprof со всеми его командами:
$ go tool pprof -top -nodecount=5 ./app leak.pprof Type: goroutineleak Showing nodes accounting for 5000, 100% of 5000 total flat flat% sum% cum cum% 5000 100% 100% 5000 100% runtime.gopark 0 0% 100% 5000 100% main.leak.func1 0 0% 100% 5000 100% runtime.chanrecv 0 0% 100% 5000 100% runtime.chanrecv1
Отсюда доступны и -http, и дерево вызовов, и сравнение двух профилей через -base. Последнее в проде полезнее всего: сняли утром, сняли вечером, посмотрели, что наросло.
В CI я бы повесил проверку на TestMain. В отличие от сравнения снимков, тут не требуется, чтобы после тестов не осталось живых горутин:
func TestMain(m *testing.M) { code := m.Run() p := pprof.Lookup("goroutineleak") p.WriteTo(io.Discard, 1) if n := p.Count(); n > 0 { os.Stderr.WriteString("обнаружены утёкшие горутины\n") p.WriteTo(os.Stderr, 1) if code == 0 { code = 1 } } os.Exit(code) }
На пакете, где один тест закрывает канал воркера, а второй про это забыл:
PASS обнаружены утёкшие горутины goroutineleak profile: total 1 1 @ 0x4864ca 0x41624e 0x415d72 0x544b99 0x48c961 # 0x544b98 tst.Buggy.func1+0x18 /app/leak.go:5 FAIL tst 0.007s FAIL
Тесты зелёные, пакет красный, в выводе точное место. Фоновые горутины пакета, воркеры и пулы соединений отчёт не засоряют, потому что их примитивы достижимы. С goleak в такой ситуации пришлось бы раздавать исключения через IgnoreTopFunction, и по опыту этот список исключений живёт своей жизнью и гниёт.
Отдельная находка: внутри пузыря testing/synctest профиль всегда возвращает ноль. Горутины там получают собственные причины ожидания вида chan receive (durable) и select (durable), в диапазон кандидатов они не входят. Свою проверку synctest делает сам, роняя тест сообщением deadlock: main bubble goroutine has exited but blocked goroutines remain. Так что зоны поделены: synctest закрывает тесты с управляемым временем, профиль всё остальное.
Утечка горутин перестала быть догадкой по косвенным признакам. Теперь это свойство, которое рантайм умеет проверять и предъявлять со стеком, и стоит проверка терпимо.
Смущает меня другое. Инструмент отлично видит короткоживущие каналы рядом с местом использования и почти слепнет там, где примитивы синхронизации разложены по полям сервисов и по глобальным реестрам. То есть он лучше всего работает на коде, который и так проще отлаживать. На спагетти из подписчиков и брокеров, где утечки и водятся, он покажет ноль и не соврёт при этом ни разу.
Механизм вырос из работы Vlad Saioc и соавторов с ASPLOS 2025, до попадания в рантайм его обкатывали в Uber на тысячах тестовых наборов и на боевых сервисах. В Go 1.26 профиль жил под GOEXPERIMENT=goroutineleakprofile, в 1.27 флаг убрали.
Если у вас уже стоит 1.27, снимите профиль на своём сервисе и напишите в комментариях, сколько нашлось. Мне очень интересно, какая доля утечек в реальном коде проходит мимо детектора из-за глобальных ссылок, и по одному моему стенду это не понять.
Go тг для разработчиков — разборы рантайма, инструментов и практик Go, полезное для ежедневной работы
runtime/pprof, runtime: new goroutine leak profile, issue #74609
Dynamic Partial Deadlock Detection and Recovery via Garbage Collection, ASPLOS 2025