Практическая ситуация: два потока должны одновременно блокировать два разных мьютекса. Какой стандартный механизм C++17 выбрать, чтобы не получить взаимную блокировку?
Используйте std::scoped_lock для одновременной блокировки нескольких мьютексов. Он применяет алгоритм, предотвращающий взаимную блокировку, а затем автоматически освобождает все мьютексы при выходе из области видимости.
Последовательная блокировка нескольких мьютексов часто приводит к deadlock: один поток захватывает первый мьютекс и ждёт второй, пока другой поток уже захватил второй и ждёт первый. В C++11 появился std::lock, который решает задачу безопасного совместного захвата, а в C++17 — RAII-обёртка std::scoped_lock.
Обычный последовательный захват мьютексов опасен, если разные участки программы используют различный порядок блокировки. Даже одинаковый порядок требует дисциплины, которую легко нарушить при дальнейшем развитии кода.
Незавершённое исключение также может оставить мьютекс заблокированным, если освобождение выполняется вручную. Это приводит к зависанию других потоков или к трудно диагностируемым сбоям производительности.
std::scoped_lock принимает несколько объектов, удовлетворяющих требованиям блокируемого мьютекса, и захватывает их совместно. Реализация использует механизм, эквивалентный применению std::lock, поэтому захват не должен приводить к deadlock при конкуренции потоков.
В примере оба мьютекса освобождаются автоматически при выходе из transfer, в том числе из-за исключения. Объект блокировки нельзя копировать, поэтому владение захваченными мьютексами однозначно связано с его областью видимости.
std::lock_guard подходит для одного мьютекса. Два независимо созданных lock_guard с разными мьютексами не дают защиты от deadlock. В C++11 для нескольких мьютексов можно было использовать std::unique_lock вместе с std::lock, но это многословнее; std::scoped_lock обычно является идиоматичным выбором начиная с C++17.
Передача одного и того же мьютекса несколько раз в один вызов не является корректным сценарием. Также важно удерживать мьютексы как можно меньше: защита должна охватывать только согласованно изменяемое состояние, иначе взаимная блокировка будет предотвращена ценой лишнего снижения параллелизма.
В банковском сервисе операция перевода изменяет балансы двух счетов. Вариант с ручным захватом сначала мьютекса исходного счёта, затем мьютекса целевого счёта зависел бы от порядка счетов. При встречном переводе другой поток мог бы захватить их в обратном порядке.
Можно заранее упорядочить мьютексы по идентификатору счёта. Это уменьшает риск deadlock, но требует поддерживать правило во всех местах доступа и аккуратно обрабатывать равные или особые идентификаторы.
Выбран std::scoped_lock с двумя мьютексами. Он централизует безопасный захват, автоматически освобождает ресурсы при исключениях и не заставляет бизнес-логику знать детали алгоритма предотвращения deadlock. В результате код становится короче, а нарушение порядка блокировки между потоками не приводит к взаимной блокировке.
Чем std::scoped_lock отличается от std::lock_guard при работе с несколькими мьютексами?
std::lock_guard предназначен для владения одним мьютексом и сразу вызывает его lock. Если создать несколько таких объектов подряд, захват будет последовательным и потенциально опасным. std::scoped_lock может захватывать несколько мьютексов совместно, используя deadlock-избегающий механизм.
Гарантирует ли std::scoped_lock отсутствие любого взаимного блокирования в программе?
Нет. Гарантия относится к совместному захвату переданных мьютексов. Deadlock всё ещё возможен, если часть кода блокирует ресурсы вручную, ждёт условную переменную в неподходящем порядке, удерживает мьютекс при ожидании внешнего ресурса или использует другие блокирующие объекты вне согласованной схемы.
Почему нельзя просто блокировать мьютексы по одному, но всегда в одном порядке?
Единый глобальный порядок действительно может предотвратить deadlock, если его строго соблюдают все участки программы. Однако это организационное ограничение легко нарушить при добавлении нового кода, а порядок может быть неудобен для составных операций. std::scoped_lock уменьшает количество таких правил и делает намерение совместного захвата явным, но не отменяет необходимость минимизировать область блокировки.