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

За счёт чего std::scoped lock предотвращает взаимную блокировку при захвате нескольких мьютексов?

За счёт чего std::scoped_lock предотвращает взаимную блокировку при захвате нескольких мьютексов?

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

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

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

std::scoped_lock также автоматически освобождает все захваченные мьютексы при выходе из области видимости.

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

Ручной последовательный захват нескольких мьютексов часто приводит к взаимной блокировке: один поток удерживает первый мьютекс и ждёт второй, а другой удерживает второй и ждёт первый. Для решения этой проблемы в стандартной библиотеке появился std::lock, а в C++17 — RAII-обёртка std::scoped_lock, объединяющая безопасный захват и автоматическое освобождение.

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

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

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

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

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

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

#include <mutex> struct Account { std::mutex mutex; int balance = 0; }; void transfer(Account& from, Account& to, int amount) { std::scoped_lock lock(from.mutex, to.mutex); from.balance -= amount; to.balance += amount; }

Порядок аргументов в двух вызовах transfer может отличаться: механизм множественного захвата всё равно координирует получение мьютексов. После успешного захвата создаётся RAII-объект, а его уничтожение освобождает все мьютексы.

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

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

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

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

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

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

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

  1. Гарантирует ли std::scoped_lock отсутствие любой взаимной блокировки в программе?

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

  1. Чем std::scoped_lock отличается от нескольких std::lock_guard?

Несколько std::lock_guard, созданных последовательно, захватывают мьютексы последовательно и могут образовать цикл ожидания. std::scoped_lock при нескольких аргументах использует алгоритм множественного захвата, а затем управляет временем жизни всех блокировок как единым RAII-объектом.

  1. Можно ли использовать std::scoped_lock для одного мьютекса?

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