「C++ 多线程」std::shared_mutex,std::shared_lock

文章大图来源:pixiv_id=95319826

1 std::shared_mutex 基本概念

std::shared_mutex 在 C++ 17 被正式引入,是一种互斥量(mutex)类型,包含在 <shared_mutex> 头文件中。std::shared_mutex 用于多线程编程中的并发控制,可以实现多个线程之间对共享数据的互斥操作。

std::mutex 不同的是,std::shared_mutex 提供了两种不同的锁机制,其可以作为 共享锁 使用,也可以作为 独占锁 使用。

共享锁 允许多个线程同时对共享数据进行访问,一般用于多个线程读取共享资源的情况。在这种机制下,多个线程可以同时获得互斥量的锁(共享锁),只要对数据的操作仅限于读取。共享锁 可以提高多线程读取数据场景下的并发性能。

独占锁 只允许单个线程在同一时刻对共享数据进行操作,一般用于多线程对共享数据进行修改等操作的情况。独占锁 被一个线程占用时,其他线程就无法获得这个锁,直到这个独占锁被释放,以确保数据的一致性。例如 std::mutexstd::timed_mutex 都属于 独占锁,且只能作为独占锁使用。

2 std::shared_mutex 演变过程

std::shared_mutex 其实最早是在 C++ 14 的时候被引入,但是此时的 std::shared_mutex带有定时功能共享互斥锁,相当于现在的 std::shared_timed_mutex

一方面为了保持命名的一致性,另一方面为了空出了空出 不带定时功能共享锁 std::shared_mutex 的定义,设定一个更加简单、更加高效的共享锁,将这两种共享锁进行区分,后来 C++ 14 引入的 std::shared_mutex 就改名为 std::shared_timed_mutex,并且在之后的 C++ 17 中正式引入了不带有定时功能的 std::shared_mutex

std::shared_mutex 演变过程详情可见 A proposal to rename shared_mutex to shared_timed_mutex

3 std::shared_mutex 成员函数

3.1 lock(), try_lock(), unlock()

std::shared_mutex 中的 lock(), try_lock(), unlock() 函数,用于 std::shared_mutex 作为独占锁时的加锁、尝试加锁和解锁。基本的用法和 std::mutex 相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <chrono>
#include <vector>

std::shared_mutex s_mtx;
int count;

void foo(int id){
if(id & 1){
s_mtx.lock(); // shared_mutex 作为独占锁使用
count ++ ;
std::cout << "thread id = " << id;
std::cout << ", count = " << count << "\n";
s_mtx.unlock();
}else{
if(s_mtx.try_lock()){
count ++ ;
std::cout << "thread id = " << id;
std::cout << ", count = " << count << "\n";
s_mtx.unlock();
}
}
}

int main(){
std::vector<std::thread> threads(10);
for(size_t i = 0; i < threads.size(); i ++ ){
threads[i] = std::thread(foo, i);
}
for(auto& t : threads) t.join();

std::cout << "final count = " << count << "\n";

return 0;
}

在上面的代码中,如果线程的标记为奇数,那么调用 std::shared_mutex::lock()std::shared_mutex::unlock() 进行手动的尝试加锁解锁。如果尝试加锁后成功获得了锁,那么线程继续执行下去;否则当前线程会被阻塞,直到其他线程释放了锁。

如果线程的标记为偶数,那么调用 std::shared_mutex::try_lock() 去进行非阻塞地尝试加锁。如果成功获得锁,那么线程继续执行下去;否则当前线程放弃获得锁,直接返回。

可能的运行结果如下:

可以看出,上面的代码中完全当作 独占锁 进行使用,和 std::mutex 的作用是一样的。

3.2 lock_shared(), try_lock_shared(), unlock_shared()

std::shared_mutex 中的 lock_shared(), try_lock_shared(), unlock_shared() 函数用于作为 共享锁 的加锁、尝试加锁和解锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <chrono>
#include <vector>

std::shared_mutex s_mtx;
int count;

