Я пишу всякое на Go в Ви.Tech (IT-дочка ВсеИнструменты.ру) и люблю периодически изучать предлагаемые нововведения. Недавно мы собирались внутренним комьюнити, обсуждали интересные пропозалы из гитхаба Go. Среди прочего — изящный и крайне перспективный Proposal #74609 Deadlock detection by using the garbage collector Собственно, о нём сегодня и пойдёт речь.

Мотивация

Горутины могут блокироваться — например, при попытке захватить уже занятую блокировку или отправить сообщение в канал, по которому ещё никто не читает. Если все горутины заблокированы, рантайм Go завершает выполнение с фатальной ошибкой о глобальной взаимной блокировке (deadlock).

Однако гораздо чаще встречаются частичные дедлоки (также известные как утечки горутин), когда часть, но не все горутины блокируются навсегда. В отличие от глобальных дедлоков, которые случаются редко, частичные дедлоки — частая проблема в реальных приложениях из-за непредсказуемых путей исполнения и планирования.

На текущий момент Go не предоставляет встроенного механизма для обнаружения или устранения частичных дедлоков. Поскольку стандартный сборщик мусора не распознаёт такие ситуации, он не может освободить ни память, занятую “мёртвыми” горутинами, ни ресурсы, достижимые только через их стеки.

Наиболее продвинутыми динамическими средствами обнаружения частичных дедлоков являются Goleak и LeakProf:

  • Goleak проверяет, остались ли лишние горутины после выполнения тестов, но не подходит для продакшен-сред.

  • LeakProf можно использовать в продакшене, но он страдает от ложных срабатываний и пропусков.

Существуют и статические методы анализа, но они тоже имеют ряд ограничений.

Простой пример дедлока

type GoFuncManager interface {
   WaitForResults() []any
}

type goFuncManager struct {
   d chan any
}

func NewFuncManager() GoFuncManager {
   gfm := &goFuncManager{
      d: make(chan any),
   }
   go func() {
      for data := range gfm.d { ... }
   }()

   return gfm

}
// если не вызывалось  - горутина из конструктора будет заблокирована
func (gfm *goFuncManager) WaitForResults() []any {
      close(gfm.d)
      ...
}
// после завершения канал d доступен только из заблокированой горутины
func ConcurrentTask() {
   gfm := NewFuncManager()
   if ... { //Если условие ложно WaitForResults не вызывается
      return
   }

   gfm.WaitForResults()
}

После завершения ConcurrentTask() приложение может оказаться в состоянии, где канал d доступен только из стека заблокированной горутины. То есть доступ к ресурсам теряется, и они не могут быть освобождены обычным GC.

Решение

Ребята из Uber разработали расширение сборщика мусора — Go Leak Fixer (GOLF), которое может обнаруживать частичные дедлоки и, с некоторыми ограничениями, освобождать занимаемые ресурсы. Так, например, GOLF игнорирует горутины с финализаторами и очистками, т.к. их срабатывание может привести к непредсказуемым последствиям.

Это расширение исключительно в части маркировки мёртвых горутин (без очистки) и стало основой Proposal #74609

Текущая статусная модель горутин предполагает 8 состояний

_Gidle

idle

Ожидает назначения

_Grunnable

runnable

Готова к выполнению, стоит в очереди планировщика.

_Grunning

running

В данный момент исполняется.

_Gsyscall

syscall

Заблокирована в системном вызове

_Gwaiting

waiting

Ждёт события: канал, мьютекс, таймер и т. п.

_Gdead

dead

Завершена, может быть переиспользована.

_Gcopystack

copystack

Выполняется перенос стека в больший или меньший.

_Gpreempted

preempted

Прервана планировщиком

К ним будет добавлено новое конечное состояние deadlock — спит и никогда не сможет быть разбужена

Ключевая идея заключается в том, что достижимость в памяти, как её определяет сборщик мусора Go, аппроксимирует “живость” горутин.

Иными словами: если горутина заблокирована, например, на chan send, и связанный с этим канал недостижим из памяти ни от одной “живой” горутины — эта горутина никогда не разблокируется и, следовательно, мертва.

Суть подхода — начать с минимального множества корней, содержащего только достижимо живые горутины, и использовать сборщик мусора для прогрессивной пометки объектов памяти и расширения множества корней за счёт других живых горутин.

Если совсем просто, механизм такой:

  1. Берём все runnable и running горутины — они очевидно достижимы, это наше стартовое множество корней.

  2. Помечаем все объекты, достижимые от корней.

  3. Для каждой заблокированной g:

    • проверяем, помечен ли объект, на котором она блокируется;

    • если да — добавляем к корням.

  4. Возвращаемся к п.2 и повторяем до достижения fixed point.

И, собственно, всё — просто и со вкусом.

go func() {
   <-ch1         // g1
}()

go func() {
   ch1 <- <-ch2  // g2
}()

go func() {
   ch2 <- 1      // g3
}()

В примере выше:

  • g3 runnable, попадает в корни;

  • ch2 → помечается → g2 ждёт ch2 → теперь достижима;

  • ch1 → помечается → g1 ждёт ch1 → достижима;

  • все g1, g2, g3 будут помечены как живые.

Формальная сложность: O(N² + N·S), где N — количество горутин, S — количество блокирующих объектов. Есть потенциал для оптимизации до O(N²).

