6
6
7
7
- 使用互斥量来保护共享数据。
8
8
9
- 在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 ` std::thread ` 源码。所以如果你好好学习了上一章,本章也完全不用担心,它甚至是更加简单的 。
9
+ 在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 ` std::thread ` 源码。所以如果你好好学习了上一章,本章也完全不用担心。
10
10
11
- 我们本节 ,就要开始聊共享数据的那些事。
11
+ 我们本章 ,就要开始聊共享数据的那些事。
12
12
13
13
## 条件竞争
14
14
@@ -124,7 +124,7 @@ int main() {
124
124
125
125
> 至于到底哪个线程才会成功调用,这个是由操作系统调度决定的。
126
126
127
- 看一遍描述就可以了,简而言之,被 ` lock() ` 和 ` unlock() ` 包含在其中的代码,是线程安全的,不会被其他线程的执行所打断 。
127
+ 看一遍描述就可以了,简而言之,被 ` lock() ` 和 ` unlock() ` 包含在其中的代码是线程安全的,同一时间只有一个线程执行,不会被其它线程的执行所打断 。
128
128
129
129
不过一般不推荐这样显式的 ` lock() ` 与 ` unlock() ` ,我们可以使用 C++11 标准库引入的“管理类” [ ` std::lock_guard ` ] ( https://zh.cppreference.com/w/cpp/thread/lock_guard ) :
130
130
@@ -135,7 +135,7 @@ void f() {
135
135
}
136
136
```
137
137
138
- 那么问题来了,` std::lock_guard ` 是如何做到的呢?它是怎么实现的呢?首先顾名思义,这是一个“管理类”模板,用来管理互斥量的上锁与解锁,我们来看它的 [ MSVC 实现 ] ( https://github.com/microsoft/STL/blob/8e2d724cc1072b4052b14d8c5f81a830b8f1d8cb/stl/inc/mutex#L452-L473 ) :
138
+ 那么问题来了,` std::lock_guard ` 是如何做到的呢?它是怎么实现的呢?首先顾名思义,这是一个“管理类”模板,用来管理互斥量的上锁与解锁,我们来看它在 [ MSVC STL ] ( https://github.com/microsoft/STL/blob/8e2d724cc1072b4052b14d8c5f81a830b8f1d8cb/stl/inc/mutex#L452-L473 ) 的实现 :
139
139
140
140
``` cpp
141
141
_EXPORT_STD template <class _Mutex >
@@ -162,7 +162,7 @@ private:
162
162
};
163
163
```
164
164
165
- 这段代码极其简单,首先管理类,自然不可移动不可复制,我们定义了复制构造复制赋值为 [弃置函数](https://zh.cppreference.com/w/cpp/language/function#.E5.BC.83.E7.BD.AE.E5.87.BD.E6.95.B0),同时[阻止](https://zh.cppreference.com/w/cpp/language/rule_of_three#.E4.BA.94.E4.B9.8B.E6.B3.95.E5.88.99)了移动等函数的隐式定义。
165
+ 这段代码极其简单,首先管理类,自然不可移动不可复制,我们定义复制构造与复制赋值为 [弃置函数](https://zh.cppreference.com/w/cpp/language/function#.E5.BC.83.E7.BD.AE.E5.87.BD.E6.95.B0),同时[阻止](https://zh.cppreference.com/w/cpp/language/rule_of_three#.E4.BA.94.E4.B9.8B.E6.B3.95.E5.88.99)了移动等函数的隐式定义。
166
166
167
167
它只保有一个私有数据成员,一个引用,用来引用互斥量。
168
168
@@ -187,7 +187,7 @@ void f(){
187
187
188
188
- 我们要尽可能的让互斥量上锁的** 粒度** 小,只用来确保必须的共享资源的线程安全。
189
189
190
- > “ ** 粒度 ** ”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
190
+ > ** “粒度 ”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。**
191
191
192
192
我们举一个例子:
193
193
@@ -253,7 +253,7 @@ std::mutex m;
253
253
std::scoped_lock lc{ m }; // std::scoped_lock<std::mutex>
254
254
```
255
255
256
- 我们在后续处理死锁 ,会详细了解这个类。
256
+ 我们在后续管理多个互斥量 ,会详细了解这个类。
257
257
258
258
## 保护共享数据
259
259
@@ -422,7 +422,7 @@ void swap(X& lhs, X& rhs) {
422
422
423
423
## ` std::unique_lock ` 灵活的锁
424
424
425
- [ ` std::unique_lock ` ] ( https://zh.cppreference.com/w/cpp/thread/unique_lock ) 是 C++11 引入的一种通用互斥包装器,它相比于 ` std::lock_guard ` 更加的灵活。当然,它也更加的复杂,尤其它还可以与后面我们要讲的条件变量一起使用。可以将之前的使用 ` std::lock_guard ` 的 ` swap ` 改写一下:
425
+ [ ` std::unique_lock ` ] ( https://zh.cppreference.com/w/cpp/thread/unique_lock ) 是 C++11 引入的一种通用互斥包装器,它相比于 ` std::lock_guard ` 更加的灵活。当然,它也更加的复杂,尤其它还可以与我们下一章要讲的 [ 条件变量 ] ( https://zh.cppreference.com/w/cpp/thread#.E6.9D.A1.E4.BB.B6.E5.8F.98.E9.87.8F ) 一起使用。使用它可以将之前使用 ` std::lock_guard ` 的 ` swap ` 改写一下:
426
426
427
427
``` cpp
428
428
void swap (X& lhs, X& rhs) {
@@ -470,11 +470,11 @@ void lock() { // lock the mutex
470
470
}
471
471
```
472
472
473
- 必须得是当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量。我们的代码因为调用了 ` lock ` ,所以 ` _Owns ` 为 ` true ` ,函数结束的时候会解锁互斥量。
473
+ 必须得是 ** 当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量** 。我们的代码因为调用了 ` lock ` ,所以 ` _Owns ` 设置为 ` true ` ,函数结束的时候会解锁互斥量。
474
474
475
475
---
476
476
477
- 其实设计挺奇怪的对吧 ,这个所有权语义, 其实上面的代码还不够简单直接,我们再举个例子:
477
+ 设计挺奇怪的对吧 ,这个所有权语义。 其实上面的代码还不够简单直接,我们再举个例子:
478
478
479
479
``` cpp
480
480
std::mutex m;
@@ -485,7 +485,7 @@ int main() {
485
485
}
486
486
```
487
487
488
- 这段代码运行会[ 抛出异常] ( https://godbolt.org/z/KqKrETe6d ) ,原因很简单,因为 ` std::adopt_lock ` 只是不上锁,但是有所有权 ,即 ` _Owns ` 设置为 ` true ` 了,当运行 ` lock() ` 成员函数的时候,调用了 ` _Validate() ` 进行检测,也就是:
488
+ 这段代码运行会[ 抛出异常] ( https://godbolt.org/z/KqKrETe6d ) ,原因很简单,因为 ` std::adopt_lock ` 只是不上锁,但是 ** 有所有权 ** ,即 ` _Owns ` 设置为 ` true ` 了,当运行 ` lock() ` 成员函数的时候,调用了 ` _Validate() ` 进行检测,也就是:
489
489
490
490
``` cpp
491
491
void _Validate () const { // check if the mutex can be locked
@@ -505,6 +505,27 @@ void _Validate() const { // check if the mutex can be locked
505
505
lock.mutex()->lock ();
506
506
```
507
507
508
+ 也就是说 ` std::unique_lock ` 要想调用 ` lock() ` 成员函数,必须是当前** 没有所有权** 。
509
+
510
+ 所以正常的用法其实是,先上锁了互斥量,然后传递 ` std::adopt_lock ` 构造 ` std::unique_lock ` 对象表示拥有互斥量的所有权,即可在析构的时候正常解锁。如下:
511
+
512
+ ``` cpp
513
+ std::mutex m;
514
+
515
+ int main () {
516
+ lock.lock();
517
+ std::unique_lock<std::mutex>lock{ m,std::adopt_lock };
518
+ }
519
+ ```
520
+
521
+ ---
522
+
523
+ 简而言之:
524
+
525
+ - 使用 ` std::defer_lock ` 构造函数不上锁,要求构造之后上锁
526
+ - 使用 ` std::adopt_lock ` 构造函数不上锁,要求在构造之前互斥量上锁
527
+ - 默认构造会上锁,要求构造函数之前和构造函数之后都不能再次上锁
528
+
508
529
---
509
530
510
531
我们前面提到了 ` std::unique_lock ` 更加灵活,那么灵活在哪?很简单,它拥有 ` lock() ` 和 ` unlock() ` 成员函数,所以我们能写出如下代码:
@@ -525,7 +546,7 @@ void f() {
525
546
526
547
而不是像之前 ` std::lock_guard ` 一样使用 ` {} ` 。
527
548
528
- 另外再聊一聊开销吧,其实倒也还好,多了一个 ` bool ` ,内存对齐,x64 环境也就是 ` 16 ` 字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 ` std::lock_guard ` 。
549
+ 另外再聊一聊开销吧,其实倒也还好,多了一个 ` bool ` ,内存对齐,x64 环境也就是 ` 16 ` 字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 ` std::lock_guard ` ,当它无法满足你的需求或者显得代码非常繁琐,那么可以考虑使用 ` std::unique_lock ` 。
529
550
530
551
## 在不同作用域传递互斥量
531
552
@@ -670,7 +691,7 @@ void process_data(){
670
691
671
692
试想一下,你有一个数据结构存储了用户的设置信息,每次用户打开程序的时候,都要进行读取,且运行时很多地方都依赖这个数据结构需要读取,所以为了效率,我们使用了多线程读写。这个数据结构很少进行改变,而我们知道,多线程读取,是没有数据竞争的,是安全的,但是有些时候又不可避免的有修改和读取都要工作的时候,所以依然必须得使用互斥量进行保护。
672
693
673
- 然而使用 ` std::mutex ` 的开销是过大的,它不管有没有发生数据竞争(也就是就算全是读的情况)也必须是老老实实上锁解锁,只有一个线程可以运行。如果你学过其他语言或者操作系统 ,相信这个时候就已经想到了:“[ *** 读写锁*** ] ( https://zh.wikipedia.org/wiki/%E8%AF%BB%E5%86%99%E9%94%81 ) ”。
694
+ 然而使用 ` std::mutex ` 的开销是过大的,它不管有没有发生数据竞争(也就是就算全是读的情况)也必须是老老实实上锁解锁,只有一个线程可以运行。如果你学过其它语言或者操作系统 ,相信这个时候就已经想到了:“[ *** 读写锁*** ] ( https://zh.wikipedia.org/wiki/%E8%AF%BB%E5%86%99%E9%94%81 ) ”。
674
695
675
696
C++ 标准库自然为我们提供了: [ ` std::shared_timed_mutex ` ] ( https://zh.cppreference.com/w/cpp/thread/shared_timed_mutex ) (C++14)、 [ ` std::shared_mutex ` ] ( https://zh.cppreference.com/w/cpp/thread/shared_mutex ) (C++17)。它们的区别简单来说,前者支持更多的操作方式,后者有更高的性能优势。
676
697
@@ -696,7 +717,7 @@ public:
696
717
};
697
718
```
698
719
699
- > [ 完整代码] ( /code/03共享数据/保护不常更新的数据结构.cpp ) 。[ 测试] ( https://godbolt.org/z/KG84rb8qd ) 链接。标准输出可能交错,但无数据竞争。
720
+ > [ 完整代码] ( https://github.com/Mq-b/ModernCpp-ConcurrentProgramming-Tutorial/blob/main /code/03共享数据/保护不常更新的数据结构.cpp) 。[ 测试] ( https://godbolt.org/z/KG84rb8qd ) 链接。标准输出可能交错,但无数据竞争。
700
721
701
722
` std::shared_timed_mutex ` 具有 ` std::shared_mutex ` 的所有功能,并且额外支持超时功能。所以以上代码可以随意更换这两个互斥量。
702
723
@@ -756,6 +777,6 @@ void recursiveFunction(int count) {
756
777
757
778
## 总结
758
779
759
- 本章讨论了线程间的共享数据发生恶性条件竞争时,会带来的问题。还讨论了如何使用互斥量(` std::mutex ` )和如何避免这些问题。C++标准库提供了不少工具来避免这些问题,但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时了解了一些避免死锁的方法和技术,之后了解了互斥量所有权的转移。以及在最早就提到的,“** 锁的粒度** ”,这个问题很好理解,没有过多额外描述。然后讨论了一些其他的保护共享数据的方式 :` std::call_once() ` 和 ` std::shared_mutex ` 。
780
+ 本章讨论了线程间的共享数据发生恶性条件竞争时,会带来的问题。还讨论了如何使用互斥量(` std::mutex ` )和如何避免这些问题。C++标准库提供了不少工具来避免这些问题,但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时了解了一些避免死锁的方法和技术,之后了解了互斥量所有权的转移。以及在最早就提到的,“** 锁的粒度** ”,这个问题很好理解,没有过多额外描述。然后讨论了一些其它的保护共享数据的方式 :` std::call_once() ` 和 ` std::shared_mutex ` 。
760
781
761
782
下一章,我们将开始讲述同步操作,会使用到 [ ` std::future ` ] ( https://zh.cppreference.com/w/cpp/thread/future ) 、[ ` std::async ` ] ( https://zh.cppreference.com/w/cpp/thread/async ) 、[ ` std::condition_variable ` ] ( https://zh.cppreference.com/w/cpp/thread/condition_variable ) 、[ ` std::atomic ` ] ( https://zh.cppreference.com/w/cpp/atomic/atomic ) 等设施,也就是异步、条件变量、原子。
0 commit comments