void read(int id){
for(int i = 0; i < 5; i ++ ){
if(id & 1){
s_mtx.lock_shared(); // shared_mutex 作为共享锁使用
std::cout << "read thread id = " << id;
std::cout << ", count = " << count << "\n";
s_mtx.unlock_shared();
}else{
if(s_mtx.try_lock_shared()){
std::cout << "read thread id = " << id;
std::cout << ", count = " << count << "\n";
s_mtx.unlock_shared();
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}

void write(){
s_mtx.lock(); // shared_mutex 作为独占锁使用
count ++ ;
std::cout << "write thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
s_mtx.unlock();
}

int main(){
std::vector<std::thread> readers(5);
for(size_t i = 0; i < readers.size(); i ++ ){
readers[i] = std::thread(read, i);
}
std::thread writer(write);

for(auto& t : readers) t.join();
writer.join();

return 0;
}

在上面的代码中,有 读线程 和 写线程,其中读线程执行的是 read 函数,写线程执行的是 write 函数。每个读线程会循环进行 5 次读取数据的操作。

在读线程中,我们调用 lock_shared(), try_lock_shared(), unlock_shared() 函数 来实现共享读的机制。写线程中,则采用独占锁的机制。

一开始有多个读线程共享了 std::shared_mutex 互斥量 s_mtx 的锁,与此同时,由于这些读线程共享了锁,写线程尝试获得锁(独占这个锁)被阻塞。

之后,当这多个读线程全部执行完毕,并且全部将共享锁释放,写线程 优先 获得锁。当写线程获得锁时,其他新的读线程尝试获得锁会被阻塞(调用 try_lock_shared() 函数的读线程则会直接返回),直到写线程释放锁。

写线程执行完数据修改操作并释放锁之后,其他新的读线程才能够成功共享锁。

可能的运行结果如下:

可以看出,一开始有多个读线程共享锁,此时的数据为 0;然后当这些读线程释放共享锁之后,写线程优先获得锁,并对数据进行修改,在此期间,新的读线程尝试共享锁被阻塞(调用 try_lock_shared() 函数的读线程则会直接返回);当写线程操作完并释放锁之后,新的读线程成功共享锁,并读取数据,此时的数据为 1

3.3 std::shared_mutex 读写线程优先级

  • 读并发与写阻塞

    当有多个读线程获得共享锁时,这些读线程可以并发地对共享资源进行读取操作。此时如果有写线程尝试获取独占锁,这个写线程会被阻塞。

    阻塞会一直持续,直到所有当前持有共享锁的读线程都释放了共享锁。

  • 写独占与读阻塞

    当一个写线程占有独占锁时,新的读线程尝试获取共享锁会被阻塞。独占锁的优先级高于共享锁

    这种设计是为了防止写操作饥饿(write - starvation)的情况。如果读线程不断地获取共享锁,而写线程一直无法获取独占锁来执行写入操作,这会导致数据不能及时更新。

实际的调度顺序还可能受到操作系统调度策略以及线程优先级等因素的影响。在某些特殊情况下,虽然原则上写线程等待读线程释放锁,读线程等待写线程释放锁,但具体的执行顺序可能会因为外部因素而有所不同。

4 std::shared_mutex 配合 std::shared_lock

为了保持代码简洁,以及为了防止出现加锁、解锁混乱的情况发生,我们通常很少去使用 std::shared_mutex 中的 lock() 等成员函数去手动的加锁。在 std::mutex 中,我们可以使用 std::unique_lock 去实现自动加锁,更好地维护锁的管理权。因此,C++ 中引入了 std::shared_lock,专门用于 std::shared_mutex 互斥量的锁的管理。

  • std::shared_lock 基本概念

    std::shared_lock 是 C++ 14 引入的用于管理 std::shared_mutex(共享互斥锁)的共享锁的类模板(不适用于其他类型的互斥量,例如 std::mutex)。

    std::shared_lock 提供了一种 RAII(Resource Acquisition Is Initialization,资源获取即初始化)方式来获取和释放共享锁,确保在对象生命周期结束时自动释放锁,从而避免了因忘记释放锁而导致的资源竞争和死锁等问题。

std::shared_mutex 作为独占锁时,我们可以使用 std::unique_lock 去实现 std::shared_mutex 的自动加锁、解锁,同样可以更加灵活地进行加锁解锁的处理。

基本的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
std::shared_mutex mtx;

void read(){ // 读线程
std::shared_lock<std::shared_mutex> lck(mtx); // 共享
// 进行对共享资源的读操作
}

void write(){ // 写线程
std::unique_lock<std::shared_mutex> lck(mtx); // 独占
// 进行对共享资源的写操作
}

以下是一个读者、写者问题的多线程代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <chrono>
#include <vector>

class Reader_Writer{
std::shared_mutex s_mtx; // 互斥量
int val = 0; // 共享数据
public:
// 读操作
void read_data(int id){
std::shared_lock<std::shared_mutex> lck(s_mtx); // 共享锁
std::cout << "Reader " << id << " reads the value = " << val << "\n";
}
// 读者,进行 100 次读操作
void reader(int id){
for(int i = 0; i < 100; i ++ ){
read_data(id);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// 写操作
void write_data(int id){
std::unique_lock<std::shared_mutex> lck(s_mtx); // 独占锁
val ++ ;
std::cout << "Writer " << id << " writes the value = " << val << "\n";
}
// 写者,进行 10 次写操作
void writer(int id){
for(int i = 0; i < 10; i ++ ){
write_data(id);
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
}
}
};

int main(){
Reader_Writer obj;
// 创建 10 个读者线程和 2 个写者线程
std::vector<std::thread> readers(10);
for(size_t i = 0; i < readers.size(); i ++ ){
readers[i] = std::thread(&Reader_Writer::reader, &obj, i);
}
std::vector<std::thread> writers(2);
for(size_t i = 0; i < writers.size(); i ++ ){
writers[i] = std::thread(&Reader_Writer::writer, &obj, i);
}

for(auto& reader : readers) reader.join();
for(auto& writer : writers) writer.join();

return 0;
}

在上面的代码中,定义了一个 Reader_Writer 类,包含一个共享数据 val 和一个共享互斥量 s_mtx

Reader_Writer 类包含了读线程函数 reader(内部调用读操作函数 read_data),同时还有写线程函数 writer(内部调用写操作函数 write_data,每次对数据 val 进行 +1)。

一共创建了 10 个读者线程和 2 个写者线程,线程接口函数分别为 Reader_Writer::readerReader_Writer::writer。在线程接口函数中,Reader_Writer::reader 采用 std::shared_lock 实现了多个读线程共享锁的自动加锁解锁; Reader_Writer::writer 采用 std::unique_lock 实现写者线程之间互斥访问共享数据(独占锁机制),以及自动的加锁解锁。

读者/写者线程在循环中每次读/写操作完成并释放锁之后,当前线程都进行一个小时间段的休息,避免频繁地读/写数据。

可能的运行结果如下:

参考

  1. cppreference std::share_mutex

  2. cppreference std::shared_lock

  3. Why shared_timed_mutex is defined in c++14, but shared_mutex in c++17?

  4. 为什么shared_timed_mutex是在c++14中定义的,而shared_mutex是在c++17中定义的?

  5. A proposal to rename shared_mutex to shared_timed_mutex


「C++ 多线程」std::shared_mutex,std::shared_lock
https://marisamagic.github.io/2024/12/29/20241229/
作者
MarisaMagic
发布于
2024年12月29日
许可协议