Функции параллелизма в 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 помечает все горутины (и глобальные переменные) как достижимые, так что они никогда не будут рассматриваться как мусор, то есть они являются корнями сканирования (mark roots). Мы изменяем это поведение так, чтобы оно включало только разблокированные горутины, поскольку они гарантированно живые.
- За этим следует фаза разметки, в ходе которой GC отслеживает объекты, на которые ссылаются (транзитивно) корни сканирования, и помечает их как используемую память. Хотя мы не изменяем эту фазу напрямую, изменения на шаге 1 неявно гарантируют, что GC помечает только память, на которую ссылаются живые горутины.
- Фаза разметки завершается проверкой всех заблокированных горутин, не включенных в корни сканирования на шаге 1. Если горутина заблокирована хотя бы одним примитивом синхронизации, который был помечен на шаге 2, она добавляется в качестве корня сканирования, и GC возобновляет фазу разметки с шага 2. Это совпадает с индуктивным шагом в определении активности (liveness).
- Как только все живые горутины обнаружены, статус любой горутины, которая не была добавлена в качестве корня сканирования, устанавливается как «утечка» (leaked).
- Затем фаза разметки возобновляется в последний раз со всеми горутинами с утечкой, добавленными в качестве корней сканирования, что позволяет GC пометить всю память, которую он пометил бы во время обычного запуска.
После завершения цикла GC профилировщик утечек горутин начинает работу так же, как и при обычном профилировании горутин, и фильтрует строго утекающие горутины.
Ограничения
Приведенные выше примеры демонстрируют полезность профилей утечек горутин. Тем не менее, у сборщика мусора есть некоторые ограничения, которые могут привести к пропуску утечек:
- Избыточный охват памяти (Memory overreach): если примитив синхронизации постоянно доступен через глобальные переменные или выполняемые горутины, то горутины, блокирующие его, никогда не будут зарегистрированы как утекающие, даже если этот примитив синхронизации больше никогда не будет использоваться. Это можно смягчить за счет более строгого упорядочивания доступа к ссылкам на примитивы синхронизации и более четкого определения их жизненного цикла.
Избыточный охват памяти (Memory overreach): если примитив синхронизации постоянно доступен через глобальные переменные или выполняемые горутины, то горутины, блокирующие его, никогда не будут зарегистрированы как утекающие, даже если этот примитив синхронизации больше никогда не будет использоваться.
Это можно смягчить за счет более строгого упорядочивания доступа к ссылкам на примитивы синхронизации и более четкого определения их жизненного цикла.
- Нестандартная блокировка: ради корректности обнаружение утечек горутин строго ограничено первоклассными примитивами синхронизации Go, к которым относятся: операции отправки и получения по каналам (в том числе по nil-каналам), блокирующие операторы select (т. е. без блока default, вплоть до операторов select без каких-либо вариантов), а именно элементы пакета sync, в частности Mutex, RWMutex, WaitGroup и Cond. Горутины, заблокированные по любой другой причине (например, ввод-вывод файлов и сети или прямые системные вызовы), никогда не считаются утекающими. Это также относится к пользовательской логике синхронизации (например, спинлокам), если только в их реализации на низком уровне не используются описанные выше примитивы.
Нестандартная блокировка: ради корректности обнаружение утечек горутин строго ограничено первоклассными примитивами синхронизации Go, к которым относятся: операции отправки и получения по каналам (в том числе по nil-каналам), блокирующие операторы select (т. е. без блока default, вплоть до операторов select без каких-либо вариантов), а именно элементы пакета sync, в частности Mutex, RWMutex, WaitGroup и Cond.
Горутины, заблокированные по любой другой причине (например, ввод-вывод файлов и сети или прямые системные вызовы), никогда не считаются утекающими. Это также относится к пользовательской логике синхронизации (например, спинлокам), если только в их реализации на низком уровне не используются описанные выше примитивы.
- Недетерминизм: утечки могут быть обнаружены только после того, как они произошли, но их нельзя предсказать заранее, поэтому воспроизведение и диагностика утечек в нестабильных программах по-прежнему остается сложной задачей. Для достижения наилучших результатов мы рекомендуем комбинировать подходы, используя профили утечек горутин на различных уровнях, вплоть до продакшена, а также комплексные наборы тестов с инструментами goleak и synctest.
Недетерминизм: утечки могут быть обнаружены только после того, как они произошли, но их нельзя предсказать заранее, поэтому воспроизведение и диагностика утечек в нестабильных программах по-прежнему остается сложной задачей. Для достижения наилучших результатов мы рекомендуем комбинировать подходы, используя профили утечек горутин на различных уровнях, вплоть до продакшена, а также комплексные наборы тестов с инструментами goleak и synctest.
Влияние на производительность
Обнаружение утечек горутин было тщательно спроектировано так, чтобы минимизировать влияние на производительность, но тем не менее определенные издержки существуют.
Хотя накладные расходы на память ничтожно малы и ограничиваются лишь небольшими дополнениями, необходимыми для учета, обнаружение утечек горутин может работать медленнее, чем обычный GC. Лучше всего это иллюстрирует патологический случай, который мы называем «цепочкой»: в этом примере без утечек исполняемая горутина G₀ имеет ссылку на примитив P₁, который блокирует G₁, и так далее.
Это означает, что доказательство активности для некоторого Pᵢ₊₁ требует доказательства активности для Pᵢ, что влечет за собой две проблемы:
- Фаза разметки GC эффективно сериализуется относительно порядка, в котором горутины могут быть просканированы, поскольку вся память, доступная из некоторого Pᵢ, должна быть помечена до того, как Pᵢ₊₁ сможет быть добавлен в качестве корня.
- Проверка в настоящее время проверяет все заблокированные горутины в конце каждого раунда разметки, что в худшем случае дает O(n²) шагов для одного цикла GC, где n — общее количество горутин.
Хотя второй пункт со временем может быть оптимизирован, первый пункт является неотъемлемым ограничением обнаружения утечек, которое невозможно обойти.
Тем не менее, мы напоминаем читателю, что, если не задано иное с помощью флагов рантайма, GC по-прежнему работает параллельно с пользовательским кодом. Более того, если утечку горутины можно обнаружить в какой-то момент времени, ее также можно обнаружить в любой будущий момент в ходе того же выполнения. Следовательно, инфраструктуры периодического профилирования могут настраивать частоту профилирования (например, каждые 4 часа), чтобы минимизировать накладные расходы практически без потери возможностей обнаружения утечек.
Благодарности
Обнаружение утечек горутин является результатом научного сотрудничества Орхусского университета, Вашингтонского университета в Сент-Луисе и Uber, как представлено в “Dynamic Partial Deadlock Detection and Recovery via Garbage Collection” (Saioc et al., ASPLOS 2025).
Переход от академического прототипа к реальной функции Go стал возможным благодаря руководству Майкла Книшека (Michael Knyszek) и Майкла Пратта (Michael Pratt) из команды Go в Google, а также Пи Джея Моллоя (PJ Malloy) (@thepudds).
Дополнительные примеры
Ниже приведены паттерны кода, приводящие к утечкам, которые наблюдаются в кодовых базах промышленного масштаба и проектах с открытым исходным кодом, в порядке возрастания сложности.
Вы можете быстро протестировать детектор утечек горутин в Go playground, а также поэкспериментировать с собственными утечками.
Пример: Двойная отправка
Некоторые из самых простых утечек происходят, когда по каналу отправляется больше сообщений, чем ожидается. Ниже ожидается, что горутина отправит одно сообщение главной горутине по небуферизованному каналу. Однако в случае ошибки после операции отправки отсутствует оператор return. Следовательно, при каждой ошибке отправитель попытается отправить два сообщения, что приведет к утечке.
Хотя профиль явно не указывает на пропущенный return как на причину, он по крайней мере направляет вас к ошибочной функции, подсвечивая утечку при операции отправки.
Эту утечку можно устранить, просто добавив оператор return после операции отправки в случае ошибки.
Пример: Ранний выход
Обратная ситуация встречается не реже: получатель пропускает коммуникацию на некоторых путях управления, что по сути является упрощенной версией вводного примера.
Утечка горутин обнаруживается с помощью профиля:
Эту утечку можно устранить, выделив для ch буфер размером 1.
Пример: Таймаут
Вариант вышеописанного паттерна раннего выхода включает в себя контексты и недетерминированный выбор (операторы select):
Если контекст отменяется до того, как отправитель синхронизируется с родителем, отправитель утечет:
Как и в предыдущем примере, исправление заключается в том, чтобы выделить каналу буфер размером 1.
Пример: Обход канала в цикле range без закрытия
Итерация по каналам с использованием range позволяет многократно получать значения из канала в цикле. Как только канал закрывается и все значения, поставленные в очередь в буфере канала, получены, цикл завершается.
Важно отметить, что если канал никогда не закрывается, цикл range будет блокировать выполняющуюся горутину вечно. Пропуск операции закрытия — частая ошибка, как показано ниже:
Профиль утечки горутин для такой программы будет включать следующее:
Мы видим 3 воркера, заблокированных на операции range ch, что дает четкий намек на причину утечки. Утечку можно устранить, просто закрыв канал после отправки всех сообщений:
Бонус! Внимательные читатели могли заметить еще одну потенциальную утечку в этом примере, если количество воркеров по ошибке установлено равным нулю, что приведет к утечке у родительского отправителя:
Это также фиксируется профилем:
Хотя в реальных производственных системах можно предполагать выполнение условия workers > 0, профили утечки горутин тем не менее можно использовать для неявного мониторинга редких нарушений без консервативных проверок workers <= 0.
Пример: Нарушения контракта методов
Рассмотренные до сих пор паттерны были относительно ограничены в своей лексической области видимости. Однако по мере того, как функциональность распределяется по функциям, методам и пакетам, а реализации маскируются интерфейсами, сложность ручного обнаружения утечек резко возрастает.
Такой случай проиллюстрирован в этом разделе на примере пользовательского типа воркера, который внедряет два поля каналов, ch и done, и создает циклическую горутину с помощью своего метода Start, считывающую данные из обоих каналов с помощью оператора select. Упомянутая горутина может быть завершена только путем получения сообщения через канал done, который закрывается методом Stop.
Метод Start может вызываться любое количество раз, но если он вызван хотя бы один раз, в конечном итоге должен быть вызван Stop.
В результате Start и Stop образуют неявный контракт, определяющий порядок вызова методов. Нарушение этого контракта может привести к нежелательному поведению, в данном случае — к утечкам горутин:
Эта проблема еще больше усугубляется на практике, когда такие пользовательские типы экспортируются только как интерфейсы, в данном случае через абстрактный тип Worker. Клиенты могут даже не подозревать о лежащей в основе реализации и, следовательно, нарушать неявный контракт, сами того не осознавая.
К счастью, получение профиля утечки горутин может выявить дефект:
Естественно, исправление заключается в том, чтобы проследить цепочку до вызова Start и добавить вызов Stop.
Пример (Cockroach): Пропущенная разблокировка
Следующий example взят из CockroachDB. Он включает в себя захват и освобождение блокировки в цикле, но забывает разблокировать ее перед выполнением оператора break:
В таком случае горутина даст утечку при сбое захвата блокировки.
Добавление вызова Unlock перед break решает проблему.
Пример (etcd): Неожиданный порядок операций с каналами
Этот example, найденный в etcd, показывает, как неожиданный порядок операций с каналами может привести к утечке горутин:
Метод run запускает цикл, который ожидает многократного получения сообщений по каналу status (отправляемых путем вызова метода Status). В то же время он также может получить одно сообщение по каналу stop (отправляемое через метод Stop), после чего закрывает канал done и завершает работу. Сам метод Stop затем ожидает получения сообщения по done, разблокировка которого происходит после закрытия done.
Утечка может возникнуть, если методы run, Status и Stop работают параллельно. Горутины Stop и run могут синхронизироваться и завершить работу, не получив сообщение, отправленное Status, что приведет к его вечной блокировке.
Обертывание отправки в status в оператор select, где другая ветка case пытается получить сообщение по done, позволяет горутине, выполняющей Status, корректно завершить работу, если она проиграла гонку вызову Stop.
Example (Kubernetes): Mutual blocking between channels and mutexes
Этот example возникает в Kubernetes в результате смешивания каналов и блокировок:
Горутина, выполняющая WriteFrame, может захватить блокировку фреймера, зависящего от простоя, с последующей отправкой сообщения по каналу resetChan, в то время как горутина мониторинга ожидает получения сообщения по каналу closeChan. Как только сообщение будет отправлено, горутина мониторинга попытается захватить ту же блокировку. Однако, поскольку трафика по resetChan нет, операция отправки блокируется навсегда, мешая горутине мониторинга освободить блокировку. Это, в свою очередь, приводит к утечке обеих горутин.
Решение заключается в том, чтобы после получения сообщения по closeChan настроить в горутине мониторинга отдельную горутину, которая очищает resetChan перед попыткой захватить блокировку.
Пример (Moby): Неправильное использование sync.WaitGroup
Следующий пример в Moby демонстрирует, как группы ожидания могут вызывать утечки:
Группа ожидания wait group увеличивает свой счетчик в зависимости от количества плагинов, удерживаемых менеджером плагинов pm, затем перебирает каждый плагин и порождает горутину. Каждая горутина уменьшает счетчик после завершения своей задачи с помощью метода Done. Однако group ошибочно вызывает Wait внутри тела цикла, а не после него! Это приведет к утечке любой горутины, выполняющей метод init, когда у менеджера больше одного плагина.
Эту проблему можно легко устранить, вынеся Wait за пределы цикла.
Пример (Moby): Взаимная блокировка между каналами и мьютексами
Другой example в демонстрирует утечку из-за смешивания каналов и блокировок:
Горутина, вызывающая StateChanged, может захватить блокировку контейнера, хранящегося в daemon, а затем вызвать метод updateHealthMonitorElseBranch в daemon, который пытается отправить сообщение по каналу stop контейнера. Однако горутина, выполняющая monitor, может не получить сообщение по каналу stop, если сообщение еще не находится в процессе передачи, а вместо этого разблокироваться, выбрав вариант по умолчанию (default) в операторе select. Это приведет к тому, что она попытается захватить ту же блокировку контейнера, которая уже удерживается горутиной StateChanged, что приведет к утечке обеих горутин.
Исправление заключается в том, чтобы закрыть канал stop вместо отправки по нему сообщения. Поскольку закрытие канала не является блокирующей операцией, горутина StateChanged получает возможность освободить блокировку. В свою очередь, это разблокирует горутину monitor, которая теперь может завершиться, выбрав разблокированную ветку случая <-stop в операторе select на следующей итерации цикла.
