Назад к блогу

Утечки горутин в Go: как работает goroutine leak profile

Утечки горутин в Go: как работает goroutine leak profile

В Go горутина, навсегда заблокированная на канале или мьютексе, не потребляет CPU и не проявляется в стандартных профилях — она просто тихо остаётся в памяти. Версия Go 1.27 закрывает этот пробел отдельным профилем утечек горутин, который опирается на сборщик мусора и показывает только те горутины, что уже никогда не завершатся. В статье разбирается, чем новый профиль отличается от привычного `goroutine`, как он устроен внутри и где проходит граница между тем, что он находит, а что остаётся за кадром.

Горутина, заблокированная навсегда, не занимает процессор и не видна в обычном профиле как проблема: она просто остаётся в памяти до конца работы программы. Go 1.27 добавляет профиль утечек горутин. Разберём, чем он отличается от обычного профиля горутин, как устроен внутри, какие утечки находит и какие — нет.

Чем профиль утечек отличается от обычного профиля горутин

Обычный профиль goroutine содержит трассировки стека всех текущих горутин. Профиль goroutineleak — только трассировки утёкших. Это принципиально разные множества: в первом случае вы видите всё, что живёт прямо сейчас, во втором — только то, что уже никогда не завершится.

Попадание в профиль утечек определяется сборщиком мусора. После того как все живые горутины найдены, любая горутина, не добавленная в mark roots, получает статус leaked. Профилировщик затем фильтрует строго утёкшие горутины.

Отдельная деталь — поведение при высоком уровне отладочного вывода. Если флаг debug достаточно велик, вывод профиля утечек откатывается к обычному профилю горутин и включает неуте́кшие горутины. То есть при debug >= 2 вы получите не профиль утечек, а полный снимок всех горутин.

Какие профили регистрирует runtime/pprof

При инициализации runtime/pprof регистрирует семь встроенных профилей в карте profiles.m: goroutine, threadcreate, heap, allocs, block, mutex и goroutineleak. Каждое имя сопоставлено объекту *Profile.

  • goroutine — трассировки стека всех текущих горутин;
  • goroutineleak — трассировки утёкших горутин;
  • allocs — выборка всех прошлых выделений памяти;
  • heap — выборка выделений памяти живых объектов;
  • threadcreate — трассировки, приведшие к созданию новых потоков ОС;
  • block — трассировки, приведшие к блокировке на примитивах синхронизации;
  • mutex — трассировки держателей спорных мьютексов.

Функция Lookup(name) возвращает профиль по имени или nil, если такого профиля нет. Функция Profiles() возвращает срез всех известных профилей, отсортированный по имени.

Как устроен сбор профиля утечек

Обход утёкших горутин

goroutineLeakProfileWithLabels сначала проверяет согласованность длин: если массив меток не nil и его длина не совпадает с длиной целевого среза, метки сбрасываются в nil. Затем вызов делегируется в concurrent-вариант.

Concurrent-вариант при пустом целевом срезе возвращает грубую оценку числа утёкших горутин и false, не пытаясь ничего записывать. Далее он берёт n = work.goroutineLeak.count, и если n больше длины среза, возвращает n и false — места не хватает на весь профиль, а частичная запись запрещена:

if n > len(p) {
	// There's not enough space in p to store the whole profile, so
	// we're not allowed to write to p at all and must return n, false.
	return n, false
}

Затем идёт обход каждой утёкшей горутины с проверкой состояния _Gleaked. Для каждой такой горутины на системном стеке снимается её стек, а при непустых метках сохраняются и метки горутины.

Concurrent-вариант используется потому, что он не останавливает мир: он работает с уже помеченными утёкшими горутинами, чьи стеки можно безопасно снимать. Обычный goroutineProfileWithLabelsConcurrent для полного профиля берёт семафор goroutineProfile.sema и использует машину состояний goroutineProfiled.

Запись стека и меток

