JavaRush /Курсы /C++ SELF /Deadlock: причины и правила предотвращения

Deadlock: причины и правила предотвращения

C++ SELF
69 уровень , 3 лекция
Открыта

1. Что такое deadlock и почему кажется, что программа «зависла»

Когда вы впервые ловите deadlock, ощущения обычно такие: «Ну всё, мой код задумался о жизни». И самое обидное — он может задумываться бесконечно, не падая с ошибкой и не печатая ничего полезного. Deadlock — это как очередь в поликлинике, где два врача ушли пить чай, потому что каждый ждёт, когда другой вернёт ему карту пациента.

Deadlock (взаимная блокировка) — состояние, когда два или больше потоков навсегда ждут друг друга, потому что каждый удерживает ресурс (обычно мьютекс) и пытается получить другой ресурс, который удерживает другой поток. В отличие от «долгих вычислений», deadlock не заканчивается никогда (если только вы не завершите процесс).

Типичные симптомы на практике выглядят так: программа стабильно «висит» на одном и том же месте, нагрузка на CPU может быть низкой, вывод в консоль перестаёт идти, а join() на потоках в main никогда не возвращается. И вот тут коварство: иногда это проявляется раз в тысячу запусков, потому что зависит от планировщика ОС и таймингов.

Для дальнейшего разговора запомним мысль: deadlock — это почти всегда ошибка протокола захвата блокировок, а не «невезение».

2. Классический deadlock: два мьютекса и разные порядки захвата

Давайте начнём с минимального примера, чтобы увидеть проблему глазами. Он намеренно немного «подкручен» через sleep_for, чтобы шанс поймать зависание был выше. В реальной жизни вместо sleep_for обычно «работает» любой неожиданный тормоз: логирование, ввод/вывод, обращение к диску, выделение памяти, да хоть переключение контекста в неподходящий момент.


#include <chrono>
#include <mutex>
#include <thread>

std::mutex mA;
std::mutex mB;

void f1() {
    std::lock_guard<std::mutex> lockA(mA);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lockB(mB);
}

void f2() {
    std::lock_guard<std::mutex> lockB(mB);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lockA(mA);
}

Если запустить это из main в двух потоках, иногда получится картина: поток t1 успел захватить mA и ждёт mB, а поток t2 успел захватить mB и ждёт mA. Никто не может сделать шаг дальше. Вежливо стоим и держим двери друг другу.

#include <thread>

int main() {
    std::thread t1(f1);
    std::thread t2(f2);

    t1.join();
    t2.join(); // иногда зависнет навсегда
}

Важно понять именно причину: не «мьютексы плохие», а мы взяли их в разном порядке. В одном месте «сначала A потом B», в другом «сначала B потом A».

3. Deadlock как ошибка протокола захвата

Сейчас будет важный момент: deadlock обычно не лечится «добавим ещё один мьютекс» (это как тушить пожар бензином, просто выглядит эффектно). Он лечится тем, что вы вводите понятный протокол, как именно в проекте берутся блокировки, и делаете так, чтобы этот протокол было трудно нарушить.

Если описывать deadlock по-человечески, то он возникает, когда одновременно выполняются четыре идеи: ресурсы эксклюзивны (мьютекс держит один поток), поток удерживает один ресурс и просит второй, «отнять» мьютекс у потока нельзя, и при этом получается цикл ожидания. В наших примерах цикл элементарный: t1 ждёт mB, t2 ждёт mA. Кольцо замкнулось.

Почему я это проговариваю: это объясняет, почему самое эффективное правило предотвращения — не «молиться», а сломать возможность цикла ожидания. Практически это делается либо единым порядком захвата (lock ordering), либо захватом нескольких мьютексов одной операцией.

Кстати, даже в документах стандартной библиотеки есть отдельные примечания и правки, связанные с темой deadlock avoidance — то есть проблема настолько типовая, что её подчёркивают на уровне спецификаций.

4. Правило №1: единый порядок захвата блокировок

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

