Перейти к содержимому
Теория · Многопоточность: обзор · урок 6 из 12

Взаимная блокировка и scoped_lock

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

С мьютексами бывает то же самое. Два потока захватывают два мьютекса в разном порядке:

C++
std::mutex a;
std::mutex b;

void left() {
    std::lock_guard<std::mutex> ga(a);
    std::lock_guard<std::mutex> gb(b);
}

void right() {
    std::lock_guard<std::mutex> gb(b);
    std::lock_guard<std::mutex> ga(a);  // ✗
}

Взаимная блокировка

Давайте разберём, что бывает, если один поток выполняет left, а другой одновременно right:

  1. Первый поток захватывает a.
  2. Второй поток захватывает b.
  3. Первый поток ждёт b, которую держит второй.
  4. Второй поток ждёт a, которую держит первый.

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

Один порядок для всех

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

Здесь аналогия заканчивается: повара могут увидеть друг друга и договориться, а потоки — нет. Порядок захвата задаёт только тот, кто пишет программу, и новая функция может его нарушить.

scoped_lock

std::scoped_lock из заголовка <mutex> захватывает несколько мьютексов сразу и без взаимной блокировки, в каком бы порядке их ни передали:

C++
#include <mutex>

std::mutex a;
std::mutex b;

void right() {
    std::scoped_lock lock(b, a);
}

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

Частая ошибка

Функция под мьютексом вызывает другую функцию, которая захватывает тот же мьютекс:

C++
#include <mutex>

std::mutex a;
int total = 0;

void add(int x) {
    std::lock_guard<std::mutex> guard(a);
    total += x;
}

void add_twice(int x) {
    std::lock_guard<std::mutex> guard(a);
    add(x);  // ✗
}

Программа собирается без предупреждений. Но add пытается захватить a, который этот же поток уже держит. Для std::mutex это неопределённое поведение: например, поток может навсегда остаться ждать сам себя. Захватывайте мьютекс в одном месте: здесь add_twice может обойтись без своего guard и вызвать add дважды.

Главное

  • Взаимная блокировка — два потока держат по мьютексу и ждут мьютекс друг друга; программа зависает навсегда.
  • Она возникает, когда потоки захватывают мьютексы в разном порядке, и случается не при каждом запуске.
  • Захватывайте мьютексы всегда в одном порядке или все сразу через std::scoped_lock.
  • Повторный захват std::mutex в потоке, который его уже держит, — неопределённое поведение.

Подпишись, чтобы пройти курс

Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.

Войти другим способом