В Go 1.27 профиль goroutineleak вышел из эксперимента: рантайм научился доказывать, что горутину уже некому разбудить. Собрал тулчейн из исходников, прогнал десяток программ и замерил, что детектор находит, где он молчит и во что обходится вызов. Читать далее
Уровень сложностиСложный
Время на прочтение9 мин
Охват и читатели8.9K
Обзор
Пятьдесят из шестидесяти трёх горутин в моём тестовом сервисе не проснутся никогда. Я узнал это одной командой, без чтения дампов и без гадания по стекам.
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
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Go 1.27: generic-методы, которых не видит reflect | 0 | 13.12 | 19-08-2026 |
| 2 | Go 1.27 подменил движок encoding/json. Замерил три конфигурации и нашёл, где стало хуже | 0 | 10 | 03-09-2026 |
| 3 | Мы измерили платный антидетект‑браузер: canvas совпадал с чистой машиной байт в байт. Потом сделали свой, открытый | 0 | 7.4 | 14-09-2026 |
| 4 | А точно ли вы знаете, чем занят ваш агент в песочнице? | 0 | 9.71 | 15-09-2026 |
| 5 | ч2: Разбор. Как мы закрывали утечку через tun0 в AmneziaVPN | 0 | 7.78 | 01-10-2026 |
| 6 | [Перевод] Реверс‑инжиниринг электросамоката и новая прошивка для него на Rust | 0 | 8.14 | 11-09-2026 |
| 7 | Вайбкодинг: запретить нельзя возглавить | 0 | 11.07 | 30-09-2026 |
| 8 | ИИ начал составлять досье на пользователей скомпрометированных устройств | 0 | 9.11 | 01-10-2026 |
| 9 | ИИ начал составлять досье на пользователей скомпрометированных устройств | 0 | 9.11 | 01-10-2026 |
| 10 | ИИ начал составлять досье на пользователей скомпрометированных устройств | 0 | 9.11 | 01-10-2026 |