Общая рекомендация, как избежать взаимоблокировок, заключается в том, чтобы всегда захватывать мьютексы в одном и том же порядке, — если мьютекс А всегда захватывается раньше мьютекса В, то взаимоблокировка не возникнет. Иногда это просто, потому что мьютексы служат разным целям, а иногда совсем не просто, например, если каждый мьютекс защищает отдельный объект одного и того же класса. Рассмотрим, к примеру, операцию сравнения двух объектов одного класса. Чтобы сравнению не мешала одновременная модификация, необходимо захватить мьютексы для обоих объектов. Однако, если выбрать какой-то определенный порядок (например, сначала захватывается мьютекс для объекта, переданного в первом параметре, а потом — для объекта, переданного во втором параметре), то легко можно получить результат, обратный желаемому: стоит двум потокам вызвать функцию сравнения, передав ей одни и те же объекты в разном порядке, как мы получим взаимоблокировку!
К счастью, в стандартной библиотеке есть на этот случай лекарство в виде функции std::lock
, которая умеет захватывать сразу два и более мьютексов без риска получить взаимоблокировку. В листинге 3.6 показано, как воспользоваться ей для реализации простой операции обмена.
Листинг 3.6. Применение std::lock
и std::lock_guard
для реализации операции обмена
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd) : some_detail(sd) {}
friend void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
std::lock(lhs.m, rhs.m); ←
(1)
std::lock_guard
(2)
std::lock_guard
(3)
swap(lhs.some_detail,rhs.some_detail);
}
};
Сначала проверяется, что в аргументах переданы разные экземпляры, постольку попытка захватить std::mutex
, когда он уже захвачен, приводит к неопределенному поведению. (Класс мьютекса, допускающего несколько захватов в одном потоке, называется std::recursive_mutex
. Подробности см. в разделе 3.3.3.) Затем мы вызываем std::lock()
(1), чтобы захватить оба мьютекса, и конструируем два экземпляра std::lock_guard
(2), (3) — по одному для каждого мьютекса. Помимо самого мьютекса, конструктору передается параметр std::adopt_lock
, сообщающий объектам std::lock_guard
, что мьютексы уже захвачены, и им нужно лишь принять владение существующей блокировкой, а не пытаться еще раз захватить мьютекс в конструкторе.
Это гарантирует корректное освобождение мьютексов при выходе из функции даже в случае, когда защищаемая операция возбуждает исключение, а также возврат результата сравнения в случае нормального завершения. Стоит также отметить, что попытка захвата любого мьютекса lhs.m
или rhs.m
внутри std::lock
может привести к исключению; в этом случае исключение распространяется на уровень функции, вызвавшей std::lock
. Если std::lock
успешно захватила первый мьютекс, но при попытке захватить второй возникло исключение, то первый мьютекс автоматически освобождается; std::lock
обеспечивает семантику «все или ничего» в части захвата переданных мьютексов.
Хотя std::lock
помогает избежать взаимоблокировки в случаях, когда нужно захватить сразу два или более мьютексов, она не в силах помочь, если мьютексы захватываются порознь, — в таком случае остается полагаться только на дисциплину программирования. Это нелегко, взаимоблокировки — одна из самых неприятных проблем в многопоточной программе, часто они возникают непредсказуемо, хотя в большинстве случаев все работает нормально. Однако все же есть несколько относительно простых правил, помогающих писать свободный от взаимоблокировок код.
3.2.5. Дополнительные рекомендации, как избежать взаимоблокировок