Порядок может быть любым, главное — единым и документированным. Например:

  • «Сначала мьютекс базы данных, потом мьютекс логгера».
  • «Сначала меньший id, потом больший id».
  • «Сначала Bank::m_, потом Logger::m_».

Схема deadlock — то, чего мы не хотим:

flowchart LR
    T1["Thread 1 держит mA"] -->|ждёт| mB["mB"]
    T2["Thread 2 держит mB"] -->|ждёт| mA["mA"]

Схема «единый порядок» — то, что хотим:

flowchart TD
    R["Правило: всегда захватываем mA → потом mB"] --> OK["Цикл ожидания не образуется"]

Покажу на том же минимальном примере: если и f1, и f2 берут сначала mA, потом mB, то deadlock из-за порядка исчезает.

void f2_fixed() {
    std::lock_guard<std::mutex> lockA(mA);
    std::lock_guard<std::mutex> lockB(mB);
}

Обратите внимание: мы не сделали код «быстрее». Мы сделали его невозможным сломать именно этим способом.

5. Правило №2: захват нескольких мьютексов одной операцией

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

Идея std::scoped_lock: «я хочу взять несколько мьютексов сразу и держать их до конца scope». Вы пишете одно выражение, и тем самым делаете намерение очевидным: «мне нужны оба ресурса одновременно».

#include <mutex>

extern std::mutex mA;
extern std::mutex mB;

void safe() {
    std::scoped_lock lock(mA, mB);
    // работа с двумя ресурсами
}

Почему это лучше, чем два lock_guard подряд? Потому что «два lock_guard» выглядят невинно, но их легко переставить местами в другом участке кода. А scoped_lock(mA, mB) визуально фиксирует «эту пару» и снижает шанс, что кто-то (включая вас через неделю) сделает mB потом mA.

Тут важная инженерная мысль: мы не просто пишем «правильный код», мы пишем код, который сложнее испортить случайной правкой.

6. Пример: мини-банк и перевод между счетами

Сейчас соберём маленький учебный сюжет. Представим, что мы пишем консольное приложение “MiniBank”: есть счета, баланс, переводы. Всё простое, но мы хотим делать переводы из разных потоков (например, имитируем операции клиентов).

Начнём с модели счёта:

#include <mutex>

struct Account {
    int id = 0;
    int balance = 0;
    mutable std::mutex m;
};

Наивный перевод выглядит логично: заблокировали from, потом заблокировали to, сделали операцию.

void transfer_bad(Account& from, Account& to, int amount) {
    std::lock_guard<std::mutex> lockFrom(from.m);
    std::lock_guard<std::mutex> lockTo(to.m);

    from.balance -= amount;
    to.balance += amount;
}

И вот где ловушка: если в другом потоке идёт перевод в обратную сторону (tofrom), порядок захвата будет зеркальным. Иногда они «встретятся» и зависнут.

Чтобы имитировать «схождение» потоков, добавим небольшую паузу. (В реальности вместо неё — что угодно, например логирование.)

#include <chrono>
#include <thread>

void transfer_bad(Account& from, Account& to, int amount) {
    std::lock_guard<std::mutex> lockFrom(from.m);
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    std::lock_guard<std::mutex> lockTo(to.m);

    from.balance -= amount;
    to.balance += amount;
}

Использование:

#include <thread>

int main() {
    Account a{.id = 1, .balance = 1000};
    Account b{.id = 2, .balance = 1000};

    std::thread t1([&]{ transfer_bad(a, b, 10); });
    std::thread t2([&]{ transfer_bad(b, a, 20); });

    t1.join();
    t2.join(); // иногда зависнет
}

Исправление: std::scoped_lock на оба мьютекса

Самый прямой ремонт — взять оба мьютекса одной конструкцией:

void transfer_scoped(Account& from, Account& to, int amount) {
    std::scoped_lock lock(from.m, to.m);

    from.balance -= amount;
    to.balance += amount;
}

Исправление: порядок по id

Иногда вы хотите зафиксировать правило явно: «меньший id всегда лочим первым». Тогда код будет одинаковым независимо от направления перевода.

