Всем привет! Меня зовут Дима, я лид команды государственных интеграций в 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 случаев:
при переключении треда в состояние ожидания новой работы — nmidle
при увеличении числа заблокированных потоков — nmidlelocked
при вызове специального метода 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 чуточку лучше. Напишите в комментариях, сталкивались ли вы с дедлоками в проде?
И спасибо за прочтение!