Функции параллелизма в Go мощны и просты в использовании, но та же простота иногда может привести к ошибкам даже опытных разработчиков. К счастью, экосистема Go оснащена полезными инструментами для отладки, например, детектором гонок, однако даже существующие инструменты могут пропускать некоторые ошибки параллелизма, такие как утечка горутин, которой посвящена эта статья.
Горутины синхронизируют или обменивают информацию с помощью общих примитивов параллелизма, таких как каналы, мьютексы и группы ожидания (wait groups). Общаясь, горутины часто блокируются на этих примитивах, то есть ждут выполнения какого-либо условия; повсеместные примеры включают ожидание захвата удерживаемого мьютекса или получение сообщения через канал. Горутины также могут блокироваться на операциях операционной системы, таких как чтение из сетевого сокета или файла.
Мы можем считать горутину утеченной, если она заблокирована, но условия, необходимые для ее разблокировки, никогда не будут выполнены. Со временем накопление утеченных горутин ухудшает производительность из-за чрезмерного использования памяти (самими утеченными горутинами или ссылающейся ими памятью), а также из-за использования ЦП сборщиком мусора, особенно если используется GOMEMLIMIT.
Утечки горутин могут быть исключительно сложны для обнаружения. В модульном тестировании наиболее значительными достижениями являются библиотека с открытым исходным кодом goleak, которая может инструментировать отдельные тесты для сигнализации о любых незавершенных горутинах после завершения теста как о подозрительных. Аналогичным образом, в Go 1.25 в стандартную библиотеку был добавлен пакет synctest; он может значительно повысить качество модульных тестов в параллельном коде, предоставляя разработчикам Go больший контроль над порядком параллельных событий для надежного тестирования трудновоспроизводимых сценариев.
К сожалению, ни один из подходов не может проверить наличие утечек горутин в производственных системах, особенно в больших масштабах, которые могут вести себя непредусмотренным тестами образом. Профили горутин — это рудиментарный способ проверки операций, блокирующих слишком много горутин, или анализа тенденций роста. Тем не менее, профили горутин не могут различать горутины, которые утекли, и те, которые временно заблокированы в большом количестве по замыслу, например, из-за возросшего трафика в микросервисе. Точно так же утечки в небольшом количестве могут оставаться незамеченными в течение многих лет.
Go 1.27 представляет профилировщик утечек горутин — гибкий и легковесный механизм для поиска утечек горутин в работающих программах Go, включая производственные системы. В отличие от предыдущих подходов, требующих анализа человеком, этот механизм точен и генерирует мало или вообще не генерирует ложных срабатываний. Компромисс заключается в том, что он ограничен подмножеством утечек горутин: горутинами, постоянно заблокированными на каналах или примитивах в пакете sync. К счастью для нас, это уже охватывает очень большое подмножество утечек горутин, как мы увидим в наших примерах.
В следующих разделах мы продемонстрируем, как использовать эту функцию, за чем последуют некоторые дополнительные примеры обнаруживаемых утечек, а также описание лежащей в основе реализации и компромиссов.
Пример: параллельные воркеры
Рассмотрим функцию, которая обрабатывает рабочие элементы параллельно:
Поскольку ch — это небуферизованный канал, каждая горутина-воркер блокируется при отправке своего результата до тех пор, пока главная горутина не получит данные из канала. Если processWorkItems преждевременно возвращает управление из-за ошибки, цикл получения завершается, и все оставшиеся горутины-отправители блокируются навсегда.
Этот пример является символом распространенной ошибки, обнаруживаемой в реальных программах на Go, включая производственные сервисы Uber. Давайте посмотрим, как мы можем найти эти утечки с помощью нового профилировщика утечек горутин.
Отладка с помощью профилировщика утечек горутин
Профиль доступен через пакет runtime/pprof как тип профиля goroutineleak или путем установки обработчиков профиля, определяемых пакетом net/http/pprof. Если у вас в сервисе уже настроен net/http/pprof, вам больше ничего не нужно делать! Профиль будет автоматически доступен для сбора по конечной точке /debug/pprof/goroutineleak на любом хосте и порту, где установлены обработчики.
Давайте поместим нашу ошибку параллелизма в контекст и настроим пакет net/http/pprof. Таким образом, вы сможете попробовать это самостоятельно!
Соберите программу, описанную выше, а затем запустите ее:
Сбор профиля
Программе не потребуется много времени, чтобы начать накапливать утечки, которые затем можно просмотреть с помощью веб-интерфейса по адресу http://localhost:6060/debug/pprof.
Альтернативно вы можете собрать профиль утечки горутин с помощью curl, а затем изучить его с помощью go tool pprof:
Профиль показывает горутины, утечки которых произошли в ch <- result{res, err} (строка 33), точно указывая на проблемную операцию. Примечательно, что чем дольше работает программа, тем больше количество утечек горутин.
Устранение утечки
Эту утечку можно просто исправить, добавив буфер для ch:
Это позволяет всем горутинам рабочих элементов отправлять сообщение без блокировки в случае преждевременного возврата processWorkItems.
Мы приводим больше примеров из реальной жизни в этом разделе.
Реализация
Этот раздел предназначен для тех, кто интересуется тем, как работает обнаружение утечек «под капотом» профилировщика утечек горутин. Сведения, относящиеся исключительно к накладным расходам на производительность и ограничениям, можно пропустить, перейдя к этому разделу.
Основная концепция
Начнем с первоначального наблюдения: если горутина заблокирована над неким примитивом параллелизма, к которому ни у какой другой горутины нет доступа (в данном случае через ссылку в памяти), то она очевидно утекла. Это уже серьезная зацепка, мы можем обобщить ее далее в определение того, когда горутина не утекла — свойство, которое мы называем жизнеспособностью (liveness). Мы формально определяем жизнеспособность как индуктивное свойство следующим образом:
Горутина является живой, если: она не заблокирована примитивом параллелизма, или по крайней мере один блокирующий ее примитив параллелизма упоминается другой живой горутиной.
Горутина является живой, если:
- она не заблокирована примитивом параллелизма, или
- по крайней мере один блокирующий ее примитив параллелизма упоминается другой живой горутиной.
В тривиальном случае незаблокированные горутины очевидно не имеют утечек. В индуктивном случае базовое предположение заключается в том, что любая горутина без утечек может в конечном итоге использовать ссылающиеся на нее примитивы параллелизма для разблокировки любых других горутин, заблокированных этими примитивами.
Чтобы найти все живые горутины, мы начинаем с очевидно живых незаблокированных горутин и отслеживаем любые удерживаемые ими ссылки (например, через их локальные переменные), чтобы найти примитивы параллелизма, к которым у них есть доступ. Затем мы постепенно включаем любые горутины, заблокированные над этими примитивами, как живые, и повторяем процесс до тех пор, пока не перестанут обнаруживаться новые живые горутины.
К счастью для нас, среда выполнения Go уже вычисляет доступность памяти с помощью сборщика мусора (GC), поэтому следующий шаг заключается в адаптации сборщика мусора под наши цели. Вы можете быстро сравнить два сборщика мусора с помощью следующих диаграмм:
Полная переработка сборщика мусора (GC) не требуется. Среда выполнения Go использует конкурентный трехцветный сборщик мусора с алгоритмом маркировки и очистки (теперь с вариантом Green Tea!), поэтому его принцип работы уже идеально соответствует нашим целям. Требуется лишь несколько ключевых изменений:
- На начальных этапах обычный GC помечает все горутины (и глобальные переменные) как достигаемые, так что они никогда не будут считаться мусором, то есть они являются корнями маркировки. Мы изменяем это поведение так, чтобы оно включало только разблокированные горутины, поскольку они гарантированно живы.
- За этим следует фаза маркировки, в ходе которой GC отслеживает объекты, на которые ссылаются (транзитивно) корни маркировки, и помечает их как используемую память. Хотя мы не изменяем эту фазу напрямую, изменения на шаге 1 неявно гарантируют, что GC помечает только память, на которую ссылаются живые горутины.
- Фаза маркировки завершается проверкой всех заблокированных горутин, не включенных в корни маркировки на шаге 1. Если горутина заблокирована хотя бы одним примитивом параллелизма, который был помечен на шаге 2, она добавляется в качестве корня маркировки, и GC возобновляет фазу маркировки с шага 2. Это совпадает с индуктивным шагом в определении актуальности (liveness).
- Как только все живые горутины обнаружены, статус любой горутины, не добавленной в качестве корня маркировки, устанавливается как утечка.
- Затем фаза маркировки возобновляется в последний раз со всеми горутинами с утечками, добавленными в качестве корней маркировки, что позволяет GC пометить всю память, которую он пометил бы во время обычного запуска.
После завершения цикла GC профилировщик утечек горутин начинает работу так же, как и при обычном профилировании горутин, и фильтрует строго утекающие горутины.
Ограничения
Приведенные выше примеры демонстрируют полезность профилей утечек горутин. Тем не менее, сборщик мусора имеет некоторые ограничения, которые могут привести к тому, что он пропустит утечки:
- Избыточность памяти: если примитив параллелизма стабильно достижим через глобальные переменные или выполняемые горутины, то блокирующиеся на нем горутины никогда не будут зарегистрированы как утекающие, даже если этот примитив параллелизма никогда больше не будет использоваться. Это можно смягчить за счет более строгого регулирования доступа к ссылкам на примитивы параллелизма и более четкого определения их жизненного цикла.
Избыточность памяти: если примитив параллелизма стабильно достижим через глобальные переменные или выполняемые горутины, то блокирующиеся на нем горутины никогда не будут зарегистрированы как утекающие, даже если этот примитив параллелизма никогда больше не будет использоваться.
Это можно смягчить за счет более строгого регулирования доступа к ссылкам на примитивы параллелизма и более четкого определения их жизненного цикла.
- Нестандартная блокировка: в целях корректности обнаружение утечек горутин строго ограничено первоклассными примитивами параллелизма Go, которые включают в себя: операции отправки и получения по каналам (в том числе по нулевым каналам), блокирующие инструкции select (т. е. без блока default, вплоть до инструкций select без кейсов включительно), а также элементы пакета sync, а именно Mutex, RWMutex, WaitGroup и Cond. Горутины, заблокированные по любой другой причине, например, из-за файлового и сетевого ввода-вывода или прямых системных вызовов, никогда не считаются утекающими. Это также относится к пользовательским средствам параллелизма, например, спинлокам (spin locks), если они не опираются на описанные выше примитивы в своей внутренней реализации.
Нестандартная блокировка: в целях корректности обнаружение утечек горутин строго ограничено первоклассными примитивами параллелизма Go, которые включают в себя: операции отправки и получения по каналам (в том числе по нулевым каналам), блокирующие инструкции select (т. е. без блока default, вплоть до инструкций select без кейсов включительно), а также элементы пакета sync, а именно Mutex, RWMutex, WaitGroup и Cond.
Горутины, заблокированные по любой другой причине, например, из-за файлового и сетевого ввода-вывода или прямых системных вызовов, никогда не считаются утекающими. Это также относится к пользовательским средствам параллелизма, например, спинлокам, если они не опираются на описанные выше примитивы в своей внутренней реализации.
- Недетерминизм: утечки могут быть обнаружены только после того, как они произошли, но их нельзя предсказать заранее, поэтому воспроизведение и диагностика утечек в нестабильных программах по-прежнему остается сложной задачей. Для достижения наилучших результатов мы рекомендуем комбинировать подходы, используя профили утечек горутин на различных уровнях, вплоть до продакшена, а также комплексные наборы тестов с инструментами goleak и synctest.
Недетерминизм: утечки могут быть обнаружены только после того, как они произошли, но их нельзя предсказать заранее, поэтому воспроизведение и диагностика утечек в нестабильных программах по-прежнему остается сложной задачей. Для достижения наилучших результатов мы рекомендуем комбинировать подходы, используя профили утечек горутин на различных уровнях, вплоть до продакшена, а также комплексные наборы тестов с инструментами goleak и synctest.
Влияние на производительность
Обнаружение утечек горутин тщательно разработано для минимизации влияния на производительность, но тем не менее здесь есть свои издержки.
Хотя накладные расходы на память ничтожно малы и ограничиваются лишь небольшими дополнениями, необходимыми для бухгалтерского учета, обнаружение утечек горутин может работать медленнее, чем обычный GC. Это лучше всего иллюстрируется патологическим случаем, который мы называем «гирляндой» (daisy-chain): в этом примере без утечек выполняемая горутина G₀ имеет ссылку на примитив P₁, который блокирует G₁, и так далее.
Это означает, что доказательство жизни для некоторого Pᵢ₊₁ требует доказательства жизни для Pᵢ, что влечет за собой две проблемы с точки зрения затрат:
- Фаза маркировки GC фактически сериализуется относительно порядка, в котором горутины могут сканироваться, поскольку вся память, достигаемая из некоторого Pᵢ, должна быть помечена до того, как Pᵢ₊₁ сможет быть добавлен в качестве корня.
- В настоящее время проверка проверяет все заблокированные горутины в конце каждого раунда маркировки, что дает худший случай O(n²) шагов для одного цикла GC, где n — общее количество горутин.
Хотя второй пункт со временем может быть оптимизирован, первый пункт является внутренним ограничением обнаружения утечек, которое нельзя обойти.
Тем не менее, мы напоминаем читателю, что, если иное не настроено с помощью флагов среды выполнения, GC по-прежнему работает параллельно с пользовательским кодом. Более того, если утечку горутины можно наблюдать в какой-то момент времени, ее также можно наблюдать в любой будущий момент во время того же выполнения. Таким образом, инфраструктуры периодического профилирования могут настраивать частоту профилирования (например, каждые 4 часа) для минимизации накладных расходов практически без потери возможностей обнаружения утечек.
Благодарности
Обнаружение утечек горутин является результатом научного сотрудничества Орхусского университета, Вашингтонского университета в Сент-Луисе и Uber, как представлено в работе «Динамическое обнаружение тупиков и восстановление с помощью сборщика мусора» (Saioc et al., ASPLOS 2025).
Переход от академического прототипа к реальной функции Go стал возможным благодаря руководству Майкла Кнышека (Michael Knyszek) и Майкла Пратта (Michael Pratt) из команды Go в Google, а также Пи Джея Маллоя (@thepudds).
Дополнительные примеры
Ниже приведены шаблоны кода, которые приводят к утечкам (как наблюдалось в кодовых базах промышленного масштаба и проектах с открытым исходным кодом), в порядке возрастания сложности.
Вы можете быстро протестировать детектор утечек goroutine в Go playground, а также поэкспериментировать с вашими собственными утечками.
Пример: Двойная отправка
Некоторые из самых простых утечек происходят, когда по каналу отправляется больше сообщений, чем ожидалось. Ниже ожидается, что goroutine отправит одно сообщение главной goroutine через небуферизованный канал. Однако в случае ошибки после операции отправки пропущен оператор return. Таким образом, при каждой ошибке отправитель попытается отправить два сообщения, что приведет к утечке.
Хотя профиль явно не указывает на пропущенный return как на причину, он по крайней мере направляет вас к проблемной функции, подсвечивая утечку при операции отправки.
Эту утечку можно устранить простым добавлением оператора return после операции отправки в случае ошибки.
Пример: Ранний возврат
Обратная ситуация встречается не реже: получатель пропускает связь на некоторых путях управления, что фактически является упрощенной версией вводного примера.
Утечка goroutine обнаруживается в профиле:
Утечку можно устранить, выделив для ch буфер размером 1.
Пример: Таймаут
Вариант описанного выше паттерна «Ранний возврат» включает контексты и недетерминированный выбор (операторы select):
Если контекст отменяется до того, как отправитель синхронизируется с родителем, отправитель утечет:
Как и в предыдущем примере, исправление заключается в том, чтобы дать каналу буфер размером 1.
Пример: Обход канала с помощью range без закрытия
Итерация по каналам с использованием range позволяет многократно получать значения из канала в цикле. Как только канал закрывается и все значения, поставленные в очередь в буфере канала, получены, цикл завершается.
Важно отметить, что если канал никогда не закрывается, цикл range будет блокировать выполняющуюся goroutine вечно. Пропуск операции закрытия — распространенная ошибка, как показано ниже:
Профиль утечки goroutine для такой программы будет включать следующее:
Мы видим 3 воркеров, заблокированных на операции range ch, что дает четкий намек на причину утечки. Утечку можно устранить, просто закрыв канал после отправки всех сообщений:
Бонус! Внимательные читатели могли заметить еще одну потенциальную утечку в этом примере, если количество воркеров по ошибке установлено равным нулю, что приведет к утечке у родительского отправителя:
Это также фиксируется в профиле:
Хотя в реалистичных производственных системах можно предполагать, что workers > 0, профили утечек goroutine тем не менее могут использоваться для неявного мониторинга случайных нарушений без консервативных проверок workers <= 0.
Пример: Нарушения контракта методов
Рассмотренные до сих пор паттерны были относительно ограничены в своей лексической области видимости. Однако по мере того как функциональность распределяется по функциям, методам и пакетам, а реализации маскируются интерфейсами, сложность ручного обнаружения утечек резко возрастает.
Такой случай проиллюстрирован в этом разделе на примере пользовательского типа воркера, который встраивает два поля каналов, ch и done, и создает зацикленную goroutine с помощью метода Start, которая читает из обоих каналов с помощью оператора select. Данная goroutine может быть завершена только путем получения сообщения через канал done, который закрывается методом Stop.
Метод Start может вызываться любое количество раз, но если он вызван хотя бы один раз, в конечном итоге должен быть вызван Stop.
В результате Start и Stop образуют неявный контракт, определяющий порядок вызова методов. Нарушение этого контракта может привести к нежелательному поведению, в данном случае — к утечкам goroutine:
Эта проблема усугубляется на практике, когда такие пользовательские типы экспортируются только как интерфейсы, в данном случае через неописательный тип Worker. Клиенты могут даже не подозревать о лежащей в основе реализации и, следовательно, нарушать неявный контракт, не осознавая этого.
К счастью, получение профиля утечки goroutine может выявить дефект:
Естественно, исправление заключается в том, чтобы проследить путь до вызова Start и добавить вызов Stop.
Пример (Cockroach): Пропущенная разблокировка
Следующий пример взят из CockroachDB. Он включает захват и освобождение блокировки в цикле, но забывание разблокировать ее перед выполнением оператора break:
В таком случае goroutine даст утечку при сбое захвата блокировки.
Добавление вызова Unlock перед break решает проблему.
Пример (etcd): Неожиданный порядок операций с каналами
Этот пример, найденный в etcd, показывает, как неожиданный порядок операций с каналами может привести к утечке goroutine:
Метод run запускает цикл, который ожидает многократного получения сообщений по каналу status (отправляемых путем вызова метода Status). В то же время он может получить одно сообщение по каналу stop (отправляемому через метод Stop), после чего он закрывает канал done и завершает работу. Сам метод Stop затем ожидает получения сообщения по done, которое разблокируется после закрытия done.
Утечка может произойти, если методы run, Status и Stop выполняются одновременно. Goroutine Stop и run могут синхронизироваться и завершить работу, не получив сообщение, выданное Status, что приведет к его вечной блокировке.
Обертывание отправки в status в оператор select, где другая ветка case пытается получить сообщение по done, позволяет goroutine, выполняющей Status, корректно завершить работу, если она проиграла гонку с вызовом Stop.
Пример (Kubernetes): Взаимная блокировка между каналами и мьютексами
Этот пример встречается в Kubernetes в результате смешивания каналов и блокировок:
Goroutine, выполняющая WriteFrame, может захватить фреймер с учетом простоя, за которым следует отправка сообщения по каналу resetChan, в то время как goroutine мониторинга ожидает получения сообщения по каналу closeChan. Как только сообщение отправлено, goroutine мониторинга попытается захватить ту же блокировку. Однако, поскольку трафика по resetChan нет, операция отправки блокируется навсегда, мешая goroutine мониторинга освободить блокировку. Это, в свою очередь, приводит к утечке обеих goroutine.
Исправление заключается в настройке отдельной goroutine после получения сообщения по closeChan в goroutine мониторинга, которая очищает resetChan перед попыткой захватить блокировку.
Пример (Moby): Неправильное использование sync.WaitGroup
Следующий пример в Moby демонстрирует, как группы ожидания могут вызывать утечки:
Группа ожидания group увеличивает свой счетчик в зависимости от количества плагинов, удерживаемых менеджером плагинов pm, затем перебирает каждый плагин и порождает goroutine. Каждая goroutine уменьшает счетчик после завершения своей задачи с помощью метода Done. Однако group ошибочно вызывает Wait внутри тела цикла, а не после него! Это приведет к утечке любой goroutine, выполняющей метод init, когда у менеджера больше одного плагина.
Эту проблему можно легко устранить, вынеся Wait за пределы цикла.
Пример (Moby): Взаимная блокировка между каналами и мьютексами
Другой пример в Moby демонстрирует утечку из-за смешивания каналов и блокировок:
Горутина, вызывающая StateChanged, может захватить блокировку контейнера, хранящегося в daemon, а затем вызвать метод updateHealthMonitorElseBranch в daemon, который пытается отправить сообщение через канал stop контейнера. Однако горутина, выполняющая monitor, может не получить сообщение через stop, если сообщение еще не находится в процессе передачи, и вместо этого разблокироваться, выбрав вариант по умолчанию (default) в операторе select. Это приведет к тому, что она попытается захватить ту же блокировку контейнера, которая уже удерживается горутиной StateChanged, что приведет к утечке обеих горутин.
Исправление заключается в том, чтобы закрыть канал stop вместо отправки через него сообщения. Поскольку закрытие канала не является блокирующей операцией, горутина StateChanged получает возможность освободить блокировку. В свою очередь, это разблокирует горутину monitor, которая теперь может завершиться, выбрав разблокированную ветку случая <-stop в операторе select на следующей итерации цикла.
