Merge pull request #289 from 1132719438/main

Some fixes in concurrency and global-variable chapter
pull/297/head
Sunface 3 years ago committed by GitHub
commit e09a951ad0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,6 +1,6 @@
# 并发和并行
> 并发是同一时间应对多件事情的能力 - [Rob Pike](https://baike.baidu.com/item/罗布·派克/10983505)
> 并发是同一时间应对多件事情的能力 - [Rob Pike](https://en.wikipedia.org/wiki/Rob_Pike)
并行和并发其实并不难,但是也给一些用户造成了困扰,因此我们专门开辟一个章节,用于讲清楚这两者的区别。
@ -11,15 +11,15 @@
上图很直观的体现了:
- 并发是多个队列使用同一个咖啡机,然后两个队列轮换着使用(未必是1:1轮换也可能是其它轮换规则),最终每个人都能接到咖啡
- 并行是每个队列都拥有一个咖啡机,最终也是每个人都能接到咖啡,但是效率更高,因为同时可以有两个人在接咖啡
- **并发(Concurrent)** 是多个队列使用同一个咖啡机,然后两个队列轮换着使用(未必是1:1轮换也可能是其它轮换规则),最终每个人都能接到咖啡
- **并行(Parallel)** 是每个队列都拥有一个咖啡机,最终也是每个人都能接到咖啡,但是效率更高,因为同时可以有两个人在接咖啡
当然,我们还可以对比下串行:只有一个队列且仅使用一台咖啡机,哪怕前面那个人接咖啡时突然发呆了几分钟,后面的人也只能等他结束才能继续接。可能有读者有疑问了,从图片来看,并发也存在这个问题啊,前面的人抽了几分钟不接怎么办?很简单,另外一个队列的人把他推开就行了,自己队友不能在背后开枪,但是其它队的可以:)
在正式开始之前,先给出一个结论:**并发和并行都是对"多任务"处理的描述,其中并发是轮流处理,而并行是同时处理**。
## CPU多核
现在的个人计算机动辄拥有十来个核心(M1 Max/Itel 12代)如果使用串行的方式那真是太低调了因此我们把各种任务简单分成多个队列每个队列都交给一个cpu核心去执行当某个cpu核心没有任务时它还能去其它核心的队列中偷任务(真·老黄牛),这样就实现了并行化处理。
现在的个人计算机动辄拥有十来个核心(M1 Max/Intel 12代)如果使用串行的方式那真是太低调了因此我们把各种任务简单分成多个队列每个队列都交给一个cpu核心去执行当某个cpu核心没有任务时它还能去其它核心的队列中偷任务(真·老黄牛),这样就实现了并行化处理。
#### 单核心并发
那问题来了在早期只有一个CPU核心时我们的任务是怎么处理的呢其实聪明的读者应该已经想到是的并发解君愁。当然这里还得提到操作系统的多线程正是操作系统多线程 + CPU核心才实现了现代化的多任务操作系统。
@ -29,7 +29,7 @@
相信大家都看出来了:**CPU核心**对应的是上图的咖啡机,而**多个线程的任务队列**就对应的多个排队的队列最终受限于CPU核心数, 每次只会有一个任务被处理。
和排队一样,假如某个任务执行时间过长,就会导致用户界面的假死(相信使用windows的同学或多或少都碰到或者假死的问题) 那么就需要CPU的任务调度了(真实CPU和调度很复杂,我们这里做了简化),有一个调度器会按照某些条件从队列中选择任务进行执行,并且当一个任务执行时间过长时,会强行切换该任务到后台中(或者放入任务队列,真实情况很复杂!),去执行新的任务。
和排队一样,假如某个任务执行时间过长,就会导致用户界面的假死(相信使用Windows的同学或多或少都碰到过假死的问题) 那么就需要CPU的任务调度了(真实CPU的调度很复杂,我们这里做了简化),有一个调度器会按照某些条件从队列中选择任务进行执行,并且当一个任务执行时间过长时,会强行切换该任务到后台中(或者放入任务队列,真实情况很复杂!),去执行新的任务。
不断这样的快速任务切换对用户而言就实现了表面上的多任务同时处理但是实际上最终也只有一个CPU核心在不停的工作。
@ -46,7 +46,7 @@
## 正式的定义
如果某个系统支持两个或者多个动作的同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于**“存在”**这个词。
如果某个系统支持两个或者多个动作的**同时存在**,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作**同时执行**,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于 **“存在”** 这个词。
在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是 **同时“存在”** 的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。

@ -102,7 +102,7 @@ fn main() {
}
```
以上代码中,`String`底层的字符串是存储在堆上,没有实现`Copy`特征,当它被发送后,会将所有权从发送端的`s`转移给接收端的`received`,之后`s`将无法被使用:
以上代码中,`String`底层的字符串是存储在堆上,没有实现`Copy`特征,当它被发送后,会将所有权从发送端的`s`转移给接收端的`received`,之后`s`将无法被使用:
```console
error[E0382]: borrow of moved value: `s`
--> src/main.rs:10:31
@ -178,10 +178,10 @@ fn main() {
- 需要所有的发送者都被`drop`掉后,接收者`rx`才会收到错误,进而跳出`for`循环,最终结束主线程
- 这里虽然用了`clone`但是并不会影响性能,因为它并不在热点代码路径中,仅仅会被执行一次
- 由于两个子线程谁创建完成是未知的,因此哪条消息先发送也是未知的,最终主线程的输出顺序也不确定
- 由于两个子线程谁创建完成是未知的,因此哪条消息先发送也是未知的,最终主线程的输出顺序也不确定
## 消息顺序
上述第三点的消息顺序仅仅是因为线程创建引起的,并不代表通道中的线程是无序的,对于通道而言,消息的发送顺序和接收顺序是一直的,满足`FIFO`原则(先进先出)。
上述第三点的消息顺序仅仅是因为线程创建引起的,并不代表通道中的消息是无序的,对于通道而言,消息的发送顺序和接收顺序是一致的,满足`FIFO`原则(先进先出)。
由于篇幅有限,具体的代码这里就不再给出,感兴趣的读者可以自己验证下。
@ -228,7 +228,7 @@ fn main() {
从输出还可以看出,`发送之前`和`发送之后`是连续输出的,没有受到接收端主线程的任何影响,因此通过`mpsc::channel`创建的通道是异步通道。
#### 同步通道
与异步通道相反,同步通道**发送消息是阻塞的,只有在消息被接收后才解除阻塞**例如:
与异步通道相反,同步通道**发送消息是阻塞的,只有在消息被接收后才解除阻塞**例如:
```rust
use std::sync::mpsc;
use std::thread;
@ -370,8 +370,8 @@ fn main() {
解决办法很简单,`drop`掉`send`即可:在代码中的注释下面添加一行`drop(send);`。
## mpmc更好的性能
## mpmc 更好的性能
如果你需要mpmc(多发送者,多接收者)或者需要更高的性能,可以考虑第三方库:
- [**crossbeam-channel**](https://github.com/crossbeam-rs/crossbeam/tree/master/crossbeam-channel), 老牌强库,功能较全,性能较强,之前是独立的库,但是后面合并到了`crossbeam`主仓库中
- [**flume**](https://github.com/zesterer/flume), 官方给出的性能数据要比crossbeam更好些,但是貌似最近没怎么更新
- [**flume**](https://github.com/zesterer/flume), 官方给出的性能数据某些场景要比crossbeam更好些

@ -76,6 +76,8 @@ unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}
当然,如果是自定义的复合类型,那没实现那哥俩的就较为常见了:**只要复合类型中有一个成员不是`Send`或`Sync`,那么该符合类型也就不是`Send`或`Sync`**。
**手动实现 `Send``Sync` 是不安全的**,通常并不需要手动实现 Send 和 Sync trait实现者需要使用`unsafe`小心维护并发安全保证。
至此,相关的概念大家已经掌握,但是我敢肯定,对于这两个滑不溜秋的家伙,大家依然会非常模糊,不知道它们该如何使用。那么我们来一起看看如何让原生指针可以在线程间安全的使用。
## 为原生指针实现`Send`

@ -248,7 +248,7 @@ fn main() {
在上面的描述中,我们用了"可能"二字,原因在于死锁在这段代码中不是必然发生的,总有一次运行你能看到最后一行打印输出。这是由于子线程的初始化顺序和执行速度并不确定,我们无法确定哪个线程中的锁先被执行,因此也无法确定两个线程对锁的具体使用顺序。
但是可以简单的说明下死锁发生的必然条件线程1锁住了`mutex1`并且线程`2`锁住了`mutex2`然后线程1试图去访问`mutex2`,同时线程`2`试图去访问`mutex1`,就会锁。 因为线程2需要等待线程1释放`mutex1`后,才会释放`mutex2`而与此同时线程1需要等待线程2释放`mutex2`后才能释放`mutex1`,这种情况造成了两个线程都无法释放对方需要的锁,最终死。
但是可以简单的说明下死锁发生的必然条件线程1锁住了`mutex1`并且线程`2`锁住了`mutex2`然后线程1试图去访问`mutex2`,同时线程`2`试图去访问`mutex1`,就会锁。 因为线程2需要等待线程1释放`mutex1`后,才会释放`mutex2`而与此同时线程1需要等待线程2释放`mutex2`后才能释放`mutex1`,这种情况造成了两个线程都无法释放对方需要的锁,最终死
那么为何某些时候死锁不会发生原因很简单线程2在线程1锁`mutex1`之前就已经全部执行完了随之线程2的`mutex2`和`mutex1`被全部释放线程1对锁的获取将不再有竞争者。 同理线程1若全部被执行完那线程2也不会被锁因此我们在线程1中间加一个睡眠增加死锁发生的概率。如果你在线程2中同样的位置也增加一个睡眠那死锁将必然发生!
@ -307,7 +307,7 @@ fn main() {
}
```
为了演示`try_lock`的作用,我们特定使用了之前必定会死锁的代码,并且将`lock`替换`try_lock`,与之前的结果不同,这段代码将不会再有死锁发生:
为了演示`try_lock`的作用,我们特定使用了之前必定会死锁的代码,并且将`lock`替换`try_lock`,与之前的结果不同,这段代码将不会再有死锁发生:
```console
线程 0 锁住了mutex1接着准备去锁mutex2 !
线程 1 锁住了mutex2, 准备去锁mutex1
@ -396,7 +396,7 @@ Err("WouldBlock")
如果不是追求特别极致的性能,建议选择前者。
## 用条件(Condvar)控制线程的同步
## 用条件变量(Condvar)控制线程的同步
`Mutex`用于解决资源安全访问的问题但是我们还需要一个手段来解决资源访问顺序的问题。而Rust考虑到了这一点为我们提供了条件变量(Condition Variables),它经常和`Mutex`一起使用,可以让线程挂起,直到某个条件发生后再继续执行,其实`Condvar`我们在之前的多线程章节就已经见到过,现在再来看一个不同的例子:

@ -138,7 +138,7 @@ Y = 3; Y *= 2;
X = 2; }
```
我们来讨论下以上线程状态`Y`最终的可能值(可能性依次降低):
我们来讨论下以上线程状态,`Y`最终的可能值(可能性依次降低):
- `Y = 3`: 线程`Main`运行完后才运行线程`A`,或者线程`A`运行完后再运行线程`Main`
- `Y = 6`: 线程`Main`的`Y = 3`运行完,但`X = 2`还没被运行, 此时线程A开始运行`Y *= 2`, 最后才运行`Main`线程的`X = 2`
@ -161,10 +161,10 @@ X = 2; }
在理解了内存顺序可能存在的改变后你就可以明白为什么Rust提供了`Ordering::Relaxed`用于限定内存顺序了事实上该枚举有5个成员:
- **Relaxed** 这是最宽松的规则它对编译器和CPU不做任何限制可以乱序
- **Release**,设定内存屏障(Memory barrier),指定屏障之前的数据不能被重新排序
- **Acquire**, 设定内存屏障,指定屏障之后的数据不能被重新排序,往往和`Release`在不同线程中联合使用
- **Release 释放**,设定内存屏障(Memory barrier),保证它之前的操作永远在它之前,但是它后面的操作可能被重排到它前面
- **Acquire 获取**, 设定内存屏障,保证在它之后的访问永远在它之后,但是它之前的操作却有可能被重排到它后面,往往和`Release`在不同线程中联合使用
- **AcqRel**, **Acquire**和**Release**的结合,同时拥有它们俩提供的保证。比如你要对一个 `atomic` 自增 1同时希望该操作之前和之后的读取或写入操作不会被重新排序
- **SeqCst** `SeqCst`就像是`AcqRel`的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到`SeqCst`的原子操作,线程中该`SeqCst`操作前的数据操作绝对不会被重新排在该`SeqCst`操作之后,且该`SeqCst`操作后的数据操作也绝对不会被重新排在`SeqCst`操作前。
- **SeqCst 顺序一致性** `SeqCst`就像是`AcqRel`的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到`SeqCst`的原子操作,线程中该`SeqCst`操作前的数据操作绝对不会被重新排在该`SeqCst`操作之后,且该`SeqCst`操作后的数据操作也绝对不会被重新排在`SeqCst`操作前。
这些规则由于是系统提供的,因此其它语言提供的相应规则也大同小异,大家如果不明白可以看看其它语言的相关解释。
@ -217,6 +217,11 @@ fn main() {
原则上,`Acquire`用于读取,而`Release`用于写入。但是由于有些原子操作同时拥有读取和写入的功能,此时就需要使用`AcqRel`来设置内存顺序了。在内存屏障中被写入的数据都可以被其它线程读取到不会有CPU缓存的问题。
**内存顺序的选择**
1. 不知道怎么选择时,优先使用`SeqCst`,虽然会稍微减慢速度,但是慢一点也比出现错误好
2. 多线程只计数`fetch_add`而不使用该值触发其他逻辑分支的简单使用场景,可以使用`Relaxed`
参考 [Which std::sync::atomic::Ordering to use?](https://stackoverflow.com/questions/30407121/which-stdsyncatomicordering-to-use)
## 多线程中使用Atomic

@ -216,9 +216,9 @@ fn main() {
据不精确估算创建一个线程大概需要0.24毫秒,随着线程的变多,这个值会变得更大,因此线程的创建耗时并不是不可忽略的,只有当真的需要处理一个值得用线程去处理的任务时,才使用线程。一些鸡毛蒜皮的任务,就无需创建线程了。
#### 创建多少线程合适
因为CPU的核心数限制当任务是密集型时就算线程数超过了CPU核心数也并不能帮你获得更好的性能因为每个线程的任务都可以轻松让CPU的某个核心跑满既然如此让线程数等于CPU核心数是最好的。
因为CPU的核心数限制当任务是CPU密集型时就算线程数超过了CPU核心数也并不能帮你获得更好的性能因为每个线程的任务都可以轻松让CPU的某个核心跑满既然如此让线程数等于CPU核心数是最好的。
但是当你的任务大部分时间都处于阻塞状态时就可以考虑增多线程数量这样当某个线程处于阻塞状态时会被切走进而运行其它的线程典型就是网络IO操作我们可以为每一个进来的用户连接创建一个线程去处理该连接绝大部分时间都是处于IO读取阻塞状态因此有限的CPU核心完全可以处理成百上千的用户连接线程但是事实上对于这种网络IO情况一般都不再使用多线程的方式了毕竟操作系统的线程数是有限的意味着并发数也很容易达到上限使用async/await的`M:N`并发模型,就没有这个烦恼。
但是当你的任务大部分时间都处于阻塞状态时就可以考虑增多线程数量这样当某个线程处于阻塞状态时会被切走进而运行其它的线程典型就是网络IO操作我们可以为每一个进来的用户连接创建一个线程去处理该连接绝大部分时间都是处于IO读取阻塞状态因此有限的CPU核心完全可以处理成百上千的用户连接线程但是事实上对于这种网络IO情况一般都不再使用多线程的方式了毕竟操作系统的线程数是有限的意味着并发数也很容易达到上限而且过多的线程也会导致线程上下文切换的代价过大,使用async/await的`M:N`并发模型,就没有这个烦恼。
#### 多线程的开销
下面的代码是一个无锁实现(CAS)的hashmap在多线程下的使用:
@ -333,7 +333,7 @@ FOO.with(|f| {
});
```
上面代码中,`FOO`即是我们创建的**线程局部变量**,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的`FOO`值彼此互不干扰。
上面代码中,`FOO`即是我们创建的**线程局部变量**,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的`FOO`值彼此互不干扰。注意`FOO`使用`static`声明为生命周期为`'static`的静态变量。
可以注意到,线程中对`FOO`的使用是通过借用的方式,但是若我们需要每个线程独自获取它的拷贝,最后进行汇总,就有些强人所难了。
@ -434,8 +434,8 @@ fn main() {
上述代码流程如下:
1. `main`线程首先进入`while`循环,并释放了锁`started`,然后开始挂起等待子线程的通知
2. 子线程获取到锁并将其修改为true, 然后调用条件的方法来通知主线程继续执行:`cvar.notify_one`
1. `main`线程首先进入`while`循环,调用`wait`方法挂起等待子线程的通知,并释放了锁`started`
2. 子线程获取到锁并将其修改为true, 然后调用条件变量的`notify_one`方法来通知主线程继续执行
## 只被调用一次的函数
有时,我们会需要某个函数在多线程环境下只被调用一次,例如初始化全局变量,无论是哪个线程先调用函数来初始化,都会保证全局变量只会被初始化一次,随后的其它线程调用就会忽略该函数:
@ -472,6 +472,14 @@ fn main() {
代码运行的结果取决于哪个线程先调用`INIT.call_once`(虽然代码具有先后顺序,但是线程的初始化顺序并无法被保证!因为线程初始化是异步的,且耗时较久),若`handle1`先,则输出`1`,否则输出`2`。
**call_once 方法**
执行初始化过程一次,并且只执行一次。
如果当前有另一个初始化过程正在运行,该方法将阻止调用的线程。
当这个函数返回时,保证一些初始化已经运行并完成,它还保证由执行的闭包所执行的任何内存写入都能被其他线程在这时可靠地观察到。
## 总结
[Rust的线程模型](./intro.md)是`1:1`模型因为Rust要保持尽量小的运行时。

@ -17,6 +17,14 @@ fn main() {
}
```
**常量与普通变量的区别**
- 关键字是`const`而不是`let`
- 定义常量必须指明类型如i32不能省略
- 定义常量时变量的命名规则一般是全部大写
- 常量可以在任意作用域进行定义,其生命周期贯穿整个程序的生命周期。编译时编译器会尽可能将其内联到代码中,所以在不同地方对同一常量的引用并不能保证引用到相同的内存地址
- 常量的赋值只能是常量表达式/数学表达式,也就是说必须是在编译期就能计算出的值,如果需要在运行时才能得出结果的值比如函数,则不能赋值给常量表达式
- 对于变量出现重复的定义(绑定)会发生变量遮盖,后面定义的变量会遮住前面定义的变量,常量则不允许出现重复的定义
#### 静态变量
静态变量允许声明一个全局的变量,常用于全局数据统计,例如我们希望用一个变量来统计程序当前的总请求数:
```rust
@ -33,6 +41,12 @@ Rust要求必须使用`unsafe`语句块才能访问和修改`static`变量,因
只有在同一线程内或者不在乎数据的准确性时,才应该使用全局静态变量。
和常量相同,定义静态变量的时候必须赋值为在编译期就可以计算出的值(常量表达式/数学表达式),不能是运行时才能计算出的值(如函数)
**静态变量和常量的区别**
- 静态变量不会被内联,在整个程序中,静态变量只有一个实例,所有的引用都会指向同一个地址
- 存储在静态变量中的值必须要实现Sync trait
#### 原子类型
想要全局计数器、状态控制等功能,又想要线程安全的实现,原子类型是非常好的办法。
@ -127,6 +141,8 @@ fn main() {
当然,使用`lazy_static`在每次访问静态变量时,会有轻微的性能损失,因为其内部实现用了一个底层的并发原语`std::sync::Once`,在每次访问该变量时,程序都会执行一次原子指令用于确认静态变量的初始化是否完成。
`lazy_static`宏,匹配的是`static ref`,所以定义的静态变量都是不可变引用
可能有读者会问,为何需要在运行期初始化一个静态变量,除了上面的全局锁,你会遇到最常见的场景就是:**一个全局的动态配置,它在程序开始后,才加载数据进行初始化,最终可以让各个线程直接访问使用**
再来看一个使用`lazy_static`实现全局缓存的例子:

Loading…
Cancel
Save