Как-то раз я допустил в своем коде дедлок и пока выкатывал пул реквест с его фиксом думал “ах как бы было хорошо, если дедлоки определялись на этапе компиляции”. Я решил немного разобраться в этом вопросе и вот что выяснил…

Попытка определить на этапе компиляции произойдет ли в программе дедлок в теории алгоритмов более известна как “Проблема остановки” и может быть сформулирована так: “Даны описание процедуры и её начальные входные данные. Требуется определить: завершится ли когда-либо выполнение процедуры с этими данными; либо, что процедура всё время будет работать без остановки”. Оказалось, что ответ на этот вопрос был дан уже более 80 лет назад и он… отрицательный. Формальное доказательство можно найти здесь. Но как же тогда бороться с дедлоками? Да и как вообще го определяет, что в программе дедлок? Не долго думая заглянем под капотом golang!

Под капотом находим метод checkdead, который и осуществляет проверку на наличие дедлоков. Изучим его устройство:

func checkdead() {
    …
	if panicking.Load() > 0 {
		return
	}
    …
	run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
	if run > 0 {
		return
	}
	…
	for _, pp := range allp {
		if len(pp.timers.heap) > 0 {
			return
		}
	}
    …
	fatal("all goroutines are asleep - deadlock!")
}

Сразу же видим интересную проверку

if panicking.Load() > 0 {
    return
}

panicking - это atomic.Bool переменная, которая отвечает на вопрос паникует ли сейчас программа и как видим, если паникует, то проверка на дедлок дальше не идет потому что… а зачем нам дедлок если мы и так паникуем? А если паника будет поймана recover, то полноценная проверка на дедлок выполнится уже в следующий раз когда будет вызван checkdead, а происходит это:

Идем дальше и видим основную логику проверки на наличие дедлоков.

run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
if run > 0 {
    return
}

В go эта проверка основана на числе работающих M(см. GMP модель).

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

nmidle - число потоков, бездействующих из-за отсутствия работы

nmidlelocked - число потоков, бездействующих из-за того, что они заблокированы ожиданием выполнения другой работы

nmsys - число потоков, используемых рантаймом в служебных целях

Таким образом в переменной run оказывается количество потоков, которые сейчас не бездействуют и не используются в служебных целях, то есть выполняют пользовательскую работу. Если находится хотя бы 1 такой поток, то мы понимаем, что дедлока нет.

Наконец переходим к последнему примечательному блоку

for _, pp := range allp {
    if len(pp.timers.heap) > 0 {
        return
    }
}

Здесь происходит итерирование через некий слайс allp. Этот слайс содержит в себе все аллоцированные структуры P(см. GMP модель). На каждой итерации цикла идет проверка на то, что к этому процессору привязаны таймеры, которые еще не завершили свою работу и если таковые находятся - дедлока не будет.

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

Конкретизируем наши наблюдения до двух интересных выводов:

1. Го не отлавливает частичные дедлоки, т. е. если будет хотя бы 1 поток, который выполняет какую‑то работу, даже если все остальные потоки заблокированы дедлока не случится. Посмотрим на небольшой пример

  func doInfiniteWork() {
   for {
      fmt.Println("some work")
      time.Sleep(1 * time.Second)
   }
}

func deadlockFunc() {
   ch := make(chan int)
   ch <- 1
}


func main() {
   wg := sync.WaitGroup{}
   
   wg.Add(1) 
   go func() {
      defer wg.Done()
      doInfiniteWork()
   }()

   wg.Add(1)
   go func() {
      defer wg.Done()
      deadlockFunc()
   }()

   wg.Wait()
}

Здесь мы видим 2 запущенные горутины, одна из которых в бесконечном цикле имитирует какую‑то работу, а вторая — сразу же блокируется за счет записи в небуферизированный канал из которого не происходит чтения. И как мы и ожидали — дедлока нет. Может возникнуть логичный вопрос — а почему в го нет детектора частичных блокировок, ведь есть готовые алгоритмы, которые решают эту проблему(поиск частичных блокировок в приложении с некоторыми уточнениями сводится к поиску компонент связности в ориентированном графе, где вершины это горутины, а ребра — зависимости между ними). Ответ прост — был proposal на его добавление еще в далеком 2015 году, его даже приняли, однако не нашлось никого, кто захотел бы это закодить, так что если вы хотите влететь с двух ног в open source и флексить тем, что приняли какое‑то участие в разработке целого языка — это ваш шанс:)

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

func deadlockWithTimerFunc() {
   _ = time.NewTimer(1 * time.Minute)
   ch := make(chan int)
   fmt.Println(ch)
   ch <- 1
}


func main() {
   deadlockWithTimerFunc()
}

Таймер здесь запускается на 1 минуту и всю эту минуту дедлока не случится, однако после его завершения сборщик мусора со спокойной душой заберет его и проверка на дедлок сразу же сообщит нам о том, что все потоки заблокированы.

В реальном приложении эту ситуацию можно встретить если вы используете тикеры для выполнения какой либо работы, например для записи фоновых метрик.

На этом у меня все, надеюсь эта короткая статья помогла узнать golang чуточку лучше, поделитесь тем как вы столкнулись с дедлоками в проде в комментариях и спасибо за прочтение!

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