Хотя квадратичная сложность в O-нотации выглядит страшно, на практике текущая реализация почти не влияет на производительность, что делает её пригодной для использования в продакшене.

Описанный подход не имеет ложно-положительных кейсов, но может иметь ложно-отрицательные.

type dispatcher struct {
    ch chan struct{}
    ticks int
}

func newDispatcher() *dispatcher {
    d := &dispatcher{
        ch: make(chan struct{}),
        ticks: 0,
    }
    go func() { // Heartbeat
        for ;; time.Sleep(time.Second) {
            d.ticks++
        }
    }()
    
    return d
}

func main() {
    d := newDispatcher()
    go func() { d.ch <- struct{}{} }()
    
    if ... { return }
    
    <-d.ch
}

Если if сработал и main() завершился, приём из ch не произойдёт и горутина с записью зависнет: она ждёт, пока кто-то прочитает из канала. Анализатор не сможет обнаружить этот дедлок, потому что:

  • горутина с heartbeat никогда не завершится;

  • значит, структура dispatcher всегда остаётся достижимой и её поле ch — тоже;

  • следовательно, анализатор считает, что какая-то живая горутина может в теории прочитать из ch.

Авторы приводят экспериментальную оценку на трёх категориях программ:

  • 73 микротеста с заранее известными дефектами;

  • 3 111 Go-пакетов из кода Uber, каждый с тестами;

  • один реальный сервис из микросервисной архитектуры Uber.

По итогам:

  • обнаружено 94% частичных дедлоков в микротестах;

  • при прогоне 3 111 тестов — 180 из 357 известных дедлоков;

  • в реальном сервисе выявлено 3 ошибки, вызвавшие 252 частичных дедлока за 24 часа.

Планы

На мой взгляд, самое крутое в этом пропозале — минимальные изменения в существующий код, весь дифф занимает примерно 600 строк. Хотя в любом случае мы вряд ли увидим его скоро.

Предлагается включить это поведение в GOEXPERIMENT.

Дополнительно, чтобы все это имело смысл, потребуется добавить поддержку в net/http/pprof и флаг для go test (как race detector). Обсуждается возможность лимитировать запуск раз в N циклов GC.

Ну и конечно, потенциальная возможность автоматического сбора зависших горутин выглядит очень вкусно, но вызывает опасения. Главная проблема в том, что утекшая горутина может удерживать не только память, но и другие ресурсы — сетевые соединения, файлы, C-память и т. п. Их можно было бы освободить через финалайзеры, но непонятно, безопасно ли это. Освобождение только памяти может скрыть проблему, усложнив отладку в будущем. Но даже просто детектор утечек горутин — уже крайне полезен для экосистемы Go.

P.S. Любопытно, что подобная концепция определения мертвых гоуртин была предложена еще 12 лет назад

Комментарии (7)


  1. Sly_tom_cat
    18.08.2025 22:25

    Так, например, GOLF игнорирует горутины с финализаторами и очистками, т.к. их срабатывание может привести к непредсказуемым последствиям.

    Как по мне - это самая серьезная проблема пропозала.

    Сами по себе финализаторы не рекомендованы, однако есть довольно много кейсов когда без значительных изменений в кодовой базе нужно туда затянуть примитив в котором есть ресурсы которые явно надо освобождать, и тут финализаторы служат спасением.

    Не скажу что часто с этим сталкивался, но один раз вот реально сильно выручила возможность навесить финализатор. И подозреваю, что кейсов, когда финализатор - меньшее зло - их довольно много.

    Ну а очистки ресурсов - это вообще повсеместно. И если все очистки не засунуты в defer (а иногда их туда и не засунуть), то вопрос корректного освобождения ресурсов становится совсем не тривиальным.


    1. sl4mmer Автор
      18.08.2025 22:25

      Финализаторы не рекомендованы с появляением очисток, тк финализиторы изначально вообще вводились, чтоб иметь возможность корректно вернуть ОС память с выделенную через C.malloc. Но в целом да, вещи ломающий абстракцию гц (финализаторы слабые ссылки, очистик) - рекомендуется использовать редко и только когда действительно в этом есть смысл

      Ну и я наверное излишне упростил, по факту горутины с финализаторами- переводятся в состояние deadlock (если недостижимы), но считаются живыми для очистки. Те дедлоклок будет задетекчен, просто в ориганильном GOLF такие не очищаются автоматически


      1. Sly_tom_cat
        18.08.2025 22:25

        Ну а смысл в такой разметке?
        Как по мне развешивание флагов - это имеет смысл только если всех с флагами - фтопку. А если выборочно, то нафига навешивать флаги на то, что в топку не отправить?


  1. A1WEB
    18.08.2025 22:25

    Насколько широко Go используется в крупной торговле в России? Модное веянье или надолго?


    1. sl4mmer Автор
      18.08.2025 22:25

      ВсеИнструменты, Магнит, Озон, Вайлдберис, Авито это из того что сразу на ум пришло


      1. Kokoulin
        18.08.2025 22:25

        У Яндекса это тоже часть стека, тинькофф, туту, и многие другие активно пишут на Го


    1. smarthomeblog
      18.08.2025 22:25

      Яндекс переписал свою главную страницу недавно с Perl на Go