Программирование C++МногопоточностьC++ разработчик системного ПО

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

В ситуации, где два потока захватывают общие мьютексы в разном порядке, какое условие приводит к взаимной блокировке?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Взаимная блокировка возникает, когда потоки удерживают уже захваченные мьютексы и одновременно ждут мьютексы, которыми владеют друг друга. Основное практическое условие — циклический порядок ожидания: один поток захватил мьютекс A и ждёт B, а другой захватил B и ждёт A.

Надёжный способ устранить такой класс deadlock — установить единый глобальный порядок захвата мьютексов и соблюдать его во всех потоках. Для совместного захвата нескольких мьютексов также можно использовать std::lock или std::scoped_lock.

Исторический контекст

Мьютексы появились как базовый механизм взаимного исключения: они не позволяют нескольким потокам одновременно изменять защищаемый ресурс. Однако мьютекс управляет только доступом к своему ресурсу и не знает, какие другие мьютексы понадобятся потоку позже.

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

Постановка проблемы

Рассмотрим два ресурса, защищённых мьютексами A и B. Первый поток захватывает A, затем пытается захватить B; второй сначала захватывает B, затем пытается захватить A.

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

Это соответствует условиям Коффмана: взаимному исключению, удержанию ресурса во время ожидания, отсутствию принудительного изъятия и циклическому ожиданию. Устранение хотя бы одного из этих условий предотвращает deadlock.

Подробное решение

Главное правило — назначить мьютексам глобальный порядок, например A перед B. Каждый поток обязан захватывать мьютексы только в направлении этого порядка. Тогда цепочка ожидания не сможет замкнуться в цикл: поток, удерживающий B, не будет ждать A.

#include <mutex> std::mutex a, b; void first() { std::lock_guard<std::mutex> x(a); std::lock_guard<std::mutex> y(b); } void second() { std::lock_guard<std::mutex> x(a); std::lock_guard<std::mutex> y(b); }

В примере оба потока используют одинаковый порядок. std::lock_guard освобождает мьютекс автоматически при выходе из области видимости, в том числе при исключении, но сам по себе не предотвращает deadlock при неправильном порядке захвата.

Когда порядок трудно поддерживать вручную, несколько мьютексов можно передать в std::scoped_lock. Этот механизм использует безопасный совместный захват и освобождает все мьютексы автоматически. Альтернативой является std::lock с последующим созданием объектов RAII-обёрток.

try_lock или тайм-ауты могут не допустить бесконечного ожидания, но обычно требуют отката уже выполненных захватов. Они снижают риск постоянной блокировки, однако могут привести к повторным попыткам, голоданию или livelock. Поэтому единый порядок захвата обычно проще для проверки и надёжнее.

Ситуация из практики

В банковском сервисе перевод блокировал счёт отправителя и счёт получателя. Один путь обработки переводов захватывал счета по идентификатору отправителя, затем получателя, а другой — в обратном порядке. При встречных переводах между двумя счетами потоки периодически зависали.

Рассматривались три варианта. Тайм-ауты позволяли прерывать ожидание, но требовали корректного отката операции и создавали повторные попытки. try_lock давал больше контроля, но усложнял обработку частично захваченных ресурсов. Выбранным решением стал единый порядок: счета всегда блокировались по возрастанию идентификатора.

После этого циклическое ожидание стало невозможным, а код остался блокирующим и простым для анализа. Для операций с несколькими независимыми мьютексами дополнительно применили std::scoped_lock, чтобы не дублировать логику безопасного совместного захвата.

Что кандидаты часто упускают

  1. Достаточно ли одинакового порядка захвата двух мьютексов во всех функциях?

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

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

  1. Устраняет ли try_lock проблему deadlock полностью?

Нет. Если поток не может захватить очередной мьютекс и корректно освобождает уже захваченные, конкретная попытка не зависает навсегда. Но постоянные синхронные повторные попытки могут привести к livelock: потоки активно работают, но каждый раз мешают друг другу.

Кроме того, один поток может постоянно проигрывать конкуренцию и голодать. Для предсказуемого поведения нужны откладывание повторной попытки, случайная задержка, приоритеты или, чаще, единый порядок захвата.

  1. Поможет ли std::recursive_mutex, если поток повторно захватывает один и тот же мьютекс?

Он разрешает повторный захват одним и тем же потоком, но не решает взаимную блокировку между разными потоками. Если один поток удерживает A и ждёт B, а другой удерживает B и ждёт A, рекурсивность не меняет ситуацию.

Более того, recursive_mutex может скрыть ошибочную структуру вызовов и затруднить понимание владения ресурсом. Его следует применять только тогда, когда повторный захват одним потоком является осознанным требованием дизайна, а не как универсальное средство борьбы с deadlock.