Многопоточность в C++. RAII механизмы для блокировки мьютекса
std::lock_guard
Не рекомендуется использовать класс std::mutex
напрямую, так как нужно помнить о вызове unlock
на всех путях выполнения функции, в том числе на тех, которые завершаются броском исключения. То есть если между вызовами lock
и unlock
будет сгенерировано исключение, а вы этого не предусмотрите, то мьютекс не освободится, а заблокированные потоки так и останутся ждать. Проблема безопасности блокировок мьютексов в C++ threading library решена довольно обычным для C++ способом – применением техники RAII (Resource Acquisition Is Initialization). Оберткой служит шаблонный класс std::lock_guard
. Это простой класс, конструктор которого вызывает метод lock
для заданного объекта, а деструктор вызывает unlock
. Также в конструктор класса std::lock_guard
можно передать аргумент std::adopt_lock
– индикатор, означающий, что mutex
уже заблокирован, и блокировать его заново не надо. std::lock_guard
не содержит никаких других методов, и его нельзя копировать, переносить или присваивать.
Пример:
#include <thread>
#include <mutex>
#include <iostream>
int g_i = 0;
std::mutex g_i_mutex; // защищает g_i
void safe_increment()
{
const std::lock_guard<std::mutex> lock(g_i_mutex);
++g_i;
std::cout << "g_i: " << g_i << "; in thread #"
<< std::this_thread::get_id() << '\n';
// g_i_mutex автоматически освобождается, когда lock
// выходит из области видимости
}
int main()
{
std::cout << "g_i: " << g_i << "; in main()\n";
std::thread t1(safe_increment);
std::thread t2(safe_increment);
t1.join();
t2.join();
std::cout << "g_i: " << g_i << "; in main()\n";
}
/*
Возможный вывод:
g_i: 0; in main()
g_i: 1; in thread #140487981209344
g_i: 2; in thread #140487972816640
g_i: 2; in main()
*/
После появления std::scoped_lock
в std::lock_guard
пропала необходимость, он остаётся в языке лишь для обратной совместимости.
std::unique_lock
Класс unique_lock
– это универсальная оболочка владения мьютексом, предоставляющая отсроченную блокировку, ограниченные по времени попытки блокировки, рекурсивную блокировку, передачу владения блокировкой и использование с condition variables
.
Ограниченные по времени попытки блокировки работают так же, как и в классе std::timed_mutex
. Для этого связанный мьютекс должен быть TimedLockable.
Отсроченная блокировка
Класс std::unique_lock
обеспечивает немного более гибкий подход, по сравнению с std::lock_guard
: экземпляр std::unique_lock
не всегда владеет связанным с ним мьютексом. Конструктору в качестве второго аргумента можно передавать не только объект std::adopt_lock
, заставляющий объект блокировки управлять блокировкой мьютекса, но и объект отсрочки блокировки std::defer_lock
, показывающий, что мьютекс при конструировании должен оставаться разблокированным. Блокировку можно установить позже, вызвав функцию lock()
для объекта std::unique_lock
(но не мьютекса) или же передав объект std::unique_lock
функции std::lock()
.
std::unique_lock
занимает немного больше памяти и работает несколько медленнее по сравнению с std::lock_guard
. За гибкость, заключающуюся в разрешении экземпляру std::unique_lock
не владеть мьютексом, приходится расплачиваться тем, что информация о состоянии должна храниться, обновляться и проверяться: если экземпляр действительно владеет мьютексом, деструктор должен вызвать функцию unlock()
, в ином случае – не должен. Этот флаг можно запросить, вызвав метод owns_lock()
. Если передача владения блокировкой или какие-то другие действия, требующие std::unique_lock
, не предусматриваются, лучше воспользоваться классом std::scoped_lock
из C++17.
Пример:
#include <mutex>
#include <thread>
#include <chrono>
struct Box {
explicit Box(int num) : num_things{num} {}
int num_things;
std::mutex m;
};
void transfer(Box &from, Box &to, int num)
{
// на самом деле пока не блокирует
std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
// блокирует оба объекта unique_lock без взаимной блокировки
std::lock(lock1, lock2);
from.num_things -= num;
to.num_things += num;
// мьютексы 'from.m' и 'to.m' разблокируются в деструкторах 'unique_lock'
}
int main()
{
Box acc1(100);
Box acc2(50);
std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);
std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 5);
t1.join();
t2.join();
}
Рекурсивная блокировка
std::unique_lock
можно использовать с мьютексами, поддерживающими рекурсивную блокировку. Это не значит, что для одного и того же unique_lock
можно несколько раз вызвать метод lock()
. Это значит, что в одном потоке несколько разных экземпляров std::unique_lock
могут вызвать метод lock()
для одного и того же мьютекса. Повторный же вызов метода lock()
для одного и того же экземпляра std::unique_lock
приводит к исключению. Подробнее про работу рекурсивных мьютексов будет написано далее.
Передача владения блокировкой
Объекты std::unique_lock
являются перемещаемыми. Владение мьютексом может передаваться между экземплярами std::unique_lock
путем перемещения. В некоторых случаях, например, при возвращении экземпляра из функции, оно происходит автоматически, а в других случаях его необходимо выполнять явным образом вызовом функции std::move()
. По сути, всё зависит от того, является ли источник l-значением (реальной переменной или ссылкой на таковую) или r-значением (неким временным объектом). Владение передается автоматически, если источник является r-значением, или же должно передаваться явным образом, если он является l-значением, во избежание случайной передачи владения за пределы переменной. Класс std::unique_lock
– это пример перемещаемого, но не копируемого типа.
Один из вариантов возможного использования заключается в разрешении функции заблокировать мьютекс, а затем передать владение этой блокировкой вызывающему коду, который впоследствии сможет выполнить дополнительные действия под защитой этой же самой блокировки. Соответствующий пример показан в следующем фрагменте кода, где функция get_lock()
блокирует мьютекс, а затем подготавливает данные перед тем, как вернуть блокировку вызывающему коду:
std::unique_lock<std::mutex> get_lock() {
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk;
}
void process_data() {
std::unique_lock<std::mutex> lk(get_lock());
do_something();
}
Поскольку lk
– локальная переменная, объявленная внутри функции, ее можно возвратить напрямую, без вызова функции std:move()
. О вызове конструктора перемещения позаботится компилятор. Затем функция process_data()
сможет передать владение непосредственно в собственный экземпляр std::unique_lock
, а вызов функции do_something()
может полагаться на правильную подготовку данных. Обычно такой шаблон следует применять, когда блокируемый мьютекс зависит от текущего состояния программы или от аргумента, переданного в функцию, возвращающую объект std::unique_lock
.
Использование с condition variables
Подробнее про использование условных переменных будет написано ниже. А пока кратко...
Есть две реализации условных переменных, доступных в заголовке <condition_variable>
:
condition_variable
: требует от любого потока перед ожиданием сначала выполнить блокировкуstd::unique_lock
;condition_variable_any
: более общая реализация, которая работает с любым типом, который можно заблокировать. Эта реализация может быть более дорогой (с точки зрения ресурсов и производительности), поэтому ее следует использовать только если необходимы те дополнительные возможности, которые она предоставляет
Как использовать условные переменные:
- Должен быть хотя бы один поток, ожидающий, пока какое-то условие станет истинным. Ожидающий поток должен сначала выполнить блокировку
unique_lock
. Эта блокировка передается методуwait()
, который освобождает мьютекс и приостанавливает поток, пока не будет получен сигнал от условной переменной. Когда это произойдет, поток пробудится и мьютекс снова заблокируется. - Должен быть хотя бы один поток, сигнализирующий о том, что условие стало истинным. Сигнал может быть послан с помощью
notify_one()
, при этом будет разблокирован один (любой) поток из ожидающих, илиnotify_all()
, что разблокирует все ожидающие потоки. - В виду некоторых сложностей при создании пробуждающего условия, могут происходить ложные пробуждения (spurious wakeup). Это означает, что поток может быть пробужден, даже если никто не сигнализировал условной переменной. Поэтому необходимо проверять, верно ли условие пробуждения уже после того, как поток был пробужден.
Пример:
#include <iostream>
#include <vector>
#include <thread>
std::vector<int> data;
std::condition_variable data_cond;
std::mutex m;
void thread_func1()
{
std::unique_lock<std::mutex> lock(m);
data.push_back(10);
data_cond.notify_one();
}
void thread_func2()
{
std::unique_lock<std::mutex> lock(m);
data_cond.wait(lock, [] {
return !data.empty();
});
std::cout << data.back() << std::endl;
}
int main()
{
std::thread th1(thread_func1);
std::thread th2(thread_func2);
th1.join();
th2.join();
}