приостановке потоков, может запуститься успешно и не столкнуться с взаимной блокировкой, если запускать ее раз за разом.) Обратите внимание: мы не используем символ 'n' для вывода на экран перевода строки, а применяем для данных целей endl. Он не только выполняет перевод строки, но еще и опустошает буфер потока cout, поэтому можно убедиться, что операции вывода не объединяются и не откладываются:
static void deadlock_func_1()
{
cout << "bad f1 acquiring mutex A..." << endl;
lock_guard<mutex> la {mut_a};
this_thread::sleep_for(100ms);
cout << "bad f1 acquiring mutex B..." << endl;
lock_guard<mutex> lb {mut_b};
cout << "bad f1 got both mutexes." << endl;
}
4. Как мы и говорили на предыдущем шаге, функция deadlock_func_2 выглядит точно так же, как и deadlock_func_1, но блокирует мьютексы A и B в противоположном порядке:
static void deadlock_func_2()
{
cout << "bad f2 acquiring mutex B..." << endl;
lock_guard<mutex> lb {mut_b};
this_thread::sleep_for(100ms);
cout << "bad f2 acquiring mutex A..." << endl;
lock_guard<mutex> la {mut_a};
cout << "bad f2 got both mutexes." << endl;
}
5. Теперь напишем свободный от взаимных блокировок вариант этих функций. Они используют класс scoped_lock, блокирующий все мьютексы, которые мы предоставляем в качестве аргументов конструктора. Его деструктор снова их разблокирует. При блокировании мьютексов он изнутри применяет стратегию избегания взаимных блокировок. Обратите внимание: обе функции все еще используют мьютексы А и В в разном порядке:
static void sane_func_1()
{
scoped_lock l {mut_a, mut_b};
cout << "sane f1 got both mutexes." << endl;
}
static void sane_func_2()
{
scoped_lock l {mut_b, mut_a};
cout << "sane f2 got both mutexes." << endl;
}
6. В функции main пройдем по двум сценариям. Сначала воспользуемся внятными функциями в многопоточном контексте:
int main()
{
{
thread t1 {sane_func_1};
thread t2 {sane_func_2};
t1.join();
t2.join();
}
7. Затем воспользуемся функциями, создающими взаимные блокировки, которые не следуют стратегиям избегания взаимных блокировок:
{
thread t1 {deadlock_func_1};
thread t2 {deadlock_func_2};
t1.join();
t2.join();
}
}
8. Компиляция и запуск программы дадут следующий результат. В первых двух строках показывается, что внятный сценарий блокировки работает и обе функции возвращают свое значение и не блокируются навсегда. Две другие функции создают взаимную блокировку. Мы можем сказать, что это точно взаимная блокировка, поскольку видим, как они выводят на экран строки, которые указывают отдельным потокам блокировать мьютексы А и В, а затем вечно ожидают. Обе функции не достигают момента, когда успешно блокируют оба мьютекса. Можно оставить программу включенной на часы, дни и годы, и ничего не произойдет.
Это приложение нужно завершить снаружи, например нажав Ctrl+C:
$ ./avoid_deadlock
sane f1 got both mutexes
sane f2 got both mutexes
bad f2 acquiring mutex B...
bad f1 acquiring mutex A...
bad f1 acquiring mutex B...
bad f2 acquiring mutex A...
Как это работает
Реализуя код, намеренно вызывающий взаимную блокировку, мы увидели, как быстро может возникнуть этот нежелательный сценарий. В крупном проекте, где несколько программистов пишут код, который должен разделять один набор ресурсов, защищенных мьютексами, всем программистам необходимо следовать одному порядку при блокировании и разблокировании мьютексов. Несмотря на то, что таким стратегиям или правилам следовать очень просто, о них легко и забыть. Еще одним термином для этой проблемы является инверсия порядка блокировки.
В подобных ситуациях поможет scoped_lock. Этот класс появился в C++17 и работает точно так же, как и классы lock_guard и unique_lock: его конструктор выполняет блокирование, а его деструктор разблокирует мьютекс. Класс может работать с несколькими мьютексами сразу.
Класс scoped_lock использует функцию std::lock, которая применяет особый алгоритм, выполняющий набор вызовов try_lock для всех предоставленных мьютексов, что позволяет предотвратить взаимные блокировки. Поэтому совершенно безопасно задействовать scoped_lock или вызывать std::lock для одного набора блокировок, но в разном порядке.
Синхронизация конкурентного использования std::cout
Многопоточные программы неудобны тем, что нужно охранять каждую структуру данных, которую они изменяют, с помощью мьютексов или других средств защиты от неуправляемых конкурентных изменений.
Одной из структур данных, часто применяемых для вывода данных, является std::cout. Если несколько потоков пытаются получить доступ к cout на конкурентной основе, то мы получим смешанные выходные данные. Чтобы это предотвратить, следует написать собственную функцию, которая выводит данные на экран и защищена от конкурентности.
Мы узнаем, как предоставить оболочку для cout, которая состоит из минимального объема кода и так же удобна в использовании, как и cout.
Как это делается
В этом примере мы реализуем программу, выводящую на экран данные на конкурентной основе из нескольких потоков. Чтобы предотвратить искажение сообщений из-за конкурентности, реализуем небольшой вспомогательный класс, который синхронизирует вывод данных между потоками.
1. Как и обычно, сначала укажем все директивы include и объявим об использовании пространства имен std:
#include <iostream>
#include <thread>
#include <mutex>
#include <sstream>
#include <vector>
using namespace std;
2. Далее реализуем вспомогательный класс, который назовем pcout. Буква p означает «паралелльный», поскольку он синхронизирован для параллельных контекстов. Идея заключается в том, что pcout явно наследует от stringstream. Таким образом, можно применять operator<< для экземпляров этого класса. Как только экземпляр pcout уничтожается, его деструктор блокирует мьютекс, а затем выводит на экран содержимое буфера stringstream. На следующем шаге мы увидим, как это использовать.
struct pcout : public stringstream {
static inline mutex cout_mutex;
~pcout() {
lock_guard<mutex> l {cout_mutex};
cout << rdbuf();
cout.flush();
}
};
3. Теперь напишем две функции, которые можно выполнить с помощью дополнительных потоков. Обе функции принимают в качестве аргументов идентификаторы потоков. Затем они отличаются только тем, что одна из них использует для вывода данных непосредственно cout.