坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.1.4

前言

昨天我们学了智能指针

坏蛋Dan:rust基础学习--day33

今天继续往下学


无畏并发(concurrency)

这里的并发指以下两个场景(后面除非单独说明,不然都指并发):

  1. 并发:程序的不同部分独立执行
  2. 并行:程序的不同部分同时执行

随着多核处理器的日益增多,并发也逐渐变得十分重要。

然而提高效率的同时,也增大了程序出错的概率。

rust团队希望能改变这些问题,最开始,它们觉得内存安全问题和并发问题是两个独立的问题,准备采用两种不同的方法来处理它们,但是后面发现原来所有权规则(ownership)和类型系统(type system)太强了,对于解决上面这俩问题有极大的帮助。

所以他们借用类型判断(type checking)和所有权规则将大多数并发问题暴露在编译阶段。

这样我们就能在开发的时候发现问题然后解决,不用等到线上出问题了再复现修复补上等等一系列操作,同时这么做也方面我们去重构,因为有问题都能在编译阶段发现。

rust团队称之为:*fearless concurrency* (我还以为是一句激励语呢。。)

高级语言中对于并发导致的问题一般都是独断的(dogmatic)提供内部方法给开发者,而这就会导致问题变得比较抽象(abstract)同时在性能上和低级语言存在着一定的差距。

而低级语言在性能上有优势的情况下,能允许开发者通过各种方式处理问题,这就意味着抽象的程度很低。

简单的说就是高级语言由于自己封装了处理问题的方法,开发者能用得爽但是比较抽象并且存在一定的性能问题,而低级语言没有这么多限制,不过这意味着开发者得自己想法子解决。

我们将会学习到以下几个知识:

  1. 如何通过创建线程来实现同时运行多块代码
  2. 消息传递并发(message-passing-concurrency): 如何在不同线程之间传递信息
  3. 状态共享并发(shared-state-concurrency): 不同线程可以访问某一块数据
  4. SyncSend这俩trait,它们都在标准库里,可以允许我们自定义的类型拓展并发相关的功能

使用线程(threads) 同时运行代码

大家应该都很清楚我们的代码在绝大多数操作系统中都是运行在进程里的,操作系统会管理这些进程。

而在我们的代码中,我们可以单独让某一块代码独立运行来达到同时运行的效果,这种特性一般被叫做thread,也就是线程。

比如一个web服务器可以开多个线程来同时响应多个客户端的请求。

开多个线程可以提高运行的效率和性能,不过相对的复杂性也会提升很多。

毕竟代码都是同时运行的,没法保证顺序也没法知道哪个先结束。

一般我们会遇到以下几种情况:

  1. 资源竞争(race conditions):由于线程是并行的,所以如果这几个线程依赖同一块数据的话那就有可能导致资源竞争的问题,毕竟不能保证顺序。
  2. 死锁(deadlock): 相信大家都很了解这个概念了。多个线程之间互相依赖对方的数据, 只有拿到对方的数据才能继续,这样就形成死锁了。
  3. 业务中遇到的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来等待所有线程结束后再结束

我们直接来看下代码

这里多了两处改动:

  1. 我们获取了子线程的返回值并存储到handle变量里
  2. 我们在程序最后一行给这个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,也就是通道。

通道是一种通用的编程概念,通过它可以把数据从一个线程发送到另一个线程 。

一个通道有两部分:

  1. 发射器(transmitter): 数据发送的对象,可以存在多个。
  2. 接收器(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类型的数据,当channelclose之后,就是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)来保证数据同一时刻只能由一个线程访问

*Mutexmutual exclusion*也就是互斥的缩写, 顾名思义就是同一时刻只能有一个线程来访问数据,然后给数据上锁,接着其它都被"斥"了。

为了能访问这个数据,线程需要先发送出我想获取互斥锁的请求。

lock也就锁是mutex中的一种数据结构,它可以跟踪到当前是谁访问这个数据。

因此,这个mutex也可以被描述为: 通过lock system来保护它所拥有的数据。

互斥锁是公认的难,因为你必须牢记以下的规则:

  1. 在访问使用数据之前, 你必须先获尝试得互斥锁
  2. 使用完数据之后必须解锁,这样其它线程才能访问

就像是一个小组讨论, 这里只有一个麦克风。任何人在发表看法之前都需要先获取麦克风,而获取麦克风之前还需要向组长申请使用麦克风的权限。然后麦克风到了你的手里,别人自然就不能发表看法了。再然后当你发表完了之后你得把麦克风归还到组长那里解除权限,这样其它组员就能再申请这个麦克风的权限了。

管理互斥锁会使得问题变得难以置信的棘手,当然我们这里看不出,毕竟只是demo。但当你用在实际项目中之后你就能明白那些数据传递并发爱好者为什么会警告尽量不要使用内存共享这种方式来并发了。

