Search
Write a publication
Pull to refresh

В поисках мертвых горутин

Level of difficultyMedium
Reading time5 min
Views2.2K

Я пишу всякое на 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 лет назад

Tags:
Hubs:
+8
Comments7

Articles