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;
}
И вот где ловушка: если в другом потоке идёт перевод в обратную сторону (to → from), порядок захвата будет зеркальным. Иногда они «встретятся» и зависнут.
Чтобы имитировать «схождение» потоков, добавим небольшую паузу. (В реальности вместо неё — что угодно, например логирование.)
#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
Сейчас полезно собрать в голове «набор привычек». Не как магические заклинания, а как инженерные решения под разные случаи. Таблица ниже — не про «единственно верный путь», а про то, какой инструмент лучше выражает намерение.
| Ситуация | Лучшее правило | Почему |
|---|---|---|
| Нужно брать несколько мьютексов для одной операции | |
Одно выражение, меньше шансов ошибиться и проще читать |
| Есть «естественный порядок» объектов (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 случился — добавлю ещё один мьютекс для координации».
Дополнительный мьютекс редко лечит протокол, чаще он добавляет ещё одну вершину в граф ожидания. Починка обычно начинается с того, что вы выписываете: какие мьютексы есть, какие пары берутся вместе, и в каком порядке. После этого становится видно, где порядок нарушен — и исправление часто оказывается удивительно маленьким.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