Как-то раз я допустил в своем коде дедлок и пока выкатывал пул реквест с его фиксом думал “ах как бы было хорошо, если дедлоки определялись на этапе компиляции”. Я решил немного разобраться в этом вопросе и вот что выяснил…
Попытка определить на этапе компиляции произойдет ли в программе дедлок в теории алгоритмов более известна как “Проблема остановки” и может быть сформулирована так: “Даны описание процедуры и её начальные входные данные. Требуется определить: завершится ли когда-либо выполнение процедуры с этими данными; либо, что процедура всё время будет работать без остановки”. Оказалось, что ответ на этот вопрос был дан уже более 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, а происходит это:
при переключении треда в состояние ожидания новой работы(nmidle)
при вызова специального метода sysmon, который собственно и отвечает за мониторинг(mon) системы(sys)
Идем дальше и видим основную логику проверки на наличие дедлоков.
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 чуточку лучше, поделитесь тем как вы столкнулись с дедлоками в проде в комментариях и спасибо за прочтение!