不过基于rust的所有权规则和类型系统,这个问题就变得简单些许。

Mutex

话不多说,直接来看下例子

老规矩还得先从标准库中把这个Mutex引入到当前上下文环境。

然后我们用Mutex::new的方法创建一个互斥锁,而5是这个锁保护的数据。

然后根据上面的规则1,我们在访问数据前调用了lock这个方法来锁住数据,当然在尝试获取lock的时候是有可能出错的,因为此时的锁可能还在别的线程手里。

lock方法返回一个MutexGuard的类型

然后通过解引用改变数据的值

看到这里结合之前的知识,大概率能猜到这个MutexGuardstruct实现了Deref以及DerefMut这两trait

果然看了下源码,发现了确实实现了这两个trait

扯远了,回到我们的代码,最后打印了下m的值。

不过你应该发现了我们并没有unlock这个锁,其实不用担心,我们用一个{}包裹了这块代码,这个锁在超出作用域的时候就调用drop方法自动释放了,所以它应该还实现了Drop这个trait

可以看到这个drop方法做了doneunlock操作。

那么我们可以确定这个MutexGuard是一个智能指针,因为它实现了DropDeref这两个trait

那么基础用法我们已经清楚了,我们来搞个几个子线程试下


使用Mutex来实现多个线程之间分享数据

话不多说,直接看下例子

我们创建了十个子线程,然后让它们修改counter这个互斥锁,让它加1

最后让主线程等待这些个子线程。

这代码咋一看无懈可击,但实际上是错的

这里我们用了move,这就意味着这个数据的所有权进入了其中一个子线程里,即使解锁了其它线程也是拿不到的。

如果我们把move去掉,那也不行,有内存安全的问题。

这咋办呢?

这时大家应该会想到用Rc,让这个互斥锁存在多个拥有者。


使用多重所有权来支持多个线程共享数据

直接来看下怎么改

但是还是报错了

这里有一句核心的提示: the trait Send is not omplemented for Rc>

也就是Rc并没有实现Send这个trait

这个Send我们之后会学到,它能允许我们的自定义类型使用线程相关的功能。

还记得之前学Rc时说过的一句话吗: Rc只能用于单线程。

为什么呢?因为它并没有任何和并发相关的功能,它的计数可能会被其它线程干扰导致出现错误的计数。

现在我们需要一个类似Rc又实现了Send这个traitcrate


Arc

Arc看起来就和Rc是亲戚, 它的全名是atomic reference counting, 原子引用计数。

相信各位都清楚这个atomic原子性是什么意思,然后你也可以看下这个文档: std::sync::atomic - Rust

目前我们暂时只需要知道这个atomic是一个可用于不同线程之间数据安全分享的基本类型。

看到这里你应该会想: 既然这玩意儿这么好用,为啥不一开始就给标准库里的类型实现这个Arc?

因为Arc是牺牲部分性能来达到安全这一点的。

话不多说,我们来改下之前的代码

直接把之前的Rc替换成Arc即可。

这个Arc也是在sync这个crate里的。

我们来run一下

这回就正常了。


Refcell/RcMutex/Arc

刚我们已经见识到了RcArc之间的区别。

Mutex我们也用过了,它lock放回的类型是实现了DerefMut这个trait的,也就是允许我们去改变数据。

这一点和Refcell是一样的(指的是可以改变不可变数据),另外他俩都是cell家族成员。然后它还可以基于Arc拓展成多重可变数据所有权。

而之前我们使用Refcell搭配Rc也能做到这一点。

区别在于两者一个是单线程一个是多线程。

前面说过Refcell搭配Rc会存在循环引用的情况导致内存泄漏。

Mutex搭配Arc容易形成死锁(deadlock)。

之前说过死锁是因为多个线程之前依赖于对方的数据导致这两个线程不能被杀死。

感兴趣的话可以去试下。


使用SyncSend这俩trait来实现可拓展并发(extensible concurrency)

学完前面和并发相关的知识,你应该(没)有注意到并发相关的功能特性都是由标准库提供的。

这就意味着rust核心core并没有多少并发相关的特性。

同时这也意味着我们可以自定义并发特性,它几乎不会被语言自身所束缚。

这也就是前面说过的低抽象高自由以及性能好,当然相对的我们得写很多东西,不能和其它语言一样直接调用api完事。 当然,我们也可以去找别的大佬写的library

不过这有两个并发相关的概念是深入到rust代码中的: 来自std::markerSyncSend

使用Send来实现不同线程之间数据所有权的转交

如果数据的类型实现了Send这个trait,那么它就能在不同线程之间实现所有权转交。

