pull/467/head
sunface 3 years ago
commit 055987ca06

@ -4,14 +4,14 @@
并行和并发其实并不难,但是也给一些用户造成了困扰,因此我们专门开辟一个章节,用于讲清楚这两者的区别。
`Erlang` 之父[`Joe Armstrong`](https://en.wikipedia.org/wiki/Joe_Armstrong_(programmer))(伟大的异步编程先驱,开创一个时代的殿堂级计算机科学家,我还犹记得当年刚学到 `Erlang` 时的震撼respect用一张5岁小孩都能看到的图片解释了并发与并行的区别
`Erlang` 之父 [`Joe Armstrong`](https://en.wikipedia.org/wiki/Joe_Armstrong_(programmer))(伟大的异步编程先驱,开创一个时代的殿堂级计算机科学家,我还犹记得当年刚学到 `Erlang` 时的震撼respect用一张5岁小孩都能看到的图片解释了并发与并行的区别
<img alt="" src="https://pic1.zhimg.com/80/f37dd89173715d0e21546ea171c8a915_1440w.png" class="center" />
上图很直观的体现了:
- **并发(Concurrent)** 是多个队列使用同一个咖啡机然后两个队列轮换着使用未必是1:1轮换也可能是其它轮换规则最终每个人都能接到咖啡
- **并发(Concurrent)** 是多个队列使用同一个咖啡机,然后两个队列轮换着使用(未必是 1:1 轮换,也可能是其它轮换规则),最终每个人都能接到咖啡
- **并行(Parallel)** 是每个队列都拥有一个咖啡机,最终也是每个人都能接到咖啡,但是效率更高,因为同时可以有两个人在接咖啡
当然,我们还可以对比下串行:只有一个队列且仅使用一台咖啡机,哪怕前面那个人接咖啡时突然发呆了几分钟,后面的人也只能等他结束才能继续接。可能有读者有疑问了,从图片来看,并发也存在这个问题啊,前面的人发呆了几分钟不接咖啡怎么办?很简单,另外一个队列的人把他推开就行了,自己队友不能在背后开枪,但是其它队的可以:)
@ -19,17 +19,17 @@
在正式开始之前,先给出一个结论:**并发和并行都是对“多任务”处理的描述,其中并发是轮流处理,而并行是同时处理**。
## CPU 多核
现在的个人计算机动辄拥有十来个核心(M1 Max/Intel 12代),如果使用串行的方式那真是太低调了,因此我们把各种任务简单分成多个队列,每个队列都交给一个 CPU 核心去执行,当某个 CPU 核心没有任务时,它还能去其它核心的队列中偷任务(真·老黄牛),这样就实现了并行化处理。
现在的个人计算机动辄拥有十来个核心M1 Max/Intel 12代,如果使用串行的方式那真是太低调了,因此我们把各种任务简单分成多个队列,每个队列都交给一个 CPU 核心去执行,当某个 CPU 核心没有任务时,它还能去其它核心的队列中偷任务(真·老黄牛),这样就实现了并行化处理。
#### 单核心并发
那问题来了,在早期只有一个 CPU 核心时,我们的任务是怎么处理的呢?其实聪明的读者应该已经想到,是的,并发解君愁。当然,这里还得提到操作系统的多线程,正是操作系统多线程 + CPU核心才实现了现代化的多任务操作系统。
那问题来了,在早期只有一个 CPU 核心时,我们的任务是怎么处理的呢?其实聪明的读者应该已经想到,是的,并发解君愁。当然,这里还得提到操作系统的多线程,正是操作系统多线程 + CPU 核心,才实现了现代化的多任务操作系统。
在 OS 级别,多线程负责管理我们的任务队列,你可以简单认为一个线程管理着一个任务队列,然后线程之间还能根据空闲度进行任务调度。我们的程序只会跟 OS 线程打交道,并不关心 CPU 到底有多少个核心,真正关心的只是 OS当线程把任务交给 CPU 核心去执行时,如果只有一个 CPU 核心,那么它就只能同时处理一个任务。
相信大家都看出来了:**CPU 核心**对应的是上图的咖啡机,而**多个线程的任务队列**就对应的多个排队的队列,由于终受限于 CPU 核心数,每个队列每次只会有一个任务被处理。
和排队一样,假如某个任务执行时间过长,就会导致用户界面的假死(相信使用 Windows 的同学或多或少都碰到过假死的问题) 那么就需要 CPU 的任务调度了(真实 CPU 的调度很复杂,我们这里做了简化),有一个调度器会按照某些条件从队列中选择任务进行执行,并且当一个任务执行时间过长时,会强行切换该任务到后台中(或者放入任务队列,真实情况很复杂!),去执行新的任务。
和排队一样,假如某个任务执行时间过长,就会导致用户界面的假死(相信使用 Windows 的同学或多或少都碰到过假死的问题), 那么就需要 CPU 的任务调度了(真实 CPU 的调度很复杂,我们这里做了简化),有一个调度器会按照某些条件从队列中选择任务进行执行,并且当一个任务执行时间过长时,会强行切换该任务到后台中(或者放入任务队列,真实情况很复杂!),去执行新的任务。
不断这样的快速任务切换,对用户而言就实现了表面上的多任务同时处理,但是实际上最终也只有一个 CPU 核心在不停的工作。
@ -37,12 +37,12 @@
#### 多核心并行
当 CPU 核心增多到 `N` 时,那么同一时间就能有 `N` 个任务被处理,那么我们的并行度就是 `N`,相应的处理效率也变成了单核心的 `N`(实际情况并没有这么高)
当 CPU 核心增多到 `N` 时,那么同一时间就能有 `N` 个任务被处理,那么我们的并行度就是 `N`,相应的处理效率也变成了单核心的 `N`(实际情况并没有这么高)
#### 多核心并发
当核心增多到 `N` 时,操作系统同时在进行的任务肯定远不止 `N` 个,这些任务将被放入 `M` 个线程队列中,接着交给 `N` 个CPU核心去执行最后实现了 `M:N` 的处理模型,在这种情况下,**并发跟并行时同时在发生的,所有用户任务从表面来看都在并发的运行,其实实际上,同一时刻只有 `N` 个任务能被同时并行的处理**。
看到这里,相信大家已经明白两者的区别,那么我们下面给出一个正式的定义(该定义摘选自<<并发的艺术>>)
看到这里,相信大家已经明白两者的区别,那么我们下面给出一个正式的定义(该定义摘选自<<并发的艺术>>
## 正式的定义
@ -57,14 +57,14 @@
如果大家学过其它语言的多线程,可能就知道不同语言对于线程的实现可能大相径庭:
- 由于操作系统提供了创建线程的 API因此部分语言会直接调用该 API 来创建线程,因此最终程序内的线程数和该程序占用的操作系统线程数相等,一般称之为**1:1 线程模型**,例如 Rust。
- 还有些语言在内部实现了自己的线程模型(绿色线程、协程),程序内部的 M 个线程最后会以某种映射方式使用 N 个操作系统线程去运行,因此称之为**M:N 线程模型**,其中 M 和 N 并没有特定的彼此限制关系。一个典型的代表就是 Go 语言。
- 还有些语言在内部实现了自己的线程模型(绿色线程、协程),程序内部的 M 个线程最后会以某种映射方式使用 N 个操作系统线程去运行,因此称之为**M:N 线程模型**,其中 M 和 N 并没有特定的彼此限制关系。一个典型的代表就是 Go 语言。
- 还有些语言使用了 Actor 模型,基于消息传递进行并发,例如 Erlang 语言。
总之,每一种模型都有其优缺点及选择上的权衡,而 Rust 在设计时考虑的权衡就是运行时(Runtime)。出于 Rust 的系统级使用场景,且要保证调用 C 时的极致性能,它最终选择了尽量小的运行时实现。
> 运行时是那些会被打包到所有程序可执行文件中的 Rust 代码,根据每个语言的设计权衡,运行时虽然有大有小(例如 Go 语言由于实现了协程和 GC运行时相对就会更大一些),但是除了汇编之外,每个语言都拥有它。小运行时的其中一个好处在于最终编译出的可执行文件会相对较小,同时也让该语言更容易被其它语言引入使用。
> 运行时是那些会被打包到所有程序可执行文件中的 Rust 代码,根据每个语言的设计权衡,运行时虽然有大有小(例如 Go 语言由于实现了协程和 GC运行时相对就会更大一些,但是除了汇编之外,每个语言都拥有它。小运行时的其中一个好处在于最终编译出的可执行文件会相对较小,同时也让该语言更容易被其它语言引入使用。
而绿色线程/协程的实现会显著增大运行时的大小,因此 Rust 只在标准库中提供了 `1:1` 的线程模型,如果你愿意牺牲一些性能来换取更精确的线程控制以及更小的线程上下文切换成本,那么可以选择 `Rust` 中的 `M:N` 模型,这些模型由三方库提供了实现,例如大名鼎鼎的 `tokio`
而绿色线程/协程的实现会显著增大运行时的大小,因此 Rust 只在标准库中提供了 `1:1` 的线程模型,如果你愿意牺牲一些性能来换取更精确的线程控制以及更小的线程上下文切换成本,那么可以选择 Rust 中的 `M:N` 模型,这些模型由三方库提供了实现,例如大名鼎鼎的 `tokio`
在了解了并发和并行后,我们可以正式开始 Rust 的多线程之旅。

@ -3,6 +3,6 @@
可惜的是,在 Rust 中由于语言设计理念、安全、性能的多方面考虑,并没有采用 Go 语言大道至简的方式,而是选择了多线程与 `async/await` 相结合,优点是可控性更强、性能更高,缺点是复杂度并不低,当然这也是系统级语言的应有选择:**使用复杂度换取可控性和性能**。
不过,大家也不用担心,本书的目标就是降低 Rust 使用门槛,这个门槛自然也包括如何在 Rust 中进行异步并发编程,我们将从多线程以及 async/await两个方面去深入浅出地讲解首先从本章的多线程开始。
不过,大家也不用担心,本书的目标就是降低 Rust 使用门槛,这个门槛自然也包括如何在 Rust 中进行异步并发编程,我们将从多线程以及 `async/await` 两个方面去深入浅出地讲解,首先,从本章的多线程开始。
在本章,我们将深入讲解并发和并行的区别以及如何使用多线程进行 Rust 并发编程,那么先来看看何为并行与并发。

@ -1,7 +1,7 @@
# Cell 和 RefCell
Rust 的编译器之严格可以说是举世无双。特别是在所有权方面Rust 通过严格的规则来保证所有权和借用的正确性,最终为程序的安全保驾护航。
但是严格是一把双刃剑,带来安全提升的同时,损失了灵活性,有时甚至会让用户痛苦不堪、怨声载道。因此 Rust 提供了 `Cell``RefCell` 用于内部可变性,简而言之,可以在拥有不可变引用的同时修改目标数据,对于正常的代码实现来说,这个是不可能做到的(要么一个可变借用,要么多个不可变借用)
但是严格是一把双刃剑,带来安全提升的同时,损失了灵活性,有时甚至会让用户痛苦不堪、怨声载道。因此 Rust 提供了 `Cell``RefCell` 用于内部可变性,简而言之,可以在拥有不可变引用的同时修改目标数据,对于正常的代码实现来说,这个是不可能做到的(要么一个可变借用,要么多个不可变借用)
> 内部可变性的实现是因为 Rust 使用了 `unsafe` 来做到这一点,但是对于使用者来说,这些都是透明的,因为这些不安全代码都被封装到了安全的 API 中
@ -14,7 +14,7 @@ fn main() {
let one = c.get();
c.set("qwer");
let two = c.get();
println!("{},{}", one,two);
println!("{},{}", one, two);
}
```
@ -57,7 +57,7 @@ fn main() {
let s1 = s.borrow();
let s2 = s.borrow_mut();
println!("{},{}",s1,s2);
println!("{},{}", s1, s2);
}
```
@ -76,7 +76,7 @@ note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
`RefCell` 正是**用于你确信代码是正确的,而编译器却发生了误判时**。
对于大型的复杂程序,也可以选择使用 `RefCell` 来让事情简化。例如在 Rust 编译器的[`ctxt结构体`](https://github.com/rust-lang/rust/blob/620d1ee5346bee10ba7ce129b2e20d6e59f0377d/src/librustc/middle/ty.rs#L803-L987)中有大量的 `RefCell` 类型的 `map` 字段, 主要的原因是:这些 `map` 会被分散在各个地方的代码片段所广泛使用或修改。由于这种分散在各处的使用方式,导致了管理可变和不可变成为一件非常复杂的任务(甚至不可能),你很容易就碰到编译器抛出来的各种错误。而且 `RefCell` 的运行时错误在这种情况下也变得非常可爱:一旦有人做了不正确的使用,代码会 `panic`,然后告诉我们哪些借用冲突了。
对于大型的复杂程序,也可以选择使用 `RefCell` 来让事情简化。例如在 Rust 编译器的[`ctxt结构体`](https://github.com/rust-lang/rust/blob/620d1ee5346bee10ba7ce129b2e20d6e59f0377d/src/librustc/middle/ty.rs#L803-L987)中有大量的 `RefCell` 类型的 `map` 字段主要的原因是:这些 `map` 会被分散在各个地方的代码片段所广泛使用或修改。由于这种分散在各处的使用方式,导致了管理可变和不可变成为一件非常复杂的任务(甚至不可能),你很容易就碰到编译器抛出来的各种错误。而且 `RefCell` 的运行时错误在这种情况下也变得非常可爱:一旦有人做了不正确的使用,代码会 `panic`,然后告诉我们哪些借用冲突了。
总之,当你确信编译器误报但不知道该如何解决时,或者你有一个引用类型,需要被四处使用和修改然后导致借用关系难以管理时,都可以优先考虑使用 `RefCell`
@ -149,7 +149,7 @@ struct MsgQueue {
}
impl Messenger for MsgQueue {
fn send(&self,msg: String) {
fn send(&self, msg: String) {
self.msg_cache.push(msg)
}
}
@ -229,7 +229,7 @@ RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
#### 性能损耗
相信这两者组合在一起使用时,很多人会好奇到底性能如何,下面我们来简单分析下。
首先给出一个大概的结论,这两者结合在一起使用的性能其实非常高,大致相当于没有线程安全版本的 C++ `std::shared_ptr` 指针,事实上,`C++` 这个指针的主要开销也在于原子性这个并发原语上,毕竟线程安全在哪个语言中开销都不小。
首先给出一个大概的结论,这两者结合在一起使用的性能其实非常高,大致相当于没有线程安全版本的 C++ `std::shared_ptr` 指针事实上C++ 这个指针的主要开销也在于原子性这个并发原语上,毕竟线程安全在哪个语言中开销都不小。
#### 内存损耗
两者结合的数据结构与下面类似:
@ -252,28 +252,28 @@ struct Wrapper<T> {
#### CPU 损耗
从CPU来看损耗如下
- 对 `Rc<T>` 解引用是免费的(编译期),但是*带来的间接取值并不免费
- 克隆 `Rc<T>` 需要将当前的引用计数跟 `0``usize::Max` 进行一次比较然后将计数值加1
- 释放 (drop)`Rc<T>` 需要将计数值减1 然后跟 `0` 进行一次比较
- 对 `Rc<T>` 解引用是免费的(编译期),但是 `*` 带来的间接取值并不免费
- 克隆 `Rc<T>` 需要将当前的引用计数跟 `0``usize::Max` 进行一次比较,然后将计数值加 1
- 释放drop `Rc<T>` 需要将计数值减 1 然后跟 `0` 进行一次比较
- 对 `RefCell` 进行不可变借用,需要将 `isize` 类型的借用计数加1然后跟 `0` 进行比较
- 对 `RefCell `的不可变借用进行释放,需要将 `isize` 减1
- 对 `RefCell` 的可变借用大致流程跟上面差不多,但是需要先跟 `0` 比较然后再减1
- 对 `RefCell` 的可变借用进行释放,需要将 `isize` 加1
- 对 `RefCell `的不可变借用进行释放,需要将 `isize` 1
- 对 `RefCell` 的可变借用大致流程跟上面差不多,但是需要先跟 `0` 比较,然后再减 1
- 对 `RefCell` 的可变借用进行释放,需要将 `isize` 1
其实这些细节不必过于关注,只要知道 `CPU` 消耗也非常低,甚至编译器还会对此进行进一步优化!
其实这些细节不必过于关注,只要知道 CPU 消耗也非常低,甚至编译器还会对此进行进一步优化!
#### CPU 缓存 Miss
唯一需要担心的可能就是这种组合数据结构对于 `CPU` 缓存是否亲和,这个我们无法证明,只能提出来存在这个可能性,最终的性能影响还需要在实际场景中进行测试。
唯一需要担心的可能就是这种组合数据结构对于 CPU 缓存是否亲和,这个我们无法证明,只能提出来存在这个可能性,最终的性能影响还需要在实际场景中进行测试。
总之,分析这两者组合的性能还挺复杂的,大概总结下:
- 从表面来看,它们带来的内存和 CPU 损耗都不大
- 但是由于 `Rc` 额外的引入了一次间接取值(*),在少数场景下可能会造成性能上的显著损失
- 但是由于 `Rc` 额外的引入了一次间接取值`*`,在少数场景下可能会造成性能上的显著损失
- CPU 缓存可能也不够亲和
## 通过 `Cell::from_mut` 解决借用冲突
在 Rust1.37 版本中新增了两个非常实用的方法:
在 Rust 1.37 版本中新增了两个非常实用的方法:
- Cell::from_mut该方法将 `&mut T` 转为 `&Cell<T>`
- Cell::as_slice_of_cells该方法将 `&Cell<[T]>` 转为 `&[Cell<T>]`

@ -32,7 +32,7 @@ fn main() {
let b = Rc::clone(&a);
assert_eq!(2, Rc::strong_count(&a));
assert_eq!(Rc::strong_count(&a),Rc::strong_count(&b))
assert_eq!(Rc::strong_count(&a), Rc::strong_count(&b))
}
```
@ -114,12 +114,15 @@ fn main() {
drop(gadget_owner);
// 尽管在上面我们释放了 gadget_owner但是依然可以在这里使用 owner 的信息
// 原因是在 drop 之前,存在三个指向 Gadget Man 的智能指针引用,上面仅仅 drop 掉其中一个智能指针引用,而不是 drop 掉 owner 数据,外面还有两个引用指向底层的 owner 数据,引用计数尚未清零
// 原因是在 drop 之前,存在三个指向 Gadget Man 的智能指针引用,上面仅仅
// drop 掉其中一个智能指针引用,而不是 drop 掉 owner 数据,外面还有两个
// 引用指向底层的 owner 数据,引用计数尚未清零
// 因此 owner 数据依然可以被使用
println!("Gadget {} owned by {}", gadget1.id, gadget1.owner.name);
println!("Gadget {} owned by {}", gadget2.id, gadget2.owner.name);
// 在函数最后,`gadget1` 和 `gadget2` 也被释放,最终引用计数归零,随后底层数据也被清理释放
// 在函数最后,`gadget1` 和 `gadget2` 也被释放,最终引用计数归零,随后底层
// 数据也被清理释放
}
```
@ -145,7 +148,7 @@ fn main() {
for _ in 0..10 {
let s = Rc::clone(&s);
let handle = thread::spawn(move || {
println!("{}",s)
println!("{}", s)
});
}
}
@ -182,7 +185,7 @@ fn main() {
for _ in 0..10 {
let s = Arc::clone(&s);
let handle = thread::spawn(move || {
println!("{}",s)
println!("{}", s)
});
}
}

@ -427,7 +427,7 @@ impl SimpleFuture for SocketRead<'_> {
关于第二点,其中一个简单粗暴的方法就是使用一个新线程不停的检查 `socket` 中是否有了数据,当有了后,就调用 `wake()` 函数。该方法确实可以满足需求,但是性能着实太低了,需要为每个阻塞的 `Future` 都创建一个单独的线程!
在现实世界中,该问题往往是通过操作系统提供的 `IO` 多路复用机制来完成,例如 `Linux` 中的 **`epoll`**`FreeBSD` 和 `MacOS` 中的 **`kqueue`** `Windows` 中的 **`IOCP`**, `Fuchisa`中的 **`ports`** 等(可以通过 Rust 的跨平台包 `mio` 来使用它们)。借助IO多路复用机制可以实现一个线程同时阻塞地去等待多个异步IO事件一旦某个事件完成就立即退出阻塞并返回数据。相关实现类似于以下代码
在现实世界中,该问题往往是通过操作系统提供的 `IO` 多路复用机制来完成,例如 `Linux` 中的 **`epoll`**`FreeBSD` 和 `macOS` 中的 **`kqueue`** `Windows` 中的 **`IOCP`**, `Fuchisa`中的 **`ports`** 等(可以通过 Rust 的跨平台包 `mio` 来使用它们)。借助IO多路复用机制可以实现一个线程同时阻塞地去等待多个异步IO事件一旦某个事件完成就立即退出阻塞并返回数据。相关实现类似于以下代码
```rust
struct IoBlocker {
/* ... */

@ -91,7 +91,7 @@ error: a bin target must be available for `cargo run`
- 默认二进制包:`src/main.rs`,编译后生成的可执行文件与`package`同名
- 其余二进制包:`src/bin/main1.rs` 和 `src/bin/main2.rs`,它们会分别生成一个文件同名的二进制可执行文件
- 集成测试文件:`tests` 目录下
- 性能测试benchmark文件`benches` 目录下
- 基准性能测试 `benchmark` 文件:`benches` 目录下
- 项目示例:`examples` 目录下

@ -10,7 +10,7 @@
下面继续简单介绍下 VSCode以下内容引用于官网
> Visual Studio Code(VSCode) 是微软 2015 年推出的一个轻量但功能强大的源代码编辑器,基于 Electron 开发,支持 Windows、Linux 和 MacOS 操作系统。它内置了对 JavaScriptTypeScript 和 Node.js 的支持并且具有丰富的其它语言和扩展的支持功能超级强大。Visual Studio Code 是一款免费开源的现代化轻量级代码编辑器,支持几乎所有主流的开发语言的语法高亮、智能代码补全、自定义快捷键、括号匹配和颜色区分、代码片段、代码对比 Diff、GIT 命令等特性,支持插件扩展,并针对网页开发和云端应用开发做了优化。
> Visual Studio Code(VSCode) 是微软 2015 年推出的一个轻量但功能强大的源代码编辑器,基于 Electron 开发,支持 Windows、Linux 和 macOS 操作系统。它内置了对 JavaScriptTypeScript 和 Node.js 的支持并且具有丰富的其它语言和扩展的支持功能超级强大。Visual Studio Code 是一款免费开源的现代化轻量级代码编辑器,支持几乎所有主流的开发语言的语法高亮、智能代码补全、自定义快捷键、括号匹配和颜色区分、代码片段、代码对比 Diff、GIT 命令等特性,支持插件扩展,并针对网页开发和云端应用开发做了优化。
## 安装 VSCode 的 Rust 插件

@ -9,7 +9,7 @@
至于版本,现在 Rust 稳定版特性越来越全了,因此下载最新稳定版本即可。由于你用的 Rust 版本可能跟本书写作时不一样,一些编译错误和警告可能也会有所不同。
## 在 Linux 或 MacOS 上安装 `rustup`
## 在 Linux 或 macOS 上安装 `rustup`
打开终端并输入下面命令:
@ -31,7 +31,7 @@ OK这样就已经完成 Rust 安装啦。
Rust 对运行环境的依赖和 Go 语言很像几乎所有环境都可以无需安装任何依赖直接运行。但是Rust 会依赖 `libc` 和链接器 `linker`。所以如果遇到了提示链接器无法执行的错误,你需要再手动安装一个 C 语言编译器:
**MacOS 下:**
**macOS 下:**
```console
$ xcode-select --install

@ -4,7 +4,7 @@
在本章中,你将学习以下内容:
1. 在 MacOS、Linux、Windows 上安装 Rust 以及相关工具链
1. 在 macOS、Linux、Windows 上安装 Rust 以及相关工具链
2. 搭建 VSCode 所需的环境
3. 简单介绍 Cargo
4. 实现一个酷炫多国语言版本的“世界,你好”的程序,并且谈谈对 Rust 语言的初印象

@ -3,7 +3,7 @@
## 开始使用
_注意: 如果你在使用 MacOS确保已经安装了 Xcode 以及相应的开发者工具 `xcode-select --install`._
_注意: 如果你在使用 macOS确保已经安装了 Xcode 以及相应的开发者工具 `xcode-select --install`._
同时你也需要安装Rust具体参见<<精通Rust编程>>一书或者访问https://rustup.rs。

Loading…
Cancel
Save