Профилировщик утечек goroutine в Golang
- title
- Профилировщик утечек goroutine в Golang
- type
- summary
- summary
- Профиль goroutineleak в Go 1.27 задействует фазу разметки GC для поиска навечно заблокированных goroutine прямо в продакшене
- tags
- golang, concurrency, garbage-collection, debugging, profiling
- sources
- goroutine-leak-profiles
- created
- 2026-09-14
- updated
- 2026-09-14
- lang
- ru
- translation_of
- goroutine-leak-profiler
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
В Go 1.27 в runtime/pprof добавлен профиль goroutineleak. Он сообщает о goroutine'ах, которые заблокированы и уже никогда не разблокируются, причём работает достаточно быстро и точно, чтобы запускать его на продакшен-сервисах. В посте Влада Сайока (Vlad Saioc) в блоге Golang разбирается использование профиля, то, как он встраивается в сборщик мусора, и чего он не замечает. Возможность появилась в результате совместного исследования Орхусского университета, Вашингтонского университета в Сент-Луисе и Uber, опубликованного под названием "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection" (Saioc et al., ASPLOS 2025) goroutine-leak-profiles.
Какую проблему он решает
Утёкшая goroutine - это goroutine, заблокированная на чём-то, чьё условие разблокировки никогда не наступит. Утечки накапливаются в памяти (как стек самой goroutine'ы, так и всё, на что она ссылается) и расходуют процессорное время GC, что, как отмечается в посте, становится ещё заметнее при установленном GOMEMLIMIT. go-channel-bug-patterns описывает типичные паттерны и напоминает, что встроенный детектор дедлоков в Golang срабатывает, только если заблокированы вообще все goroutine'ы. Поэтому пара-тройка зависших goroutine'ов в работающем сервисе остаётся незамеченной.
Все существующие инструменты работают во время тестов. goleak отмечает goroutine'ы, оставшиеся в живых после завершения теста, а пакет synctest из Go 1.25 позволяет тесту управлять порядком конкурентных событий. Ни тот, ни другой ничего не могут сказать о продакшен-процессе, выполняющем сценарии, которые не покрыты тестами. Обычный профиль goroutine'ов действительно можно снять в проде, но он не отличает утечку от множества goroutine'ов, штатно ожидающих ответа во время всплеска нагрузки, а утечка всего нескольких штук в нём и вовсе никак не выделяется.
Использование
Сервис, который уже импортирует net/http/pprof, получает этот профиль автоматически по адресу /debug/pprof/goroutineleak. Сквозной пример в статье - распределение задач по воркерам (fan-out) с ранним возвратом при первой же ошибке:
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err // remaining senders block forever
}
results = append(results, r.res)
}
return results, nil
}
Если собрать профиль через curl и открыть в go tool pprof, он привязывает каждую утёкшую goroutine к строке, на которой произошла блокировка:
$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
0 116 (flat, cum) 100% of Total
. 116 33: ch <- result{res, err}
Счётчик растёт по мере работы программы. Исправление - буфер размером len(ws) у канала, чтобы каждый отправитель мог завершиться даже после того, как получатель уже ушёл.
Как работает обнаружение утечек
Идея опирается на достижимость. Если goroutine заблокирована на канале или блокировке, на которые больше ни у одной другой goroutine'ы нет ссылки, разбудить её уже ничто не сможет. В посте это обобщается в индуктивное определение живости (liveness): goroutine жива, если она не заблокирована на примитиве синхронизации либо если хотя бы на один блокирующий её примитив ссылается другая живая goroutine. Всё, что не является живым, утекло.
Вычисление этого сводится к задаче достижимости, а runtime решает её на каждом цикле GC. Сборщик в Golang - это конкурентный трёхцветный mark-and-sweep (сейчас в варианте Green Tea), и обнаружение утечек модифицирует его в пять шагов:
- Обычный цикл считает корнями разметки (mark roots) каждую goroutine и каждую глобальную переменную. Цикл с поиском утечек использует в качестве корней только незаблокированные goroutine'ы, так как они живы по определению.
- Разметка идёт без изменений, так что теперь помечается только память, достижимая из живых goroutine'ов.
- В конце разметки runtime проверяет каждую заблокированную goroutine, которая ещё не стала корнем. Если какой-либо блокирующий её примитив оказался помечен, она становится корнем, и разметка возобновляется. Это индуктивный шаг определения.
- Когда новых goroutine'ов больше добавить нельзя, все goroutine'ы, так и не ставшие корнями, помечаются как утёкшие.
- Разметка возобновляется в последний раз, куда в качестве корней добавляются уже утёкшие goroutine'ы, так что цикл сохраняет ровно то же самое, что сохранил бы и обычный сборщик.
Последний шаг принципиален: детектор ничего не освобождает сам по себе. Утёкшие goroutine'ы и их память остаются на месте, а профилировщик затем снимает обычный профиль goroutine'ов, отфильтрованный до найденных утечек.
Чего он не замечает
Из конструкции вытекают три ограничения.
Примитив, остающийся достижимым из глобальной переменной или из готовой к исполнению goroutine'ы, сохраняет живыми все заблокированные на нём goroutine'ы, даже если к нему больше никто никогда не обратится. В посте это называется "memory overreach" (избыточная достижимость памяти), и единственное предлагаемое решение - сужать область видимости ссылок на каналы и блокировки.
По соображениям корректности детектор покрывает только встроенные примитивы Golang первого класса: отправку и получение из каналов (включая nil-каналы), select без ветки default (включая пустой select {}), а также sync.Mutex, RWMutex, WaitGroup и Cond. Goroutine, заблокированная на файловом или сетевом вводе-выводе, прямом системном вызове или самодельном spinlock'е, никогда не попадёт в отчёт, если только этот собственный примитив не построен поверх перечисленных выше.
Утечка видна только после того, как она уже произошла. Профилировщик не предсказывает утечки, поэтому нестабильная программа должна допустить утечку прямо во время его работы. В посте рекомендуется не выбирать что-то одно, а сочетать его в тестах с goleak и synctest.
Накладные расходы
Накладные расходы по памяти сводятся к небольшому учёту. Реальную цену составляет CPU, и пост иллюстрирует худший случай на примере цепочки без утечек ("daisy chain"): исполняемая G₀ ссылается на примитив P₁, блокирующий G₁, та ссылается на P₂, блокирующий G₂, и так далее. Доказательство живости каждого звена требует предварительной разметки всего, что достижимо из предыдущего, поэтому разметка фактически сериализуется вдоль цепочки. Кроме того, проверка в конце раунда каждый раз заново обходит все заблокированные goroutine'ы, что даёт O(n²) шагов за цикл от числа goroutine'ов. Авторы рассчитывают оптимизировать вторую часть расходов; первая же заложена в саму суть подхода.
Два фактора делают это приемлемым. По умолчанию GC по-прежнему работает конкурентно с пользовательским кодом. А возникшая утечка остаётся до конца жизни процесса, поэтому профилирование можно запускать редко (в посте предлагается раз в 4 часа), не рискуя упустить то, что было бы поймано более частым сбором.
Паттерны, которые он находит
Набор примеров из поста варьируется от тривиальных до межпакетных, и некоторые взяты из реальных исправлений в крупных проектах на Golang:
- двойная отправка, когда пропущенный
returnпосле отправки в ветке ошибки приводит к тому, что goroutine отправляет данные дважды получателю, который читает лишь единожды; - ранний возврат или ветка
ctx.Done()вselect, бросающая небуферизованный отправитель (исправляется буфером размера 1); rangeпо каналу, который никогда не закрывается, из-за чего утекают все воркеры, а также родительский отправитель, если число воркеров равно нулю;- контракт
Start/Stop, спрятанный за интерфейсом, когда вызывающий код никогда не вызываетStop, оставляя фоновый цикл брошенным; - выход по
breakиз цикла под блокировкой безUnlock(CockroachDB #584) иWaitGroup.Wait(), помещённый внутрь цикла запуска вместо места после него (Moby #25384); - гонки порядка работы с каналами, когда
Stopиrunзавершают рукопожатие, оставляя конкурентного отправителяStatusзаблокированным (etcd #6857); - взаимная блокировка между мьютексом и каналом, когда одна goroutine держит блокировку во время отправки, а потенциальный получатель ждёт ту же самую блокировку (Kubernetes #6632, Moby #28462).
Профиль указывает на строку с блокировкой, а не на её первопричину. В случае двойной отправки он подсветит вторую отправку, а пропущенный return человеку придётся искать самому. В смешанных случаях с блокировками и каналами такое указание полезнее всего: две утёкшие goroutine'ы видны вместе - на отправке и на вызове Lock().
Как и ThreadSanitizer для гонок данных, чистый профиль goroutineleak - это свидетельство, но не строгое доказательство: он ничего не говорит о goroutine'ах, зависших на вводе-выводе или за примитивом, на который всё ещё ссылается какая-нибудь глобальная переменная.