Многопоточность в C++. Мьютексы чтения-записи для защиты часто читаемых и редко обновляемых структур данных
Если мы производим только чтение данных, то гонки данных не возникает. Однако, если мы хотим изменять данные, то мы вынуждены защищать их от одновременного доступа. Но что делать, если большую часть времени структура данных используется только для чтения, а в защите мы нуждаемся только при редких обновлениях этой структуры. Блокировать потоки при каждом чтении без необходимости не хотелось бы, потому что от этого пострадает производительность. Поэтому применение std::mutex
для защиты такой структуры данных имеет мрачные перспективы, поскольку при этом исключается возможность реализовать конкурентность при чтении структуры данных в тот период, когда она не подвергается модификации, так что нужен другой вид мьютекса. Этот другой тип мьютекса обычно называют мьютексом чтения-записи, поскольку он допускает два различных типа использования: монопольный доступ для одного потока записи или общий одновременный доступ для нескольких потоков чтения. Стандартная библиотека C++17 предоставляет два полностью готовых мьютекса такого вида, std::shared_mutex
и std::shared_timed_mutex
.
Для операций записи можно использовать std::lock_guard<std::shared_mutex>
и std::unique_lock<std::shared_mutex>
. Они обеспечивают монопольный доступ, как и при использовании std::mutex
. В потоках, которым не нужно обновлять структуру данных, для получения совместного доступа вместо этого можно воспользоваться std::shared_lock<std::shared_mutex>
. Этот шаблон класса RAII был добавлен в C++14 и применяется так же, как и std::unique_lock
, за исключением того, что несколько потоков могут одновременно получить общую блокировку на один и тот же мьютекс std::shared_mutex
. Ограничение заключается в том, что, если какой-либо имеющий shared блокировку поток попытается получить монопольную блокировку, он будет ждать до тех пор, пока все другие потоки не снимут свои блокировки. Аналогично, если какой-либо поток имеет монопольную блокировку, никакой другой поток не может получить shared или монопольную блокировку, пока не снимет свою блокировку первый поток.
std::shared_mutex
Класс shared_mutex
– это примитив синхронизации, который может использоваться для защиты общих данных от одновременного доступа нескольких потоков. В отличие от других типов мьютексов, которые обеспечивают эксклюзивный доступ, shared_mutex
имеет два уровня доступа:
- общий доступ – несколько потоков могут совместно владеть одним и тем же мьютексом;
- эксклюзивный доступ (исключительная блокировка) – только один поток может владеть мьютексом.
Если один поток получил эксклюзивный доступ (через lock
, try_lock
), то никакие другие потоки не могут получить блокировку (включая общую).
Если один поток получил общую блокировку (через lock_shared
, try_lock_shared
), ни один другой поток не может получить эксклюзивную блокировку, но может получить общую блокировку.
Только если исключительная блокировка не была получена ни одним потоком, общая блокировка может быть получена несколькими потоками.
В пределах одного потока одновременно может быть получена только одна блокировка (общая или эксклюзивная).
shared_mutex
особенно полезны, когда общие данные могут быть безопасно считаны любым количеством потоков одновременно, но поток может перезаписывать данные только тогда, когда ни один другой поток не читает и не записывает в это время.
Пример использования:
#include <iostream>
#include <mutex> // для std::unique_lock
#include <shared_mutex>
#include <thread>
class ThreadSafeCounter {
public:
ThreadSafeCounter() = default;
// Значение счетчика могут считывать несколько читающих потоков одновременно.
unsigned int get() const {
std::shared_lock lock(mutex_);
return value_;
}
// Увеличивать/записывать может только один записывающий поток.
void increment() {
std::unique_lock lock(mutex_);
value_++;
}
// Сбрасывать/записывать может только один записывающий поток.
void reset() {
std::unique_lock lock(mutex_);
value_ = 0;
}
private:
mutable std::shared_mutex mutex_;
unsigned int value_ = 0;
};
int main() {
ThreadSafeCounter counter;
auto increment_and_print = [&counter]() {
for (int i = 0; i < 3; i++) {
counter.increment();
std::cout << std::this_thread::get_id() << ' ' << counter.get() << '\n';
// Примечание. Запись в std::cout на самом деле также должна быть синхронизирована
// другим std::mutex. Это было опущено, чтобы не сохранить пример небольшим.
}
};
std::thread thread1(increment_and_print);
std::thread thread2(increment_and_print);
thread1.join();
thread2.join();
}
std::shared_timed_mutex
std::shared_timed_mutex
предлагает такую же семантику владения мьютексом, как std::shared_mutex
.
Кроме того, std::shared_timed_mutex
подобно timed_mutex
предоставляет возможность попытаться претендовать на владение shared_timed_mutex
с таймаутом с помощью методов try_lock_for()
, try_lock_until()
, try_lock_shared_for()
, try_lock_shared_until()
.
std::shared_lock
Класс shared_lock
– это аналог std::unique_lock
для получения общего доступа к данным, защищаемым с помощью shared_mutex
. Он позволяет отсроченную блокировку, попытку блокировки с таймаутом и передачу права владения блокировкой. Блокировка shared_lock
блокирует shared_mutex
в общем режиме (чтобы заблокировать его в эксклюзивном режиме, можно использовать std::unique_lock
).
Класс shared_lock
является перемещаемым, но не копируемым.
Для работы с условными переменными можно использовать std::condition_variable_any
(std::condition_variable
требует std::unique_lock
и поэтому поддерживает только исключительное владение).