昨天我们学了智能指针
今天继续往下学
concurrency
)这里的并发指以下两个场景(后面除非单独说明,不然都指并发):
随着多核处理器的日益增多,并发也逐渐变得十分重要。
然而提高效率的同时,也增大了程序出错的概率。
rust
团队希望能改变这些问题,最开始,它们觉得内存安全问题和并发问题是两个独立的问题,准备采用两种不同的方法来处理它们,但是后面发现原来所有权规则(ownership
)和类型系统(type system
)太强了,对于解决上面这俩问题有极大的帮助。
所以他们借用类型判断(type checking
)和所有权规则将大多数并发问题暴露在编译阶段。
这样我们就能在开发的时候发现问题然后解决,不用等到线上出问题了再复现修复补上等等一系列操作,同时这么做也方面我们去重构,因为有问题都能在编译阶段发现。
rust
团队称之为:*fearless concurrency*
(我还以为是一句激励语呢。。)
高级语言中对于并发导致的问题一般都是独断的(dogmatic
)提供内部方法给开发者,而这就会导致问题变得比较抽象(abstract
)同时在性能上和低级语言存在着一定的差距。
而低级语言在性能上有优势的情况下,能允许开发者通过各种方式处理问题,这就意味着抽象的程度很低。
简单的说就是高级语言由于自己封装了处理问题的方法,开发者能用得爽但是比较抽象并且存在一定的性能问题,而低级语言没有这么多限制,不过这意味着开发者得自己想法子解决。
我们将会学习到以下几个知识:
message-passing-concurrency
): 如何在不同线程之间传递信息shared-state-concurrency
): 不同线程可以访问某一块数据Sync
和Send
这俩trait
,它们都在标准库里,可以允许我们自定义的类型拓展并发相关的功能threads
) 同时运行代码大家应该都很清楚我们的代码在绝大多数操作系统中都是运行在进程里的,操作系统会管理这些进程。
而在我们的代码中,我们可以单独让某一块代码独立运行来达到同时运行的效果,这种特性一般被叫做thread
,也就是线程。
比如一个web
服务器可以开多个线程来同时响应多个客户端的请求。
开多个线程可以提高运行的效率和性能,不过相对的复杂性也会提升很多。
毕竟代码都是同时运行的,没法保证顺序也没法知道哪个先结束。
一般我们会遇到以下几种情况:
race conditions
):由于线程是并行的,所以如果这几个线程依赖同一块数据的话那就有可能导致资源竞争的问题,毕竟不能保证顺序。deadlock
): 相信大家都很了解这个概念了。多个线程之间互相依赖对方的数据, 只有拿到对方的数据才能继续,这样就形成死锁了。bug
,并且场景很特殊很难复现和修复.说实话我没怎么遇到过....毕竟我目前是前端开发.....不过前端也有允许多线程的场景,比如web worker
等。
rust
希望能减少上面场景的触发,但是开发者不能依赖于rust
的编译器,还是得多思考多想。
大部分操作系统都会给出创建线程的api
,不过这些个api
又有各自的不同。所以rust
采取了1:1
模型(model
)的方式,也就是不同操作系统你需要实现的trait
不同,不过也有些crate
帮忙做了多操作系统的兼容,到时候要怎么用就看开发者自己了。
spawn
来创建新线程直接来看例子
我们勇thread::spawn
创建了一个新的线程,现在我们有两个线程,一个是main
,一个就是这个刚开的。
然后我们给这个新的线程传入了一个闭包
thread::sleep
会让当前运行的线程停止一小段时间,然后其它线程去跑。
我们cargo run
一下
可以看到每次输出的结果会有些许的不同。
并且你应该也注意到了,新开的线程里的代码并没有跑完,而是在main
也就是主线程被杀死之后也跟着结束了。
这里需要注意一点: 实际上main
主线程是最早运行的线程(这里只说我们自己开的线程),即使这个新开的线程比这个主线程先打印内容。
这当然不符合我们的预期,我们的预期是两个线程里的内容都打印输出,我们来看下要怎么改。
join Handles
来等待所有线程结束后再结束我们直接来看下代码
这里多了两处改动:
handle
变量里handle
调用了join
这个方法这个handle
变量的类型是一个JoinHandle
,暂时我们还不知道是个啥。
当我们调用join
这个方法之后,主线程就会等待这个子线程结束再结束。
我们来运行下看下结果
可以看到子线程的内容也都打印出来了,并且有些是在主线程打印完毕的时候才打印的。
这里有一点需要注意的: 它们的执行顺序还是无法预测的。
然后我们来试下把handle.join
这一行移到main
打印之前
可以看到是先等待子线程执行完再执行主线程的打印。
所以在哪里使用join handle
是会对代码执行有影响的。
之前我们学闭包的时候有说过, 如果想把闭包中引用的上下文数据的所有权移交出去,那么就可以用move
这个关键词。
我们之前学的时候就是用过,将闭包里的数据转交给其他线程
我们引用了上下文环境里的v
这个vector
,这时如果不用move
关键字转交所有权的话就会报错
毕竟编译器没法知道这个引用的数据所在的线程什么时候会被杀死。
所以我们需要用move
将引用数据的所有权移交给子线程。
当然,移交后主线程自然就没法再使用这个v
了.
message passing
)来转移不同线程之间的(transfer
)数据message passing
是一种能确保并行安全的方法,现在越来越流行了,它可以用于数据的分享。
很喜欢go
语言的一句话:“啊?!”(误)
在go
语言[7]文档中有这么一句话: Do not communicate by sharing memory; instead, share memory by communicating.
翻译过来也就是不要通过共享内存来通信,而是通过通信来共享内存。
为了实现通信传递,rust
标准库中实现了channels
,也就是通道。
通道是一种通用的编程概念,通过它可以把数据从一个线程发送到另一个线程 。
一个通道有两部分:
transmitter
): 数据发送的对象,可以存在多个。receiver
): 数据接收的对象,只能存在一个。当其中一个对象被drop
之后,这个channel
也就关闭了。
话不多说,直接上例子
channel
这个方法返回一个元组,元组元素第一个是发射器,第二个是接收器
需要我们手动引入标准库里的sync::mpsc
这个mpsc
是*multiple producer, single consumer*
的缩写。也就是前面说的允许多个发射器和只能有一个接收器。
不过现在这代码会报错
看源码我们也知道这个channel
是一个泛型,需要我们去确定对应的类型。
不过相信大家也想起来还有种方式可以不用定义类型,那就是直接使用,让编译器推断类型。
我们新开了一个线程,然后把这个发射器move
给了子线程,这里为什么要用move
相信大家也都清楚,就不多说了。
现在就不会报错了,即使我们只是使用了tx
,因为他俩的数据类型肯定是一样的。
我们调用发射器的send
方法把数据发射出去, send
这个过程是有可能发生错误的,比如没有接收器或者发射器被drop
了。
这里没有把源码全部放出来是因为现在我们还看不懂,所以只看下我们关注的类型就行了。
可以看到send
方法返回的是一个Result
的类型, 所以这里可以调用unwrap
做兜底处理。
然后我们在主线程调用接收器的recv
方法接收数据
接收数据的过程也是会有可能出错的,比如没有发射器,接受不到东西。
这回就成功接收到数据了。
然后我们还可以使用try_recv
来接收数据,它和recv
方法的区别在于:
recv
会等待发射器的数据,然后返回一个Result
类型的数据,当channel
被close
之后,就是Err
场景。
而try_recv
方法不会等待发射器,它会立即执行,有数据接收就返回Ok(V)
,没有的话就Err(e)
。
一般try_recv
会被用在当线程还有别的事情要处理的时候,这时就可以循环调用try_recv
,一边等待数据一边处理别的事情。
比如这样
channel
)和所有权转交(ownership transference
)在数据传递之间,ownership
也就是所有权扮演着重要的角色,因为所有权规则使得我们的并发代码变得安全可靠。
直接来看个例子
这段代码咋一看没什么问题,但是实际编译会报错
这个问题相信大家都很熟了, 这个val
的所有权在调用send
的方法之后就被转交了。
可以看到t
是全部转交的,为什么要这么做的缘由相信大家都清楚,还是内存安全那个老问题。
不过需要注意,这里是堆内存安全,栈的话直接就是copy
处理,是不会有问题的。
multiple values
)并看到接收器正在等待之前我们发射器只是发送了一个数据,然后接收器很快就拿到了,这期间看起来就像是直接打印一样,看不到不同线程之间的交流(感受不到你的想法~)。
直接来看例子
这回我们直接延迟一秒(from_secs
)发送数据,之前是一毫秒(from_millis
)。
这回应该看的很清楚了,我直接用的gif
图。
我们直接for
循环等待获取数据,现在的rx
是一个迭代器,当这个通道被close
了之后就会停止for
。
clone
发射器(transmitter
)来创建多个发送者(producer
)话不多说,直接上例子
我们调用发射器的clone
方法创建一个新的发射器tx1
然后我们开了两条子线程,分别发送数据给主线程.
为了方便辨别这两个线程的数据,我给tx1
的数据加上了1:
标识符。
可以看到两个子线程的数据都接收到了,当然还是老样子不能保证顺序。
share state concurrency
)上一章节提到的message-passing
是一种流行的安全并发方法。
而这一章的主题share-state
也是一种安全并发的方法。
还记得前面go
语言官方文档里提到的吗?
"不要通过内存共享来通信"
那么什么是通过内存共享来通信呢?
另外,为什么信息传递并发方法的爱好者会警告不要使用内存共享的方法呢?
(估计放到走近科学能播个五六集)
从某种程度上来说, 编程语言中的channel
也就是通道这个概念实际上和rust
中的单所有权的概念挺像的,毕竟数据的所有权会被转交给接收器。
但是,我们昨天学完smart pointer
也就是智能指针之后完全可以创建一个多重所有权的场景,所有同一个数据被多个线程共用的场景也是存在的。
那么就得做限制了,因为同时时间可能存在多个线程修改同一个数据。
mutexes
)来保证数据同一时刻只能由一个线程访问*Mutex
是mutual exclusion
*也就是互斥的缩写, 顾名思义就是同一时刻只能有一个线程来访问数据,然后给数据上锁,接着其它都被"斥"了。
为了能访问这个数据,线程需要先发送出我想获取互斥锁的请求。
lock
也就锁是mutex
中的一种数据结构,它可以跟踪到当前是谁访问这个数据。
因此,这个mutex
也可以被描述为: 通过lock system
来保护它所拥有的数据。
互斥锁是公认的难,因为你必须牢记以下的规则:
就像是一个小组讨论, 这里只有一个麦克风。任何人在发表看法之前都需要先获取麦克风,而获取麦克风之前还需要向组长申请使用麦克风的权限。然后麦克风到了你的手里,别人自然就不能发表看法了。再然后当你发表完了之后你得把麦克风归还到组长那里解除权限,这样其它组员就能再申请这个麦克风的权限了。
管理互斥锁会使得问题变得难以置信的棘手,当然我们这里看不出,毕竟只是demo
。但当你用在实际项目中之后你就能明白那些数据传递并发爱好者为什么会警告尽量不要使用内存共享这种方式来并发了。
不过基于rust
的所有权规则和类型系统,这个问题就变得简单些许。
话不多说,直接来看下例子
老规矩还得先从标准库中把这个Mutex
引入到当前上下文环境。
然后我们用Mutex::new
的方法创建一个互斥锁,而5
是这个锁保护的数据。
然后根据上面的规则1,我们在访问数据前调用了lock
这个方法来锁住数据,当然在尝试获取lock
的时候是有可能出错的,因为此时的锁可能还在别的线程手里。
lock
方法返回一个MutexGuard
的类型
然后通过解引用改变数据的值
看到这里结合之前的知识,大概率能猜到这个MutexGuard
的struct
实现了Deref
以及DerefMut
这两trait
果然看了下源码,发现了确实实现了这两个trait
。
扯远了,回到我们的代码,最后打印了下m
的值。
不过你应该发现了我们并没有unlock
这个锁,其实不用担心,我们用一个{}
包裹了这块代码,这个锁在超出作用域的时候就调用drop
方法自动释放了,所以它应该还实现了Drop
这个trait
可以看到这个drop
方法做了done
和unlock
操作。
那么我们可以确定这个MutexGuard
是一个智能指针,因为它实现了Drop
和Deref
这两个trait
。
那么基础用法我们已经清楚了,我们来搞个几个子线程试下
Mutex
来实现多个线程之间分享数据话不多说,直接看下例子
我们创建了十个子线程,然后让它们修改counter
这个互斥锁,让它加1
。
最后让主线程等待这些个子线程。
这代码咋一看无懈可击,但实际上是错的
这里我们用了move
,这就意味着这个数据的所有权进入了其中一个子线程里,即使解锁了其它线程也是拿不到的。
如果我们把move
去掉,那也不行,有内存安全的问题。
这咋办呢?
这时大家应该会想到用Rc
,让这个互斥锁存在多个拥有者。
直接来看下怎么改
但是还是报错了
这里有一句核心的提示: the trait Send is not omplemented for Rc>
也就是Rc
并没有实现Send
这个trait
。
这个Send
我们之后会学到,它能允许我们的自定义类型使用线程相关的功能。
还记得之前学Rc
时说过的一句话吗: Rc
只能用于单线程。
为什么呢?因为它并没有任何和并发相关的功能,它的计数可能会被其它线程干扰导致出现错误的计数。
现在我们需要一个类似Rc
又实现了Send
这个trait
的crate
。
Arc
Arc
看起来就和Rc
是亲戚, 它的全名是atomic reference counting
, 原子引用计数。
相信各位都清楚这个atomic
原子性是什么意思,然后你也可以看下这个文档: std::sync::atomic - Rust
目前我们暂时只需要知道这个atomic
是一个可用于不同线程之间数据安全分享的基本类型。
看到这里你应该会想: 既然这玩意儿这么好用,为啥不一开始就给标准库里的类型实现这个Arc
?
因为Arc
是牺牲部分性能来达到安全这一点的。
话不多说,我们来改下之前的代码
直接把之前的Rc
替换成Arc
即可。
这个Arc
也是在sync
这个crate
里的。
我们来run
一下
这回就正常了。
Refcell/Rc
和Mutex/Arc
刚我们已经见识到了Rc
和Arc
之间的区别。
而Mutex
我们也用过了,它lock
放回的类型是实现了DerefMut
这个trait
的,也就是允许我们去改变数据。
这一点和Refcell
是一样的(指的是可以改变不可变数据),另外他俩都是cell
家族成员。然后它还可以基于Arc
拓展成多重可变数据所有权。
而之前我们使用Refcell
搭配Rc
也能做到这一点。
区别在于两者一个是单线程一个是多线程。
前面说过Refcell
搭配Rc
会存在循环引用的情况导致内存泄漏。
而Mutex
搭配Arc
容易形成死锁(deadlock
)。
之前说过死锁是因为多个线程之前依赖于对方的数据导致这两个线程不能被杀死。
感兴趣的话可以去试下。
Sync
和Send
这俩trait
来实现可拓展并发(extensible concurrency
)学完前面和并发相关的知识,你应该(没)有注意到并发相关的功能特性都是由标准库提供的。
这就意味着rust
核心core
并没有多少并发相关的特性。
同时这也意味着我们可以自定义并发特性,它几乎不会被语言自身所束缚。
这也就是前面说过的低抽象高自由以及性能好,当然相对的我们得写很多东西,不能和其它语言一样直接调用api
完事。 当然,我们也可以去找别的大佬写的library
。
不过这有两个并发相关的概念是深入到rust
代码中的: 来自std::marker
的Sync
和Send
。
Send
来实现不同线程之间数据所有权的转交如果数据的类型实现了Send
这个trait
,那么它就能在不同线程之间实现所有权转交。
rust
提供的所有类型几乎都能实现Send
这个trait
,但是还有一小部分不行,就是前面提到过的使用了Rc
的类型不能实现Send
。
前面我们说过的Rc
只能用于单线程是因为可能存在被其它线程干扰导致计数出错的问题,但是没有解释为什么会有干扰。
这里给出解释: 如果你使用了Rc
来创建多个所有权,然后把它们分别派发到各个子线程里,这个转交的过程会导致各个线程同时更新这个计数,这个时候就存在问题了。
在rust
的trait bound
下,这个问题会被阻止在编译阶段(还不说声多谢rust
哥?)。
如果某个类型完全是由Send
这个trait
实现的,那它也会被标记为Send
。几乎所有的基础类型都是Send
,除了原始指针(raw pointer
)。
Sync
来允许多个线程的访问这个trait
会把你这个数据的类型标记为Sync
,表示安全的,这样其它线程接收的时候也是安全的。
换句话说,任何类型如果被标记为Send
,那它的不可变引用都是Sync
。毕竟你都允许转交所有权了,自然是safe
的。
和Send
一样,基础类型都是Sync
的,任何有Send
组成的类型也都是Sync
。
这里再把Rc
拉出来鞭尸, 它是非Sync
,因为它是非Send
。
另外Refcell
和它的家族cell
成员都是非Sync
的。
为啥呢?因为它们是运行在runtime
的,所以是not thread-safe
。
而Mutex
是safe
的,所以它是Sync
Send
和Sync
是不安全的因为由Send
和Sync
组成的类型自身也会被标记为Send
和Sync
,我们并不需要手动去实现这两个trait
。
作为marker trait
,它们甚至没有需要实现的method
,它们只是用来把invariants
(不变量?)和并发关联起来而已。
手动实现它们会涉及到rust
的unsafe code
,我们会在后面学到unsafe code
。
目前我们只需要知道如果实现并发不基于Sync
和Send
,那么你就需要去确保安全才行。
今天我们学了和并发相关的知识,知道了message passing
和share state
两种安全并发的方式。
以及Send
和Sync
这两个trait
的介绍。
rust
的所有权规则和类型系统能将很多runtime
的问题在编译阶段就暴露出来,这使得我们的代码会安全很多。
谢谢Rust哥
发布于 2023-01-04 17:13・IP 属地广东