doRecordGoroutineProfile записывает стек вызовов и метки одной горутины в текущий профиль, при этом вытеснение запрещено. Сначала она отбрасывает системные горутины, затем проверяет, что не пытается читать стек реально выполняющейся горутины, после чего атомарно резервирует слот. Если слот вне границ буфера, возвращается усечённый профиль. Стек сохраняется на системном стеке, метки — по тому же смещению. Стек разворачивается во временный буфер, затем выделяется массив точного размера и туда копируется трассировка.

tryRecordGoroutineProfileWB — обёртка, которая утверждает, что write barrier разрешён, проверяя наличие P. Без P барьер записи запрещён, поэтому WB-вариант нужен там, где запись меток в буфер требует барьера. Само состояние горутины переводится из Absent в InProgress через CompareAndSwap, а затем в Satisfied.

Синхронный профиль и остановка мира

goroutineProfileWithLabelsSync снимает профиль стеков всех горутин, останавливая мир для согласованного снимка. Сначала определяется через isOK, какие горутины включать: исключаются сама себя, мёртвые (_Gdead, _Gdeadextra) и системные. Затем вызывается stopTheWorld(stwGoroutineProfile), после чего мир остановлен и блокировки не нужны.

При остановленном мире подсчитывается n: начинается с 1 (текущая горутина) и увеличивается на каждую горутину, прошедшую isOK. Это даёт согласованное число живых горутин, потому что gcount(false) при остановленном мире возвращает стабильное значение — горутины не создаются и не завершаются. Специальная обработка финализатора и cleanup-горутин нужна, чтобы счёт совпал с isSystemGoroutine. Если n не превышает длину среза, профиль записывается: сначала текущая горутина, затем остальные, после чего мир возобновляется.

Как сборщик мусора определяет утёкшие горутины

Профиль утечек собирается после специального запуска сборщика мусора с детекцией утечек: сначала вызывается runtime_goroutineLeakGC(), чтобы утёкшие горутины могли перейти в состояние leaked.

Во время маркировочной фазы GC сначала помечает объекты, достижимые от mark roots. Затем проверяет все заблокированные горутины, не включённые в mark roots: если горутина заблокирована хотя бы на одном примитиве конкурентности, который сборщик мусора уже признал живым в ходе маркировочной фазы, она добавляется как mark root, и маркировка продолжается. Когда все живые горутины найдены, любая горутина, не добавленная как mark root, получает статус leaked.

После этого маркировка возобновляется последний раз уже со всеми утёкшими горутинами как mark roots, чтобы GC пометил всю память, которую пометил бы при обычном запуске. По завершении цикла профилировщик утечек работает как обычный профиль горутин, но фильтрует строго утёкшие.

Как получить профиль

Через HTTP

Профиль доступен по адресу http://localhost:6060/debug/pprof/goroutineleak. Его можно сохранить в файл:

curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof

Затем файл открывается интерактивно через go tool pprof leak.prof, где заголовок показывает Type: goroutineleak. Команда list с именем функции выводит построчный разбор стека с колонками flat и cum.

Как net/http/pprof регистрирует обработчики

Пакет регистрирует обработчики в init(). Если значение godebug httpmuxgo121 не равно "1", к путям добавляется префикс "GET ", и через http.HandleFunc регистрируются Index на /debug/pprof/, Cmdline, Profile, Symbol и Trace.

Index при наличии остатка пути после /debug/pprof/ передаёт управление соответствующему обработчику, иначе формирует HTML-страницу со списком профилей. Handler(name) возвращает обработчик, который ищет профиль через pprof.Lookup и при отсутствии отдаёт «Unknown profile».

Параметр seconds при непустом значении направляет в serveDeltaProfile, где он должен быть положительным целым, иначе 400. При debug != 0 вместе с seconds возвращается 400 «seconds and debug params are incompatible». Параметр debug задаёт text/plain при ненулевом значении и octet-stream с Content-Disposition при нулевом. Для heap при gc > 0 вызывается runtime.GC(). Profile читает seconds как int64 и при неположительном значении или ошибке берёт 30, а Trace — как float64 и берёт 1.