Ниже — пример плохой идеи (логически плохой, но синтаксически корректной): сравнивать адреса полей, а не сами id. Это легко написать «случайно», и оно будет компилироваться, но такая логика не отражает нужный порядок и может вести себя неожиданно.

void transfer_ordered_bad(Account& x, Account& y, int amount) {
    Account* first  = (&x.id < &y.id) ? &x : &y; // так делать не надо
    Account* second = (first == &x) ? &y : &x;

    std::scoped_lock lock(first->m, second->m);

    x.balance -= amount;
    y.balance += amount;
}

Правильнее сравнивать именно id, а не адреса «чего-то внутри». Сделаем корректно и коротко:

void transfer_ordered(Account& from, Account& to, int amount) {
    Account& first  = (from.id < to.id) ? from : to;
    Account& second = (from.id < to.id) ? to : from;

    std::scoped_lock lock(first.m, second.m);

    from.balance -= amount;
    to.balance += amount;
}

Обратите внимание на приятный эффект: мы не обязаны использовать scoped_lock, можно было бы сделать два lock_guard подряд. Но scoped_lock здесь добавляет ещё один слой страховки: даже если два мьютекса окажутся «в другом порядке» в будущем, намерение «взять оба» всё равно выражено одним объектом.

7. Deadlock из-за побочных действий под lock

Очень многие deadlock’и рождаются не там, где вы честно писали «захвачу два мьютекса». Они рождаются там, где вы думаете: «Сейчас быстренько залогирую». И вот у вас уже появился второй мьютекс — мьютекс логгера — и вы незаметно начали собирать цепочку блокировок.

Сделаем простейший потокобезопасный логгер:

#include <iostream>
#include <mutex>
#include <string>

class Logger {
public:
    void log(const std::string& s) {
        std::lock_guard<std::mutex> lock(m_);
        std::cout << s << '\n';
    }
private:
    std::mutex m_;
};

Представим, что в Bank есть свой мьютекс на данные, и мы внутри «банковской» операции зовём логгер.

#include <mutex>

class Bank {
public:
    Bank(Logger& logger) : logger_(logger) {}

    void deposit(Account& a, int amount) {
        std::lock_guard<std::mutex> lock(m_);
        a.balance += amount;
        logger_.log("deposit"); // логирование под lock
    }

private:
    std::mutex m_;
    Logger& logger_;
};

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

void print_report(Bank& bank, Logger& logger) {
    logger.log("report");          // логгер залочен внутри log()
    // ... а потом ещё что-то, что приводит к захвату bank.m_ (например, bank.total())
}

И если внутри «печати отчёта» вы сделаете вызов метода банка, который лочит Bank::m_, вы получите потенциальный цикл: один поток держит Bank::m_ и ждёт Logger::m_, другой держит Logger::m_ и ждёт Bank::m_.

Как это лечится по-взрослому? Варианта два, и оба про дисциплину.

Первый — единый порядок: например, «всегда сначала Bank::m_, потом Logger::m_». Тогда print_report должен быть устроен так, чтобы не логировать до захвата банковского состояния (или логировать без мьютекса — но это отдельный разговор).

Второй — уменьшение времени под lock и “снимки состояния”: под банковским мьютексом вы формируете строку или данные для отчёта, отпускаете мьютекс, и только потом печатаете/логируете.

Вот пример «снимка» (идея важнее содержания):

#include <string>

std::string make_report_line(const Account& a) {
    return "id=" + std::to_string(a.id) + " balance=" + std::to_string(a.balance);
}

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

void print_account(Logger& logger, const Account& a) {
    std::string line;
    {
        std::lock_guard<std::mutex> lock(a.m);
        line = make_report_line(a); // читаем под lock
    }
    logger.log(line); // печатаем уже без lock на аккаунте
}

Смысл: мы не держим сразу два мьютекса «на всякий случай». Чем меньше пересечений блокировок, тем меньше шансов построить цикл ожидания.

8. Практические правила предотвращения deadlock

Сейчас полезно собрать в голове «набор привычек». Не как магические заклинания, а как инженерные решения под разные случаи. Таблица ниже — не про «единственно верный путь», а про то, какой инструмент лучше выражает намерение.

