Skip to content

Commit 941566a

Browse files
committed
1. 更新修改“共享数据”中的部分表述
2. 修改仓库的 README,增加部分描述
1 parent 6db047d commit 941566a

File tree

2 files changed

+38
-17
lines changed

2 files changed

+38
-17
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616

1717
---
1818

19-
  国内的 C++ 并发编程的教程并不稀少,不管是书籍、博客、视频。而我们想以更加**现代**简单的方式进行教学
19+
  国内的 C++ 并发编程的教程并不稀少,不管是书籍、博客、视频。然而大多数是粗糙的、不够准确、复杂的。而我们想以更加**现代****简单****准确**的方式进行教学
2020

21-
  我们在教学中可能常常为您展示标准库源码,自己手动实现一些库,这是必须的,希望您是已经较为熟练使用模板(如果没有,可以先学习 [**现代C++模板教程**](https://github.com/Mq-b/Modern-Cpp-templates-tutorial))。
21+
  我们在教学中可能常常为您展示部分标准库源码,自己手动实现一些库,这是必须的,希望您是已经较为熟练使用模板(如果没有,可以先学习 [**现代C++模板教程**](https://github.com/Mq-b/Modern-Cpp-templates-tutorial)。阅读源码可以帮助我们更轻松的理解标准库设施的使用与原理
2222

2323
  本教程假设开发者的最低水平为:**`C++11 + STL + template`**
2424

md/03共享数据.md

+36-15
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
- 使用互斥量来保护共享数据。
88

9-
在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 `std::thread` 源码。所以如果你好好学习了上一章,本章也完全不用担心,它甚至是更加简单的
9+
在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 `std::thread` 源码。所以如果你好好学习了上一章,本章也完全不用担心。
1010

11-
我们本节,就要开始聊共享数据的那些事。
11+
我们本章,就要开始聊共享数据的那些事。
1212

1313
## 条件竞争
1414

@@ -124,7 +124,7 @@ int main() {
124124

125125
> 至于到底哪个线程才会成功调用,这个是由操作系统调度决定的。
126126
127-
看一遍描述就可以了,简而言之,被 `lock()``unlock()` 包含在其中的代码,是线程安全的,不会被其他线程的执行所打断
127+
看一遍描述就可以了,简而言之,被 `lock()``unlock()` 包含在其中的代码是线程安全的,同一时间只有一个线程执行,不会被其它线程的执行所打断
128128

129129
不过一般不推荐这样显式的 `lock()``unlock()`,我们可以使用 C++11 标准库引入的“管理类” [`std::lock_guard`](https://zh.cppreference.com/w/cpp/thread/lock_guard)
130130

@@ -135,7 +135,7 @@ void f() {
135135
}
136136
```
137137

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) 的实现
139139

140140
```cpp
141141
_EXPORT_STD template <class _Mutex>
@@ -162,7 +162,7 @@ private:
162162
};
163163
```
164164
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)了移动等函数的隐式定义。
166166
167167
它只保有一个私有数据成员,一个引用,用来引用互斥量。
168168
@@ -187,7 +187,7 @@ void f(){
187187

188188
- 我们要尽可能的让互斥量上锁的**粒度**小,只用来确保必须的共享资源的线程安全。
189189

190-
> **粒度**”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
190+
> **“粒度”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。**
191191
192192
我们举一个例子:
193193

@@ -253,7 +253,7 @@ std::mutex m;
253253
std::scoped_lock lc{ m }; // std::scoped_lock<std::mutex>
254254
```
255255

256-
我们在后续处理死锁,会详细了解这个类。
256+
我们在后续管理多个互斥量,会详细了解这个类。
257257

258258
## 保护共享数据
259259

@@ -422,7 +422,7 @@ void swap(X& lhs, X& rhs) {
422422

423423
## `std::unique_lock` 灵活的锁
424424

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` 改写一下:
426426

427427
```cpp
428428
void swap(X& lhs, X& rhs) {
@@ -470,11 +470,11 @@ void lock() { // lock the mutex
470470
}
471471
```
472472

473-
必须得是当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量。我们的代码因为调用了 `lock` ,所以 `_Owns` `true` ,函数结束的时候会解锁互斥量。
473+
必须得是**当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量**。我们的代码因为调用了 `lock` ,所以 `_Owns` 设置为 `true` ,函数结束的时候会解锁互斥量。
474474

475475
---
476476

477-
其实设计挺奇怪的对吧,这个所有权语义其实上面的代码还不够简单直接,我们再举个例子:
477+
设计挺奇怪的对吧,这个所有权语义其实上面的代码还不够简单直接,我们再举个例子:
478478

479479
```cpp
480480
std::mutex m;
@@ -485,7 +485,7 @@ int main() {
485485
}
486486
```
487487

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()` 进行检测,也就是:
489489

490490
```cpp
491491
void _Validate() const { // check if the mutex can be locked
@@ -505,6 +505,27 @@ void _Validate() const { // check if the mutex can be locked
505505
lock.mutex()->lock();
506506
```
507507

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+
508529
---
509530

510531
我们前面提到了 `std::unique_lock` 更加灵活,那么灵活在哪?很简单,它拥有 `lock()``unlock()` 成员函数,所以我们能写出如下代码:
@@ -525,7 +546,7 @@ void f() {
525546

526547
而不是像之前 `std::lock_guard` 一样使用 `{}`
527548

528-
另外再聊一聊开销吧,其实倒也还好,多了一个 `bool` ,内存对齐,x64 环境也就是 `16` 字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 `std::lock_guard`
549+
另外再聊一聊开销吧,其实倒也还好,多了一个 `bool` ,内存对齐,x64 环境也就是 `16` 字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 `std::lock_guard`,当它无法满足你的需求或者显得代码非常繁琐,那么可以考虑使用 `std::unique_lock`
529550

530551
## 在不同作用域传递互斥量
531552

@@ -670,7 +691,7 @@ void process_data(){
670691
671692
试想一下,你有一个数据结构存储了用户的设置信息,每次用户打开程序的时候,都要进行读取,且运行时很多地方都依赖这个数据结构需要读取,所以为了效率,我们使用了多线程读写。这个数据结构很少进行改变,而我们知道,多线程读取,是没有数据竞争的,是安全的,但是有些时候又不可避免的有修改和读取都要工作的时候,所以依然必须得使用互斥量进行保护。
672693
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)”。
674695
675696
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)。它们的区别简单来说,前者支持更多的操作方式,后者有更高的性能优势。
676697
@@ -696,7 +717,7 @@ public:
696717
};
697718
```
698719
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)链接。标准输出可能交错,但无数据竞争。
700721
701722
`std::shared_timed_mutex` 具有 `std::shared_mutex` 的所有功能,并且额外支持超时功能。所以以上代码可以随意更换这两个互斥量。
702723
@@ -756,6 +777,6 @@ void recursiveFunction(int count) {
756777
757778
## 总结
758779
759-
本章讨论了线程间的共享数据发生恶性条件竞争时,会带来的问题。还讨论了如何使用互斥量(`std::mutex`)和如何避免这些问题。C++标准库提供了不少工具来避免这些问题,但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时了解了一些避免死锁的方法和技术,之后了解了互斥量所有权的转移。以及在最早就提到的,“**锁的粒度**”,这个问题很好理解,没有过多额外描述。然后讨论了一些其他的保护共享数据的方式`std::call_once()``std::shared_mutex`
780+
本章讨论了线程间的共享数据发生恶性条件竞争时,会带来的问题。还讨论了如何使用互斥量(`std::mutex`)和如何避免这些问题。C++标准库提供了不少工具来避免这些问题,但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时了解了一些避免死锁的方法和技术,之后了解了互斥量所有权的转移。以及在最早就提到的,“**锁的粒度**”,这个问题很好理解,没有过多额外描述。然后讨论了一些其它的保护共享数据的方式`std::call_once()``std::shared_mutex`
760781
761782
下一章,我们将开始讲述同步操作,会使用到 [`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

Comments
 (0)