Программно

Lookup(name) возвращает профиль по имени или nil. WriteTo(w, debug) записывает снимок профиля в w и возвращает ошибку записи либо nil. Параметр debug управляет форматом: debug=0 пишет gzip-сжатый protocol buffer, debug=1 — legacy текстовый формат с комментариями, переводящими адреса в имена функций и номера строк. Предопределённые профили могут придавать другим значениям debug свой смысл: для профиля goroutine debug=2 печатает стеки горутин в том же виде, что использует Go-программа при падении из-за невосстановленной паники.

Типичные паттерны утечек

Double send в небуферизованный канал

В примере DoubleSend создаётся небуферизованный канал. Горутина-отправитель при ошибке сначала выполняет ch <- nil, но после этого отсутствует return, поэтому затем всё равно выполняется второй send ch <- struct{}{}. Поскольку канал небуферизованный, каждая отправка блокирует отправителя до тех пор, пока получатель не примет значение. Главная горутина делает только один приём, поэтому второй send навсегда блокирует отправителя.

Профиль не указывает прямо на пропущенный return, но направляет к виновной функции, подсвечивая утекающую операцию отправки. В выводе pprof list DoubleSend строка ch <- struct{}{} имеет ненулевое значение в колонке cum (1), тогда как строка ch <- nil — нулевое.

Early return без закрытия канала

В примере EarlyReturn функция создаёт небуферизованный канал и запускает горутину, которая пытается отправить в него значение. Если в родительской горутине err != nil, она делает ранний возврат до получения из канала, поэтому отправитель навсегда блокируется на отправке. Исправление — дать каналу буфер размера 1: тогда отправка завершается немедленно, не дожидаясь получателя.

Отмена контекста до синхронизации

В примере Timeout родитель ждёт либо сообщения из канала, либо отмены или таймаута контекста через select. Если контекст отменяется раньше, чем отправитель синхронизируется с родителем, родитель уходит по ветке отмены, и больше никто не примет значение из канала — отправитель навсегда блокируется. Профиль показывает 10 единиц в столбце cum на строке отправки. Решение — буфер размера 1.

Range по каналу без close

В примере noCloseRange запускаются воркеры, каждый из которых в цикле for item := range ch принимает элементы. Родитель отправляет все элементы, но канал не закрывается. Поскольку range по каналу завершается только после закрытия канала и получения всех буферизованных значений, при отсутствии close цикл блокирует исполняющую горутину навсегда. Вызов go noCloseRange([]any{1, 2, 3}, 3) приводит к утечке всех 3 воркеров. Профиль показывает 3 горутины, заблокированные именно на строке for item := range ch. Исправление — close(ch) после отправки всех элементов.

Нарушение контракта Start/Stop

Метод Start создаёт фоновую горутину, которая читается из двух каналов в цикле select и может завершиться только при получении сообщения из канала done, закрываемого методом Stop. Start и Stop образуют неявный контракт: если Start вызван хотя бы раз, Stop должен быть в конечном счёте вызван. В примере MethodContractViolation worker запускается через Start, элементы добавляются в очередь, но функция завершается без вызова Stop, поэтому горутина остаётся заблокированной в select. Профиль показывает запись main.(*worker).Start.func1 с одной горутиной в столбце cum.

Отсутствие Unlock перед break

В примере CockroachDB структура Gossip содержит мьютекс и флаг closed. Метод bootstrap в цикле захватывает мьютекс, и если g.closed истинно, выполняется break без вызова Unlock. Функция Cockroach584 создаёт Gossip с closed: true и вызывает bootstrap дважды, и второй вызов приводит к утечке: горутина остаётся заблокированной навсегда при повторной попытке захватить уже удерживаемый мьютекс. В профиле одна горутина находится в строке с вызовом g.mu.Lock(). Исправление — добавить Unlock перед break.

Неверный порядок операций с каналами

