Pull to refresh
39.75

Секреты дедлоков в Go

Level of difficultyEasy
Reading time4 min
Views2.8K

Всем привет! Меня зовут Дима, я лид команды государственных интеграций в Ozon Банке. Возможно вы видели мою статью про жизненный цикл горутин (если нет, советую заглянуть). Сегодня статья будет тоже про Go, а точнее про борьбу с дедлоками.

Как-то раз я допустил в своем коде дедлок и, пока выкатывал пул-реквест с его фиксом, думал: «Ах, как бы было хорошо, если бы дедлоки определялись на этапе компиляции». Я решил немного разобраться в этом вопросе и в этой статье расскажу о том, что выяснил.

Как бороться с дедлоками?

Попытка определить на этапе компиляции, произойдёт ли в программе дедлок, в теории алгоритмов более известна под названием «проблема остановки». Сформулировать её можно так:

Даны описание процедуры и её начальные входные данные. Требуется определить, завершится когда-либо выполнение процедуры с этими данными или же процедура всё время будет работать без остановки.

Оказалось, что ответ на этот вопрос был дан уже более 80 лет назад и он… отрицательный. Формальное доказательство можно найти вот здесь. Но как же тогда бороться с дедлоками? Да и как вообще Go определяет, что в программе дедлок?

Не долго думая, заглянем под капот языка. А там мы находим метод 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 — в одном из 3 случаев:

  1. при переключении треда в состояние ожидания новой работы — nmidle

  2. при увеличении числа заблокированных потоков — nmidlelocked

  3. при вызове специального метода sysmon, который отвечает за системный (sys) мониториниг (mon)

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

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 модель). На каждой итерации цикла идёт проверка: привязаны ли к процессору таймеры, которые ещё не завершили свою работу. Если такие находятся — дедлока не будет.

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

К чему это нас привело?

Из своих наблюдений я пришёл к 2 интересным выводам:

Вывод №1

Go не отлавливает частичные дедлоки. Если есть хотя бы 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 горутины, одна из которых в бесконечном цикле имитирует какую-то работу, а вторая сразу же блокируется из-за записи в небуферизированный канал, который не читается. Как мы и ожидали — дедлока нет.

Может возникнуть логичный вопрос: почему в Go нет детектора частичных блокировок? Существуют же готовые алгоритмы, которые решают эту проблему: поиск частичных блокировок в приложении сводится по сути к поиску компонента связности в ориентированном графе. Где вершины — это горутины, а ребра — зависимости между ними.

Ответ прост: был 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 чуточку лучше. Напишите в комментариях, сталкивались ли вы с дедлоками в проде?

И спасибо за прочтение!

Tags:
Hubs:
+4
Comments2

Articles

Information

Website
job.ozon.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия