golang

Утёкшие горутины теперь ищет сборщик мусора. Проверил, где он молчит

  • вторник, 1 сентября 2026 г. в 00:00:13
https://habr.com/ru/articles/1076654/

Пятьдесят из шестидесяти трёх горутин в моём тестовом сервисе не проснутся никогда. Я узнал это одной командой, без чтения дампов и без гадания по стекам.

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:

Причина ожидания

Что это

chan receive (nil chan)

чтение из nil-канала

chan send (nil chan)

запись в nil-канал

select (no cases)

пустой select {}

select

обычный select

chan receive

чтение из канала

chan send

запись в канал

sync.Cond.Wait

ожидание на условной переменной

sync.Mutex.Lock

захват мьютекса

sync.RWMutex.RLock

захват на чтение

sync.RWMutex.Lock

захват на запись

sync.WaitGroup.Wait

ожидание группы

Неочевидные строки я проверил отдельно. 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 МБ

runtime.GC()

21 мс

126 мс

профиль goroutine

8 мс

186 мс

профиль goroutineleak

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, снимите профиль на своём сервисе и напишите в комментариях, сколько нашлось. Мне очень интересно, какая доля утечек в реальном коде проходит мимо детектора из-за глобальных ссылок, и по одному моему стенду это не понять.

Ссылки