В примере etcd структура node содержит три канала: status, stop и done. Метод run в цикле выбирает между приёмом из status (тогда он шлёт в полученный канал) и приёмом из stop (тогда он закрывает done и выходит). Status создаёт канал, шлёт его в n.status и затем ждёт получения. Stop шлёт в n.stop и ждёт закрытия n.done. Утечка возникает, когда run и Stop успевают синхронизироваться и завершиться, не приняв сообщение от Status: тогда Status навсегда блокируется на отправке n.status <- c. Исправление — обернуть эту отправку в select с дополнительной ветвью приёма из done.

Взаимная блокировка канала и мьютекса

В примере Kubernetes горутина, выполняющая WriteFrame, захватывает мьютекс writeLock, а затем пытается отправить значение в канал resetChan; горутина monitor в это время ждёт получения из closeChan. После получения сообщения monitor пытается захватить тот же writeLock, но не может, потому что WriteFrame уже держит его и навсегда блокируется на отправке в resetChan, поскольку по этому каналу нет трафика. Обе горутины остаются заблокированными навсегда. В профиле при Total: 200 строка отправки в resetChan и строка захвата writeLock имеют cum 100 каждая — по 100 горутин на каждую точку блокировки. Исправление — запустить отдельную горутину, которая опустошает resetChan перед попыткой захватить блокировку.

Wait внутри цикла

В примере Moby функция init создаёт sync.WaitGroup, счётчик которого увеличивается на число плагинов, затем в цикле запускается горутина, уменьшающая счётчик через defer group.Done(). Однако group.Wait() вызывается внутри тела цикла, а не после него, из-за чего при числе плагинов больше одного горутина, выполняющая init, блокируется на первой итерации и не может запустить горутины для остальных плагинов. Профиль показывает, что строка group.Wait() удерживает горутину. Исправление — перенести Wait за пределы цикла.

Отправка в stop-канал вместо close

В примере Moby горутина, вызывающая StateChanged, захватывает мьютекс контейнера, затем вызывает CloseMonitorChannel, который пытается отправить значение в канал stop. Если горутина monitor не готова принять из stop, отправка блокируется, и StateChanged не может освободить мьютекс. monitor, не получив сообщение, выбирает ветку default в select и вызывает handleProbeResult, который пытается захватить тот же мьютекс — взаимная блокировка и утечка обеих горутин. Исправление — закрыть канал stop вместо отправки: закрытие не блокирует, поэтому StateChanged освобождает мьютекс, а monitor при следующей итерации выбирает разблокированную ветку <-stop и завершается.

Ограничения и компромиссы

Что не находится

Обнаружение утечек строго ограничено первоклассными примитивами конкурентности Go: операции отправки и получения по каналам (включая nil-каналы), блокирующие select без default, вплоть до select без случаев, и члены пакета sync — Mutex, RWMutex, WaitGroup и Cond. Горутины, заблокированные по любой другой причине, например файловый и сетевой ввод-вывод или прямые системные вызовы, никогда не считаются утёкшими. Это также относится к пользовательским примитивам конкурентности, например спин-блокировкам, если они не полагаются на перечисленные примитивы в своей реализации.

Есть и ограничение «memory overreach»: если примитив конкурентности постоянно достижим через глобальные переменные или исполняемые горутины, то горутины, блокирующиеся на нём, никогда не будут отчитаны как утёкшие, даже если этот примитив никогда не будет использован в будущем.

Недетерминированность

Утечки можно обнаружить только после того, как они произошли, но нельзя предсказать заранее, поэтому воспроизведение и диагностика утечек в нестабильных программах остаются сложной задачей. Профиль не выделяет причину явно — например, отсутствующий return — а лишь указывает на проблемную функцию, подсвечивая утекающую операцию. В одном примере видно 3 воркера, заблокированных на операции range ch, что даёт лишь намёк на причину.

Накладные расходы

