Взаимная блокировка, или deadlock, является самой обидной неисправностью в многопоточном коде: программа не падает, не пишет ошибок, не жрёт процессор, а просто замирает. Пользователь видит окно, которое не отвечает, служба перестаёт обслуживать запросы, а в логах тишина. Опытный отладчик знает, что за этой тишиной стоит простая механика: два или более потока держат ресурсы и ждут друг друга по кругу, и никто из них не может сдвинуться ни на шаг. Эта статья разбирает анатомию взаимной блокировки, четыре условия Коффмана, классические сценарии из реальной практики и конкретные приёмы, которые позволяют находить зависания в дампах и предотвращать их при проектировании.
Четыре условия Коффмана и главный вывод из них
В 1971 году Эдвард Коффман сформулировал четыре условия, которые должны выполняться одновременно, чтобы в системе возникла взаимная блокировка. Этот список ценен не только для теоретиков: он даёт четыре рычага, за которые можно дёргать на практике, потому что устранение любого одного условия делает дедлок математически невозможным.
Первое условие называется взаимным исключением. Ресурс в конкретный момент времени принадлежит только одному потоку: один держит mutex, один открыл файл монопольно, один держит объект lock. Если ресурс можно разделять без ограничений, условие снимается само собой, но большинство реальных ресурсов, от критических секций до портов принтера, исключительны по своей природе.
Второе условие называется удержанием и ожиданием. Поток уже держит один ресурс и при этом ждёт следующий, не отпуская первый. Если бы каждый поток сперва отпускал всё накопленное, а потом запрашивал новое, дедлок бы растворился. К сожалению, такой стиль часто невозможен: транзакция не может отдать строки базы на середине работы.
Третье условие называется отсутствием вытеснения. Никто не может отнять ресурс у потока насильно, освобождение происходит только добровольно. Некоторые системы именно здесь и ломают дедлок: база данных выбирает жертву и откатывает её транзакцию, фактически вытесняя ресурс у процесса.
Четвёртое условие называется циклическим ожиданием. Существует замкнутая цепочка: поток А ждёт ресурс у потока Б, поток Б ждёт у потока В, поток В ждёт у потока А. Любой граф ожидания без цикла безопасен, поэтому порядок захвата ресурсов является столь мощным инструментом.
Главный практический вывод звучит просто: инженеру не нужно побеждать все четыре условия, достаточно сломать одно. Выбор конкретного условия и определяет стратегию защиты конкретной системы.
Классическая сценка с двум замками и два принтера
Канонический сценарий умещается в четыре строки. Поток А захватывает Lock1 и ложится ждать Lock2. Поток Б захватывает Lock2 и ждёт Lock1. Если между захватами есть хоть минимальное окно, рано или поздно планировщик прерывает оба потока ровно в неудачной точке, и система виснет намертво. Каждый поток держит то, что нужно другому, и цикл замкнулся.
Из мира железа приходит живой пример: бухгалтерия, два крупных офисных устройства и негласное правило "сначала захвати сканер, потом принтер". Один сотрудник вставил стопку бумаг в сканер и ждёт освобождения принтера, а его коллега на другом устройстве уже встал в очередь печати и теперь ждёт сканер. Каждый держит одно устройство и хочет второе, и без вмешательства диспетчера оба будут стоять до обеда. Здесь работают все четыре условия Коффмана, и программные дедлоки ведут себя точно так же, только в наносекундном масштабе.
Транзакции в базах данных показывают взрослую версию той же сцены. Одна транзакция обновляет сначала таблицу Orders, затем Inventory; другая транзакция идёт в обратном порядке. Рано или поздно работа двух сессий пересекается, и SQL Server обнаруживает цикл в графе ожидания. СУБД применяет принудительное вытеснение: выбирает более дешёвую для отката транзакцию в качестве жертвы, отменяет её с ошибкой 1205 и разблокирует вторую. Клиент получает исключение, должен повторить операцию, но система в целом не висит. Именно поэтому код доступа к базе обязан уметь повторять транзакции, а инженер обязан упорядочивать обновление таблиц.
Третья бытовая разновидность живёт в асинхронном коде на платформе .NET. Разработчик пишет обработчик кнопки в WinForms или WPF и вызывает background.Result, чтобы дождаться результата асинхронной операции. UI-поток останавливается на ожидании, а продолжение await внутри операции пытается вернуться на тот же SynchronizationContext, который занят именно этим UI-потоком. Классический .Result deadlock: поток ждёт продолжение, продолжение ждёт поток. Лечится применением async и await до самого верха по стеку либо ConfigureAwait(false) в библиотечном коде, где контекст синхронизации не нужен.
Методы предотвращения на этапе проектирования
Самый надёжный и самый дешёвый метод называется иерархией блокировок. Всем замкам присваивается глобальный порядок, и любой код в системе всегда захватывает их только по возрастанию ранга: сначала Lock1, потом Lock2, никогда наоборот. При таком порядке цикл в графе ожидания построить невозможно по построению, и четвёртое условие Коффмана исчезает. Цена метода в дисциплине: порядок нужно документировать, проверять на ревью и соблюдать в каждой новой функции, включая вызовы внешних обратных вызовов, которые держат собственные неочевидные замки.
Второй метод расслабляет условие удержания и ожидания через таймауты захвата. Вместо вечного ожидания применяется Monitor.TryEnter с ограничением по времени или async-аналог SemaphoreSlim.WaitAsync с CancellationToken. Если замок не получен за разумный интервал, поток отпускает уже накопленные ресурсы, делает паузу и повторяет попытку с честным ограничением числа попыток. Таймаут превращает дедлок в оживлённую, но временную конкуренцию, которая рано или поздно рассасывается сама.
Третий путь вообще выводит ситуацию из зоны взаимного исключения. Lock-free структуры данных на атомарных операциях вроде Interlocked.CompareExchange позволяют обновить указатель без блокировки вовсе. Модель актёров делает ещё более сильный шаг: каждый актёр имеет собственное состояние и общается с соседями только неизменяемыми сообщениями через очередь, поэтому совместно владеемых замков в системе почти не остаётся. Лок есть только внутри почтового ящика, и он слишком узок, чтобы строить циклы.
- Зафиксировать глобальный порядок замков и описать его в документе проекта.
- Применять таймауты захвата там, где порядок по техническим причинам гарантировать нельзя.
- Там, где схема позволяет, заменять общие данные на lock-free структуры или на обмен сообщениями между актёрами.
- Не вызывать внешний код под локом и не держать лок дольше, чем это строго необходимо.
Это единственный нумерованный список в статье, но его стоит повесить у доски каждой команды, которая пишет многопоточный код.
Как расследовать зависание по дампу
Четвёртая часть ремесла наступает тогда, когда дедлок уже случился на стенде или у заказчика. Главное правило: не перезапускать процесс, не сняв дамп памяти. Диспетчер задач умеет сохранить полный дамп процесса, и этот файл является главным свидетелем происшествия.
В среде Windows исследование ведётся в WinDbg. Штатная команда !locks выводит критические секции и цепочки ожидания: какой поток владеет секцией, какие потоки стоят в очереди. Следующим шагом строятся стеки ожидающих потоков и ищутся пересечения: поток со стеком внутри EnterCriticalSection на объекте, которым владеет другой ожидающий поток. Найденный цикл из двух или трёх вершин даёт готовую диаграмму сценария. Для managed-кода добавляется команда !syncblk из sos, показывающая владельцев Monitor, и перекрёстный анализ !threads и !dumpstack.
Каналы Windows Error Reporting приносят готовую пользу ещё до ручного анализа. Если приложение зависает в поле, WER автоматически собирает дамп, помечает отчёт меткой Hang, и разработчик получает типичный стек зависания вместе с сигнатурой. Повторяющиеся сигнатуры Hang показывают, какой именно сценарий вносит наибольший вклад в жалобы, и позволяют ставить приоритеты на исправление.
При журналировании зависаний стоит фиксировать и граф ожидания самой системы: регулярный процесс, который периодически вызывает sys.dm_tran_locks и sys.dm_os_waiting_tasks в SQL Server, позволяет поймать циклическое ожидание прямо в момент его зарождения и получить XML-граф дедлока через deadlock graph. Такой XML читается как книга и точно показывает владельцев, жертву и обе цепочки запросов.
Livelock и голод как близкие родственники
Livelock часто путают с дедлоком, но различие принципиально. При дедлоке потоки стоят, при livelock они энергично работают, и прогресса нет в обоих случаях. Пример: два потока, столкнувшись на одном ресурсе, вежливо отступают и повторяют; оба постоянно откатывают и заново сталкиваются, процессор греется, а полезной работы нет. Политика экспоненциальной паузы со случайной составляющей разбивает такое согласование и выводит один из потоков вперёд.
Голод, или starvation, является третьим членом семейства. Строгой блокировки нет вообще, просто какой-то поток систематически обходится без процессора или ресурса: очередь с приоритетами бесконечно подсовывает более важные задания, и низкоприоритетный обработчик никогда не выполняется. Планировщик с учётом старения заданий и честная ротация решают проблему, но обнаружить её сложнее, чем дедлок, поскольку внешне система выглядит живой.
Дисциплина ширины лока и практические правила
Когда анализ завершён и код исправлен, наступает этап дисциплины, потому что дедлоки являются болезнью культуры проекта. Чем шире область, охватываемая локом, тем выше вероятность, что под ним произойдёт вызов чужого кода, обращение к сети или вложенный захват второго ресурса. Широкий лок удобен программисту и опасен системе. Чем аккуратнее и уже держится лок, тем предсказуемее поведение: короткая критическая секция обновляет строго защищённые поля и сразу выходит, а долгие операции выполняются на локальной копии данных.
Дополнительной опорой служат неизменяемые структуры данных и копирование при записи: если объект после публикации никогда не меняется, читателям не нужны блокировки вовсе, и пространство для дедлока сжимается до одного атомарного обновления указателя. Компилятор, анализатор и аккуратный ревью-комментарий "а под каким локом мы сейчас стоим" экономят недели расследования через год после публикации.
Полезным дополнением становится привычка документирования: у каждого лока в проекте есть имя роли, и правила захвата собраны в одном месте, а не распределены по комментариям у кода. Когда возникает вопрос «кто кого ждёт», ответ должен читаться как короткая таблица, а не как блуждание по сорока файлам. Там, где такая таблица отсутствует, разработчики обнаруживают её, вычитая стек-трейсы посмертно: поток А стоит внутри критической секции с ожиданием, поток Б - наоборот, и цикл нарисован прямо в дампе. Урок после каждого инцидента формулируется одинаково: фиксировать порядок и упрощать вложенность - это не стилистика, а профилактика, окупающаяся в первую же ночь продакшена.
Отдельно стоит сказать об автоматическом обнаружении. Современные платформы предлагают анализ графов ожидания в базах данных - SQL-сервер сам распознаёт дедлок и выбирает жертву, а в приложениях приходится надеяться на дисциплину. Инструменты статического анализа худо-бедно находят несогласованные поряды захвата, детекторы вроде Thread Sanitizer ловят нарушения на тестовых прогонах, и стресс-тесты с искусственными задержками превращают неуловимую гонку в воспроизводимую через час работы стенда. Ничто из этого не заменяет дизайн, но сокращает время поиска на порядки.
Отладчик-ветеран скажет, что дедлок не является загадкой. Это честная механика четырёх условий Коффмана, механика графа ожидания и механика игнорирования порядка захвата. Команда, которая знает условия, проектирует иерархию замков, применяет таймауты, умеет читать дампы через !locks и не путает дедлок с livelock и голодом, перестаёт бояться зависших сервисов: она знает, где искать цикл и какой рычаг дёрнуть первым.