rust提供的所有类型几乎都能实现Send这个trait,但是还有一小部分不行,就是前面提到过的使用了Rc的类型不能实现Send

前面我们说过的Rc只能用于单线程是因为可能存在被其它线程干扰导致计数出错的问题,但是没有解释为什么会有干扰。

这里给出解释: 如果你使用了Rc来创建多个所有权,然后把它们分别派发到各个子线程里,这个转交的过程会导致各个线程同时更新这个计数,这个时候就存在问题了。

rusttrait 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

Mutexsafe的,所以它是Sync


手动实现SendSync是不安全的

因为由SendSync组成的类型自身也会被标记为SendSync,我们并不需要手动去实现这两个trait

作为marker trait,它们甚至没有需要实现的method,它们只是用来把invariants(不变量?)和并发关联起来而已。

手动实现它们会涉及到rustunsafe code,我们会在后面学到unsafe code

目前我们只需要知道如果实现并发不基于SyncSend,那么你就需要去确保安全才行。


总结

今天我们学了和并发相关的知识,知道了message passingshare state两种安全并发的方式。

以及SendSync这两个trait的介绍。

rust的所有权规则和类型系统能将很多runtime的问题在编译阶段就暴露出来,这使得我们的代码会安全很多。

谢谢Rust哥

参考

  1. ^rust-fearless-concurrency https://doc.rust-lang.org/book/ch16-00-concurrency.html#fearless-concurrency
  2. ^rust-use-threads-run-code-simultaneously https://doc.rust-lang.org/book/ch16-01-threads.html#using-threads-to-run-code-simultaneously
  3. ^rust-use-spawn-to-create-new-thread https://doc.rust-lang.org/book/ch16-01-threads.html#creating-a-new-thread-with-spawn
  4. ^rust-use-join-handles-to-wait-for-all-threads-finish https://doc.rust-lang.org/book/ch16-01-threads.html#waiting-for-all-threads-to-finish-using-join-handles
  5. ^rust-use-move-closures-with-threads https://doc.rust-lang.org/book/ch16-01-threads.html#using-move-closures-with-threads
  6. ^rust-using-message-passing-to-transfer-data-in-threads https://doc.rust-lang.org/book/ch16-02-message-passing.html#using-message-passing-to-transfer-data-between-threads
  7. ^go-language-documentation https://golang.org/doc/effective_go.html#concurrency
  8. ^rust-channel-and-ownership-transference https://doc.rust-lang.org/book/ch16-02-message-passing.html#channels-and-ownership-transference
  9. ^rust-sending-multiple-values-and-seeing-receiver-waiting https://doc.rust-lang.org/book/ch16-02-message-passing.html#sending-multiple-values-and-seeing-the-receiver-waiting
  10. ^rust-create-multiple-producer-by-cloning-transmitter https://doc.rust-lang.org/book/ch16-02-message-passing.html#creating-multiple-producers-by-cloning-the-transmitter
  11. ^rust-Share-State-Concurrency https://doc.rust-lang.org/book/ch16-03-shared-state.html#shared-state-concurrency
  12. ^rust-using-mutexes-to-allow-access-to-data-from-one-thread-at-a-time https://doc.rust-lang.org/book/ch16-03-shared-state.html#using-mutexes-to-allow-access-to-data-from-one-thread-at-a-time
  13. ^rust-Mutex https://doc.rust-lang.org/book/ch16-03-shared-state.html#the-api-of-mutext
  14. ^rust-use-Mutex-to-share-value-between-multiple-threads https://doc.rust-lang.org/book/ch16-03-shared-state.html#sharing-a-mutext-between-multiple-threads
  15. ^rust-multiple-threads-with-multiple-ownership https://doc.rust-lang.org/book/ch16-03-shared-state.html#multiple-ownership-with-multiple-threads
  16. ^rust-Arc https://doc.rust-lang.org/book/ch16-03-shared-state.html#atomic-reference-counting-with-arct
  17. ^rust-similarities-between-Refcell/Rc-and-Mutex/Arc https://doc.rust-lang.org/book/ch16-03-shared-state.html#similarities-between-refcelltrct-and-mutextarct
  18. ^rust-extensible-with-Sync-and-Send-Traits https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html#extensible-concurrency-with-the-sync-and-send-traits
  19. ^rust-allowing-transference-of-ownership-with-Send https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html#allowing-transference-of-ownership-between-threads-with-send
  20. ^rust-allowing-access-from-multiple-threads-with-Sync https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html#allowing-access-from-multiple-threads-with-sync
  21. ^rust-implement-Send-and-Sync-manually-is-unsafe https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html#implementing-send-and-sync-manually-is-unsafe

发布于 2023-01-04 17:13・IP 属地广东