Накладные расходы описаны как тщательно спроектированные для минимизации влияния на производительность. Память расходуется незначительно — только небольшие добавления для учёта. Основная стоимость проявляется в патологическом случае «daisy-chain»: доказательство liveness для одной горутины требует доказательства liveness для предыдущей, из-за чего фаза маркировки GC фактически сериализуется по порядку сканирования горутин. Проверка всех заблокированных горутин в конце каждого раунда маркировки даёт в худшем случае O(n²) шагов за один цикл GC, где n — общее число горутин.

Сериализация маркировки названа внутренним ограничением, которое нельзя обойти; O(n²) со временем может быть оптимизировано. При этом GC по-прежнему работает параллельно с пользовательским кодом, если не настроено иное через runtime-флаги. Поскольку утечка, наблюдаемая в некоторый момент, наблюдаема и в любой будущий момент того же исполнения, периодический профайлер может снижать частоту, например раз в 4 часа, минимизируя накладные расходы практически без потери способности обнаруживать утечки.

В коде это видно в goroutineLeakProfileWithLabelsConcurrent: он не останавливает мир, а лишь обходит горутины и сохраняет стеки тех, у кого статус _Gleaked, тогда как обычный профиль выполняет stopTheWorld(stwGoroutineProfile).

Интеграция с тестами: goleak

goleak

Как работает фильтрация

filterStacks

При проверке утечек goleak сначала отбрасывает саму запущенную горутину по её идентификатору, затем применяет пользовательский или стандартный фильтр, и только оставшиеся стеки считаются подозрительными.

Find

Поиск лишних горутин получает идентификатор текущей горутины, строит опции, применяет фильтрацию и, если после неё стеков не осталось, завершается успешно, иначе повторяет попытки согласно настройкам retry и в итоге сообщает об ошибке «found unexpected goroutines». VerifyNone помечает тест как проваленный, если поиск находит лишние горутины.

Проблема с t.Parallel

VerifyNone несовместим с t.Parallel, потому что не может связать конкретные горутины с конкретными тестами: не текущие горутины из других параллельных тестов могут провалить проверку. Для параллельных тестов предлагается VerifyTestMain, который проверяет отсутствие утечек после завершения всех тестов. Для тестов с t.Parallel goleak не умеет отличать утекающую горутину от тестов, которые ещё не завершились.

Как найти источник утечки

Когда проверка утечек выполняется через TestMain, тест утечек запускается один раз после всех тестов, поэтому трудно понять, какой именно тест вызвал утечку. Чтобы найти источник, сначала создают тестовый бинарник командой go test -c -o tests. Затем в цикле перебирают имена тестов, полученные через go test -list . с фильтром grep -E "^(Test|Example)", и запускают каждый тест отдельно:

for test in $(go test -list . | grep -E "^(Test|Example)"); do ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo -e "\n$test failed"; done

Для успешных тестов печатается точка, для падающих выводится имя теста с пометкой failed. В результате выводятся только имена падающих тестов, которые можно исследовать по отдельности.

Что из этого следует на практике

Профиль утечек горутин — точный инструмент с малым числом ложных срабатываний, но его область действия узка: только горутины, навсегда заблокированные на каналах или примитивах пакета sync. Утечка на файловом вводе-выводе или в пользовательском примитиве, не опирающемся на стандартные, в профиль не попадёт.

Профиль собирается после специального цикла GC с детекцией утечек, поэтому его нельзя получить мгновенно — нужен запуск сборщика мусора. При этом сам сбор профиля не останавливает мир, в отличие от обычного профиля горутин.

Профиль не называет причину утечки — он показывает место блокировки. Пропущенный return, незакрытый канал, забытый Unlock, Wait внутри цикла — все эти ошибки видны как висящая строка в конкретной функции, но диагноз ставит разработчик.

Для отладки в нестабильных программах профиль ограничен недетерминированностью: утечку видно только после того, как она произошла. Для лучших результатов рекомендуется сочетать подходы — использовать профили утечек на разных уровнях, вплоть до продакшена, а также комплексные наборы тестов, инструментированные goleak и synctest.

Где смотреть в коде

Источники

Похожее