Ситуация Лучшее правило Почему
Нужно брать несколько мьютексов для одной операции
std::scoped_lock(m1, m2, ...)
Одно выражение, меньше шансов ошибиться и проще читать
Есть «естественный порядок» объектов (id, индекс, имя) lock ordering по этому ключу Легко объяснить и проверить: «меньший id первым»
Под lock есть тяжёлая работа (I/O, вычисления, длинные циклы) «снимок под lock → работа без lock» Уменьшает время удержания, снижает пересечения блокировок
Под lock вызываются «чужие» функции (логгер, коллбеки, пользовательский код) не звать их под lock или чётко фиксировать порядок «Чужая функция» может взять другой мьютекс и сломать протокол

9. Как отличить deadlock от «просто медленно»

Когда программа «зависла», новичок часто начинает подозревать всё: от «сломался cout» до «компьютер устал». Важно научиться отличать deadlock от обычной задержки.

Deadlock обычно проявляется так: процесс жив, но ничего не происходит; если у вас есть отладочная печать «в начале функции» и «в конце функции», вы видите, что начало печатается, а конец — никогда. Если вы уже знакомы с отладчиком, то типичная картина — один поток стоит на попытке захватить мьютекс, второй поток стоит на попытке захватить другой мьютекс. То есть каждый ждёт входа в критическую секцию, но вход никогда не наступит.

Очень полезная бытовая привычка: если вы пишете код с несколькими мьютексами, считайте, что deadlock возможен «по умолчанию», пока вы не доказали обратное протоколом. Это не паранойя — это просто уважение к реальности.

10. Типичные ошибки при защите от deadlock

Ошибка №1: «Я возьму два мьютекса, но порядок не важен».
На практике порядок важен всегда, как только в проекте появляется больше одного места, где эти мьютексы могут пересечься. Сегодня вы взяли A B, завтра коллега (или вы же через неделю) возьмёт B A, и получите зависание, которое невозможно «угадывать» по логике, потому что оно зависит от таймингов.

Ошибка №2: «Поставлю мьютексы на всё подряд, чтобы точно было безопасно».
Если вы начинаете удерживать по два-три мьютекса на половину функции «для спокойствия», то вы сами увеличиваете число пересечений и шанс циклов ожидания. В многопоточности спокойствие чаще приходит не от количества блокировок, а от коротких критических секций и понятного протокола.

Ошибка №3: «Под lock можно вызвать любую функцию, это же просто вызов».
Вызов «чужой» функции под блокировкой — один из самых опасных источников deadlock. «Чужая» означает не только библиотеку, но и ваш же логгер, ваш же print_report, ваш же «маленький helper». Внутри она может взять другой мьютекс — и вы уже построили цикл, даже не заметив.

Ошибка №4: «Логирование под мьютексом — это безопасно, ведь оно полезное».
Логирование полезное, но оно почти всегда использует общий ресурс (консоль, файл, буфер) и поэтому тоже синхронизируется. Если логгер имеет свой мьютекс, вы автоматически получаете второй ресурс, который может пересекаться с другими. Хорошая привычка — формировать сообщение под lock, а писать его уже без lock (или строго фиксировать порядок «сначала данные, потом лог»).

Ошибка №5: «Deadlock случился — добавлю ещё один мьютекс для координации».
Дополнительный мьютекс редко лечит протокол, чаще он добавляет ещё одну вершину в граф ожидания. Починка обычно начинается с того, что вы выписываете: какие мьютексы есть, какие пары берутся вместе, и в каком порядке. После этого становится видно, где порядок нарушен — и исправление часто оказывается удивительно маленьким.

1
Задача
C++ SELF, 69 уровень, 3 лекция
Недоступна
Перевод без дедлока
Перевод без дедлока
1
Задача
C++ SELF, 69 уровень, 3 лекция
Недоступна
Логирование без вложений
Логирование без вложений
1
Задача
C++ SELF, 69 уровень, 3 лекция
Недоступна
Две структуры вместе
Две структуры вместе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