Многопоточность в C++. ​RAII механизмы для блокировки мьютекса

Добавлено 22 декабря 2021 в 00:54

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();
}

Теги

C++ / CppMutex / Мьютексstd::lock_guardstd::mutexstd::unique_lockSTL / Standard Template Library / Стандартная библиотека шаблоновМногопоточность

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.