Процесс не падает. -race молчит. Метрики CPU и RPS зелёные. А число горутин на графике медленно ползёт вверх неделю за неделей, память вслед за ним — и когда это наконец кто-то замечает, воспроизвести причину уже нельзя: сервис успели перезапустить раз двадцать.
Утечка горутины — это не гонка данных и не паника. Горутина заблокирована совершенно корректно: на канале, на мьютексе, на WaitGroup. Просто разблокировать её больше некому. -race ищет одновременный неатомарный доступ к памяти — заблокированная навсегда горутина под это определение не подходит, гонки в ней нет. Отличить «заблокирована навсегда» от «долго, но легитимно ждёт» на глаз тоже нельзя: горутина воркера, ждущая тик тикера раз в 10 мс, и горутина, зависшая на приёме из канала, который никто никогда не заполнит, в pprof/goroutine выглядят одинаково — обе просто «waiting».
До Go 1.27 у этой проблемы не было прямого решения. Go 1.27 добавил профиль goroutineleak, который решает именно эту задачу: не считает горутины, а классифицирует их по достижимости — может ли горутину в принципе кто-то разблокировать.
В статье
- Почему утечка горутин коварна
- Что было до 1.27: pprof, goleak, NumGoroutine
- Новый leak-профиль: что выделяет и как снять
- Классы утечек на стенде
- Сигнал vs шум: контраст с pprof/goroutine
- В прод: мониторинг и алерты
- Демо и версии
- Документация и первоисточники
Почему утечка горутин коварна
Горутина стоит дорого не сама по себе — стартовый стек невелик, планировщик легко таскает тысячи таких горутин, а размер стека дальше подстраивается сборщиком мусора по мере роста и платформозависим в деталях. Дорого то, что она держит: замыкание с указателями на объекты, которые иначе давно собрал бы GC, файловый дескриптор или соединение, которое так и не закрылось, канал, ждать который больше некому. Утечка горутин почти всегда означает утечку памяти и ресурсов вслед за ней — просто не мгновенную, а размазанную по времени.
Граница между «заблокирована навсегда» и «долго ждёт легитимно» решается не временем ожидания, а достижимостью: может ли эту горутину в принципе кто-то разблокировать. Горутина, ждущая тик тикера раз в 10 мс, и горутина, зависшая на приёме из канала, в который никто никогда не напишет и который никто не закроет, в pprof/goroutine обе значатся как chan receive. Разница видна только если знать, жив ли отправитель на другом конце — а это уже вопрос не к рантайму горутины, а к графу владения каналом, который снаружи не виден.
Что было до 1.27: pprof, goleak, NumGoroutine
Три инструмента закрывали три разных куска задачи, и ни один — саму задачу целиком.
runtime.NumGoroutine() — это просто число. Растёт — плохо, но какая именно горутина растёт и почему, счётчик не скажет. Полезен как триггер алерта («число горутин за час выросло вдвое»), бесполезен как диагностика.
pprof/goroutine — полный дамп стеков всех горутин процесса, сгруппированных по одинаковому стеку. Если утечка одна и заметная, её стек выделяется числом повторов. Но если утёкших горутин десяток на фоне сотен штатно работающих (эта статья ниже это же и покажет на числах), искать их в общем списке — та же задача, что искать иголку в стоге, только стог отсортирован по алфавиту, а не разобран по смыслу. pprof/goroutine показывает, что горутина заблокирована и где именно в коде, но не показывает, заблокирована ли она навсегда или просто пока.
go.uber.org/goleak закрывает другой случай — не мониторинг живого процесса, а проверку в тесте: в конце теста снимается снимок горутин, сверяется с ожидаемым (обычно пустым за вычетом известных фоновых), и тест падает, если появились лишние — причём падение печатает стеки этих лишних горутин, так что «что именно осталось» goleak тоже показывает, не только «осталось или нет». Ограничение не в этом, а в том, где и когда он смотрит: goleak срабатывает на выходе из теста, в контролируемой обвязке, где легитимные фоновые горутины заранее известны и исключены, — а не в работающем процессе, который никто не останавливает специально, чтобы «выйти из теста» и снять снимок.
Собрать из этих трёх инструментов ответ на вопрос «какие именно горутины в работающем процессе прямо сейчас заблокированы навсегда» было нельзя — это и есть дыра, которую закрывает новый профиль.
Новый leak-профиль: что выделяет и как снять
Профиль goroutineleak появился в Go 1.27 как общедоступный: экспериментальный флаг GOEXPERIMENT=goroutineleakprofile, под которым он разрабатывался, в релизе убран. Снимается он так же, как любой встроенный pprof-профиль — через runtime/pprof:
prof := pprof.Lookup("goroutineleak")
if prof == nil {
// профиль недоступен — нужен Go 1.27+
}
// Первый вызов запускает цикл детекции, но его собственный вывод в
// экспериментах регулярно оказывался неполным или пустым — см. ниже,
// почему это наблюдение, а не документированный контракт. Пишем в
// буфер-пустышку и не полагаемся на этот результат.
var discard bytes.Buffer
if err := prof.WriteTo(&discard, 1); err != nil {
// обработать ошибку
}
// Второй вызов подряд в экспериментах уже давал полный результат.
var buf bytes.Buffer
if err := prof.WriteTo(&buf, 1); err != nil {
// обработать ошибку
}
got := prof.Count()В HTTP-обвязке net/http/pprof он подключается автоматически вместе с остальными встроенными профилями — на инстансе с import _ "net/http/pprof" он доступен по /debug/pprof/goroutineleak, рядом с уже привычными /debug/pprof/goroutine и /debug/pprof/heap.
Классифицирует профиль горутины не по виду блокировки, а по достижимости: способен ли кто-то в принципе разблокировать эту горутину, анализируя граф ссылок на объекты синхронизации (каналы, мьютексы, WaitGroup, sync.Cond), на которых горутины заблокированы. Если объект, который мог бы разблокировать горутину, недостижим ни из одной другой живой горутины — блокировка признаётся вечной. Это правило — не деталь реализации, а главное, что стоит понять про инструмент, и ниже оно проверяется прямым экспериментом, а не декларируется.
Сначала — механика снятия, потому что она задаёт условия эксперимента. WriteTo() для профиля goroutineleak не читает готовое состояние, а сам запускает цикл детекции: в runtime/pprof обработчик этого профиля явно вызывает внутреннюю функцию рантайма с комментарием «запустить GC с детекцией утечек первым, чтобы утёкшие горутины могли перейти в состояние leaked». Отдельный runtime.GC() перед снятием для этого НЕ нужен — раньше в этой статье и в стенде было заявлено обратное, и это было неверной причинностью: помогал не сам факт вызова runtime.GC(), а побочный эффект, разобранный в находке ниже.
Настоящая ловушка — в другом: одного вызова WriteTo() часто мало. Здесь важно разделить то, что установлено экспериментом, и то, что — только правдоподобное объяснение. Установлен факт: на воспроизводимом сценарии «мьютекс, чей держатель завершился» (Linux, WSL, go1.27.0) первый WriteTo() в свежем процессе не находит утечку, а второй подряд — находит, и обычная сборка мусора эту разницу не закрывает, сколько раз её ни повторяй:
первый WriteTo() -> goroutineleak profile: total 0
второй WriteTo() подряд -> total 2
1, 2 или 3 обычных runtime.GC() + один WriteTo() -> total 0
прогрев runtime.GC() до выделения мьютекса + один WriteTo() -> total 0
То есть обычные циклы сборки мусора не помогли ни в одном из трёх проверенных вариантов (один, два и три подряд), — помогает только второй цикл ДЕТЕКЦИИ, который запускает сам WriteTo(). На Windows та же картина видна на отдельной пробе из двух классов (мьютекс с завершившимся держателем плюс канал без читателя — 4 горутины суммарно, не все пять классов стенда): первый снимок сразу находил только один из них (count=2), второй — оба (count=4), третий ничего не добавлял. Эта проба — не пересказ, а отдельная программа стенда cmd/writeto-probe, воспроизводимая на месте; сырой вывод обеих платформ — в results/04-writeto-probe.txt. Числа по всем пяти классам стенда — отдельно, в разделе ниже (10 из 10).
Почему так — установлено не до конца, и здесь стоит быть честным, а не подгонять наблюдение под контракт. В runtime/mgc.go функция findGoroutineLeaks(), встретив горутины, которые она считает «возможно достижимыми», возвращает false и возобновляет фазу маркировки; счётчик work.goroutineLeak.count и перевод горутины в состояние _Gleaked происходят только тогда, когда таких «возможно достижимых» больше не остаётся. Правдоподобно, что в синтетическом сценарии стенда, где примитив синхронизации создаётся незадолго до снятия профиля, первый цикл детекции ещё застаёт горутины в разряде «возможно достижимых», и лишь второй докручивает их до _Gleaked. Но это гипотеза, а не проверенный критерий: точную границу между «возможно достижима» и «leaked» эксперимент не вскрывает, и формулировать это как контракт API («первый вызов лишь помечает, второй впервые видит результат») было бы неверно — исходник runtime/pprof такого контракта не описывает, он просто вызывает функцию детекции и пишет то, что она успела насчитать к этому моменту.
Из этого наблюдения — практический совет для одной конкретной ситуации, а не универсальное предписание всегда звать WriteTo() дважды: если снимок в свежем процессе выглядит пустым или подозрительно неполным, прежде чем делать вывод «утечек нет», снимите профиль ещё раз и сравните. Контракта «первый вызов только помечает, второй впервые показывает результат» исходник runtime/pprof не даёт — это наблюдение на конкретных версиях рантайма, а не документированное поведение, которое можно безусловно закладывать в код. Стенд в cmd/leaks выбирает осторожный вариант — снимает профиль дважды безусловно, не дожидаясь признака «пусто», — это оправданно для диагностического скрипта, где лишний вызов ничего не стоит, но не значит, что API это обещает. Так же осторожно стоит читать и Count(): если возвращаетесь к нему после WriteTo(), которому доверяете, — берите значение после него, а не раньше: Profile.Count() сам по себе не запускает цикл поиска, а лишь возвращает значение, оставшееся от предыдущего обнаружения, поэтому порядок «сначала быстро спросить число, потом при желании записать подробности» на первом снятии в программе даёт count=0 при реально существующих утечках — не ошибку, а тихое неверное «утечек нет».
Третья особенность — главный вывод этой статьи, и ради него стоило разбираться с первыми двумя. Детектор консервативен по достижимости: он не считает утечкой горутину, ждущую примитив, который ещё МОЖЕТ освободить живой владелец. Это проверено прямым сравнением двух вариантов одного и того же ожидания мьютекса — на обеих платформах, числа совпали:
| Кто держит мьютекс | Что видит детектор |
|---|---|
Держатель — отдельная горутина, которая захватила Lock() и завершилась, не отпустив |
утечка, count=2 |
Держатель — горутина, которая захватила Lock() и спит (по таймеру, может проснуться и отпустить) |
НЕ утечка, count=0 |
Снаружи, по стеку ожидающих горутин, оба случая выглядят абсолютно одинаково — sync.Mutex.Lock(), семафор, ожидание. Разница целиком в достижимости самого держателя: жив он или нет. Детектор не пытается угадать, наступит ли разблокировка «скоро» или «никогда» — он смотрит только на то, есть ли в принципе живая горутина, способная её выполнить. Это осознанное правило инструмента, а не недоработка: без него он ловил бы как утечки любые легитимные долгие ожидания — таймер раз в час, редкое внешнее событие, — а не только настоящие тупики.
Отсюда и осторожность нужна при чтении отсутствия результата: count=0 для класса ожидания мьютекса или канала — это не всегда «утечки нет», это может быть «у объекта, на который ждут, ещё формально жив держатель», даже если по факту он никогда не разблокирует. Граф достижимости и граф «разблокирует ли реально» — разные графы, и детектор считает по первому.
Классы утечек на стенде
На стенде пять детерминированных классов утечек, по две горутины на класс — число известно заранее, поэтому профиль можно сверять точным равенством, а не «примерно нашёл». Плюс шестой, контрольный класс — не утечка, а иллюстрация правила достижимости из предыдущего раздела: то же ожидание мьютекса, но с живым держателем.
// Мьютекс, чей держатель УСПЕЛ ЗАВЕРШИТЬСЯ: отпустить его больше некому —
// это настоящая утечка.
func mutexHolderGone() int {
var mu sync.Mutex
holderLocked := make(chan struct{})
go func() {
mu.Lock()
close(holderLocked)
// Возврат без Unlock: держатель заканчивает работу здесь.
// Горутина, которая могла бы отпустить мьютекс, уже завершилась.
}()
<-holderLocked
for i := 0; i < perClass; i++ {
go func() { mu.Lock() }()
}
return perClass
}
// Контрольный класс: мьютекс держит ЖИВАЯ горутина — не утечка, хотя
// ожидающие выглядят заблокированными точно так же, как выше.
func mutexHeldByLiveGoroutine() int {
var mu sync.Mutex
holderLocked := make(chan struct{})
go func() {
mu.Lock()
close(holderLocked)
// Держатель НЕ завершается: он спит по таймеру и рано или поздно
// проснётся и отпустит мьютекс — то есть остаётся достижимым.
time.Sleep(time.Hour)
mu.Unlock()
}()
<-holderLocked
for i := 0; i < perClass; i++ {
go func() { mu.Lock() }()
}
return 0 // детектор НЕ должен посчитать это утечкой
}Остальные три класса — sendNoReceiver (отправка в небуферизованный канал без читателя), receiveNeverClosed (приём из канала, который никто не закроет и в который никто не напишет) и waitGroupMissingDone (Wait() при недосчитанном Done()) — устроены проще: там нет живого/мёртвого держателя, разблокировать некому по построению.
Прогон cmd/leaks поднимает все пять «настоящих» классов плюс контрольный и сверяет результат точным равенством:
класс send-no-receiver поднято утечек: 2
класс receive-never-closed поднято утечек: 2
класс waitgroup-missing-done поднято утечек: 2
класс mutex-holder-gone поднято утечек: 2
класс cond-never-signaled поднято утечек: 2
класс mutex-held-by-live-goroutine поднято горутин: 2 (контрольный, НЕ утечка)
ожидалось утечек: 10
профиль нашёл: 10
Пять прогонов подряд на Windows и пять на Linux (go run, каждый — новый процесс, не кэшируется): 10 из 10 совпадений на обеих платформах, ноль расхождений, и контрольный класс ни разу не попал в тело профиля утечек. Числа на двух платформах совпали — это отдельно стоит подчеркнуть, потому что раньше не совпадали: до переработки стенда мьютексный класс на Linux не находился вовсе (детектор был прав — держателем оставалась живая main), и общий счёт на Linux выходил 8 из 10 вместо 10 из 10. Профиль при этом не просто считает — он указывает точный стек каждой утёкшей горутины, например для класса mutex-holder-gone:
2 @ 0x7ff7f371d20a 0x7ff7f36fb805 0x7ff7f36fb7d7 0x7ff7f371e165 0x7ff7f372927a 0x7ff7f378a48c 0x7ff7f378a473 0x7ff7f378a46f 0x7ff7f3723721
# internal/sync.runtime_SemacquireMutex+0x24 .../src/runtime/sema.go:95
# internal/sync.(*Mutex).lockSlow+0x159 .../src/internal/sync/mutex.go:149
# internal/sync.(*Mutex).Lock+0x2b .../src/internal/sync/mutex.go:70
# sync.(*Mutex).Lock+0x12 .../src/sync/mutex.go:46
# leaks.mutexHolderGone.func2+0xe leaks/leaks.go:112
Строка 2 @ ... — две горутины с идентичным стеком, ровно столько, сколько класс поднимает. Это и есть главное отличие от pprof/goroutine: не просто «горутина ждёт на мьютексе», а «эта горутина ждёт на мьютексе, который никто никогда не отпустит» — с указанием конкретной строки в конкретной функции, где произошёл захват без последующего снятия. И симметрично — контрольный класс с абсолютно похожим стеком (mutexHeldByLiveGoroutine) в этот список не попадает вовсе, потому что держатель там жив.
Здесь же стоит закрыть один вопрос заранее, до раздела про мониторинг: детектор в принципе не различает select с несколькими ветками отдельным классом — с его точки зрения горутина, зависшая в select без готовой ветки, это та же задача «достижим ли хоть один из каналов, на которые горутина может разблокироваться», что и обычный <-ch. Отдельного примера под select стенд не заводит именно поэтому: механизм детекции у него общий с receive-never-closed.
Сигнал vs шум: контраст с pprof/goroutine
Пять классов утечек по отдельности убедительны, но не показывают главного: как оба инструмента ведут себя на фоне живого процесса, где утёкшие горутины — меньшинство среди работающих. cmd/contrast поднимает те же десять утечек на фоне пула из 50 штатно работающих горутин, читающих из общего *time.Ticker с периодом 10 мс — они реально блокируются в ожидании тика, выглядят в pprof/goroutine точно так же «заблокированными», как и настоящие утечки, но канал тикера жив и рано или поздно каждую из них разблокирует.
Результат пяти прогонов свежесобранным бинарником без пересборки между запусками (плюс независимый контрольный go run с тем же результатом) — идентичен во всех пяти:
| Инструмент | Сколько горутин показывает |
|---|---|
pprof/goroutine |
61 |
goroutineleak |
10 |
Всего горутин по pprof/goroutine — 61 (50 воркеров + 10 утёкших + main). goroutineleak — ровно 10. Отсеяно как не утечка — 51 (50 воркеров и main). Ни одна из 50 рабочих горутин не была засчитана утечкой ни в одном из пяти прогонов — то есть детектор не просто «показывает меньше», он ЗНАЕТ, какие горутины блокированы навсегда, и не путает их с горутинами, которые заблокированы точно так же на вид, но живы по существу.
Разница не в шуме измерения — размер текстового профиля тоже показателен. В сохранённом прогоне стенда pprof/goroutine занял 3105 байт против 2231 у goroutineleak. Абсолютные значения привязаны к конкретному запуску и переносу не подлежат: в текст профиля входят полные пути пакетов из стека, поэтому на другой машине тот же сценарий дал 3361 против 2389. Устойчива здесь не величина, а доля — профиль утечек короче примерно на 28–29%, и это повторяется от прогона к прогону. При том что в goroutineleak не меньше деталей на горутину — он просто не тратит место на 51 горутину, которая утечкой не является. На реальном сервисе с сотнями живых горутин разница масштабируется в ту же сторону: pprof/goroutine покажет сотни строк, где десяток настоящих утечек теряется визуально, goroutineleak — только эти десять.
goleak в этом сравнении участвует текстом, а не числом: в сценарии cmd/contrast он неприменим напрямую, потому что проверяет отсутствие утечек на выходе из теста, а не показывает их список в работающем процессе — это принципиально другая задача, не облегчённая версия той же.
В прод: мониторинг и алерты
Практическая схема — та же, что и для остальных pprof-профилей: /debug/pprof/goroutineleak за периметром (не наружу, только для внутреннего сбора), периодический снап тем же механизмом, что уже собирает heap и goroutine, и Count() профиля — в метрику. Отличие от привычного порядка два: Count() читается после WriteTo(), а не до (до записи профиля счётчик отражает предыдущий цикл обнаружения, а не текущее состояние); и на периодическое снятие наблюдение «первый снимок в процессе неполон» практически не бьёт: каждый плановый вызов WriteTo() — это уже не первый вызов в процессе, а очередной в бесконечной последовательности, так что после первого-второго тика после старта процесса разница между «первым» и «вторым» снятием перестаёт быть заметна на графике метрики. Ловушка ощутима именно там, где снятие разовое — в диагностическом скрипте или тесте, где второй вызов приходится делать явно.
Естественная метрика — khorost_tech.runtime.goroutine_leaks (gauge, снимается тем же тикером, что и остальные runtime-метрики), и алерт не на абсолютное число, а на рост: одна-две утёкшие горутины на старте процесса, вызванные, например, библиотечным кодом с тестовыми хуками, — это шум, который не стоит алертить; устойчивый рост count от снятия к снятию — сигнал, за который стоит следить, потому что это ровно та картина, которая до 1.27 была видна только как медленный рост общего числа горутин без указания причины.
Отдельная оговорка касается правила достижимости, разобранного выше: Count() может расти не только из-за новых утечек, но и из-за того, что раньше живой держатель примитива синхронизации сам завершился (например, воркер вышел по ошибке, не отпустив мьютекс) — числовой алерт на рост поймает это одинаково хорошо, но при разборе конкретного всплеска стоит помнить, что «горутина держит примитив» само по себе не утечка, пока держатель жив. Числовых замеров накладных расходов на живом сервисе (влияние периодического WriteTo() профиля goroutineleak на латентность или CPU) стенд не проводил — заявлять конкретную цифру было бы подгонкой под несуществующий замер, поэтому единственная честная рекомендация здесь — обращаться с ним так же осторожно, как с любым pprof-снятием под нагрузкой: не на каждый запрос, а по таймеру с разумным интервалом, и смотреть за деталями снятия в общей схеме наблюдаемости сервиса.
Метки в трейсбеке — соседняя новинка 1.27, полезная в той же работе. Модули с директивой go 1.27 и выше по умолчанию печатают pprof.Labels, привязанные к горутине через pprof.Do, прямо в заголовке паники:
=== с метками (умолчание Go 1.27) ===
goroutine 7 [running] {shard: 7, worker: "payment-processor"}:
=== с GODEBUG=tracebacklabels=0 ===
goroutine 36 [running]:
При GODEBUG=tracebacklabels=0 метка из заголовка полностью пропадает. Номер горутины (7 против 36) между прогонами не совпадает и значения не имеет — рантайм переиспользует внутренние номера по-разному от запуска к запуску. Если сервис уже размечает горутины через pprof.Labels (шардинг, воркер, идентификатор задачи — та же практика, что в работе с примитивами синхронизации), эта разметка теперь бесплатно попадает в любой краш-дамп, включая тот, что случайно словит утёкшую горутину под нагрузкой.
Демо и версии
digital-cookbook/go/goroutine-leak-profileСтенд закреплён на GA-тулчейне go1.27.0 (не RC): GOTOOLCHAIN=go1.27.0 go version. Внутри — пять детерминированных классов утечек плюс контрольный, снятие профиля с двумя местами, где легко ошибиться (в этом сценарии первый снимок бывал неполным, поэтому берётся результат второго — а не отдельный runtime.GC(), который не помогает; плюс порядок Count()/WriteTo()), контраст с pprof/goroutine на фоне пула воркеров и демонстрация меток в трейсбеке. Проверено на двух платформах, Windows и Linux (WSL Ubuntu), тулчейн go1.27.0 на обеих — результаты совпали; это стоило проверить отдельно, потому что до переработки стенда числа между платформами расходились.
Контрольный класс mutex-held-by-live-goroutine — это и есть проверка поведения на «долгом, но легитимном» ожидании: держатель мьютекса спит по таймеру час, то есть с точки зрения человека выглядит так же подозрительно, как настоящая утечка, но детектор его не ловит ни разу за пять прогонов на каждой платформе. Более общий случай — горутина, ждущая редкое внешнее событие (не таймер, а, например, сообщение из внешней системы), стендом отдельно не проверялся: по устройству детектора (судит по достижимости, а не по времени) он должен вести себя так же, но прямого замера именно на этом варианте нет.
Ещё одна оговорка касается самого теста в стенде (leaks_test.go): он использует фиксированную паузу между запуском класса и проверкой числа утёкших горутин. На медленной машине это может дать ложный отказ (горутины не успели дойти до блокировки), но не ложный успех — тест ошибается в строгую сторону, в пользу «не соврать», а не в пользу «пройти».
Документация и первоисточники
- Обзор Go 1.27 целиком: VictoriaMetrics — Go 1.27 interactive tour.
- Официальные заметки о релизе: go.dev/doc/go1.27 — раздел про профиль утечек горутин и про метки в трейсбеке.
- Пакет
runtime/pprof: pkg.go.dev/runtime/pprof.
Смежные материалы на сайте: отладка гонок и утечек до 1.27 — где заканчиваются возможности -race и goleak; примитивы синхронизации и атомики — те самые Mutex, WaitGroup и Cond, на которых стенд строит классы утечек; json/v2 в Go 1.27 — вторая крупная новинка того же релиза.
Комментарии