多线程并发和多任务并行的小结

一、多线程并行的一点小结

1.无论是thread::spawn还是tokio::spawn,都是创建一个线程或者任务去执行闭包的函数体。thread::spawn接受一个闭包作为参数,并返回一个 JoinHandle,其中 T 是闭包的返回类型。创建的新线程将在后台运行,并执行闭包中的代码。

2.多线程并行:其他的高级语言可以实现并行,会有额外的运行时来进行多线程调度,耗费时间。C/C++没有额外的运行时,速度快但是不安全,手动保证线程安全。Rust没有多线程运行时,但是可以利用所有权等特征在编译时消除不安全代码。

3.多线程并行:move闭包通常和thread::spawn函数一起使用,闭包move||的作用将变量的所有权从一个线程转移到另一个线程。move||和|v|的作用一样。好处是不用解决借用生命周期的问题,坏处就是无法跟其它代码实现对变量的共享。

4.多线程并行:channel类似单所有权:一旦将值的所有权转移至channel,就无法使用它了。共享内存并发类似多所有权:多个线程可以同时访问同一块内存。

6.多线程并行:Mutex类似于RefCell提供内部可变性,就是多个共享所有权时可以修改变量的值。Rc和Arc都是共享所有权的智能指针,但是Rc和RefCell是非线程安全的,所以使用Mutex和Arc来解决多线程数据共享的安全。
a.clone()和Rc::clone(&a)的效果一样。

二、多任务并发的一点小结

1.什么时候使用异步编程或者多线程?
对于很多的任务,比如读取1000个文件,每个文件开一个线程?很耗费资源,这时可以使用异步编程。
对于IO密集型适合使用异步编程,对于CPU密集型异步编程也不起作用,适合多线程。
要避免在异步任务中处理大量计算密集型的任务,因为效率不高,且还容易饿死其它任务,CPU 密集型任务更适合使用线程,而非 Future。
如果真的需要在 tokio(或者其它异步运行时)下运行计算密集型的代码,那么最好使用 yield 来主动让出 CPU,将线程交还给调度器,自己则进入就绪队列等待下一轮的调度,比如 tokio::task::yield_now(),这样可以避免某个计算密集型的任务饿死其它任务。

2.一个Future创建之后并不会运行,是惰性的。需要执行者,使用.await或者block_on来进行执行过程。block_on会造成当前的程序阻塞,block_on里面的执行完成了才会接着执行,而在async函数里面的.await不会阻塞线程,会让出线程给其他的,等Future有进展了再执行该任务。而且运行是直接接着这个被.await的Future运行,上面的代码不会再运行。

3.补充关于进程、线程、协程的相关概念:进程其实就是一个运行的代码程序,这个程序可以由多个线程来执行,是资源分配和调度的基本单位。线程就是比如CPU有四核,那么最多同时运行4个线程,线程是操作系统进行调度的,创建和销毁都是操作系统来实现,线程是执行和并发的基本单位。协程由用户空间的调度器进行调度,调度器根据协程的调度策略进行切换,切换开销较小,协程在单个线程中执行,通过调度器的切换来实现多个协程之间的并发执行,协程之间共享线程的调用栈和上下文,可见异步编程就是协程的操作。

4.何时唤醒函数?什么时候可以poll了?
这个过程来源于操作系统通知,比如IO操作已完成,操作系统告知waker,waker告知调度器,调度器这个时候就进行poll以获取更多进展。

5.Reactor Pattern 是构建高性能事件驱动系统的一个很典型模式,executor 和 reactor 是 Reactor Pattern 的组成部分。Reactor pattern 包含三部分:
task:待处理的任务。任务可以被打断,并且把控制权交给 executor,等待之后的调度
executor:一个调度器。维护等待运行的任务(ready queue),以及被阻塞的任务(wait queue)
reactor:维护事件队列。当事件来临时,通知 executor 唤醒某个任务等待运行
executor 会调度执行待处理的任务,当任务无法继续进行却又没有完成时,它会挂起任务,并设置好合适的唤醒条件。之后,如果 reactor 得到了满足条件的事件,它会唤醒之前挂起的任务,然后 executor 就有机会继续执行这个任务。这样一直循环下去,直到任务执行完毕。

6.tokio 的调度器会运行在多个线程上,运行线程上自己的 ready queue 上的任务(Future),如果没有,就去别的线程的调度器上偷一些过来运行(work-stealing 调度机制)。当某个任务无法再继续取得进展,此时 Future 运行的结果是 Poll::Pending,那么调度器会挂起任务,并设置好合适的唤醒条件(Waker),等待被 reactor 唤醒。而reactor 会利用操作系统提供的异步 I/O(如epoll / kqueue / IOCP),来监听操作系统提供的 IO 事件,当遇到满足条件的事件时,就会调用 Waker.wake() 唤醒被挂起的 Future,这个 Future 会回到 ready queue 等待执行。

7.Pin 包裹一个指针(一个可以解引用成 T 的指针类型 P,而不是直接拿原本的类型 T),并且能确保该指针指向的数据不会被移动,所以,对于 Pin 而言,你看到的都是 Pin、Pin<&mut T>,但不会是 Pin。
Pin 的目的是,把 T 的内存位置锁住,从而避免移动后自引用类型带来的引用失效问题。
自引用结构有一个很大的问题是:一旦它被移动,原本的指针就会指向旧的地址。所以需要有某种机制来保证这种情况不会发生,Pin 就是为这个目的而设计的一个数据结构,可以 Pin 住指向一个 Future 的指针。
比如使用move或者swap的时候,就会出现错误。一个a,b=&a,这个时候move a,b还指向a原来的地址,这肯定不行啊。
当使用pin之后,由于数据结构被包裹在 Pin 内部,所以在函数间传递时,变化的只是指向 data 的 Pin,避免了移动带来的问题。

8.在 Rust 中,绝大多数数据结构都是可以移动的,所以它们都自动实现了Unpin,即便这些结构被 Pin 住,它们依旧可以进行移动。当希望一个数据结构不能被移动,可以使用 !Unpin

9.Iterator 可以不断调用 next() 方法,获得新的值,直到 Iterator 返回 None。但是 Iterator 是阻塞式返回数据的,每次调用 next(),必然 独占CPU 直到得到一个结果,而异步的 Stream 是非阻塞的,在等待的过程中会空出 CPU 做其他事情。
Stream 的 poll_next() 方法,它跟 Future 的 poll() 方法很像,和 Iterator 版本的 next() 的作用类似。然而,poll_next() 调用起来不方便,我们需要自己处理 Poll 状态,所以,StreamExt 提供了 next() 方法,返回一个实现了 Future trait 的 Next 结构,这样就可以直接通过 stream.next().await来获取下一个值了。虽然可以视作是迭代器,但是它并不能用for来遍历,不过我们还是可以用while let来搭配StreamExt::next()迭代的。

三、tokio的一些操作小结

1.tokio::spawn(),接受一个Future,返回一个Joinhandle,如果你的异步block有返回数据,那么你可以对spawn返回的Joinhandle做await,拿到的是一个Result包裹的数据。Joinhandle是一个句柄(handle),用于管理和操作异步任务,可以等待任务完成、获取任务的执行结果以及取消任务。

2.channels有这么几种mpsc、oneshot、broadcast和watch,都是几个producer或者几个consumer的映射关系,在写项目之前提前规划好即可。在tokio的底层使用的是多线程,所以很多设计思路和多线程里面保持一致。

3.异步IO操作
tokio里的异步I/O用法上和标准库里的差不多,不过是异步的,每一步使用都需要.await。我们基本都是通过AsyncReadExt和AsyncWriteExt这俩提供的方法来间接使用到AsyncRead和AsyncWrite。

4.#[tokio::main]这个是属性宏,这里就不多说了,简单地说就是编译阶段会触发这个宏然后执行对应的函数处理抽象语法树再转换回代码,比如这里将async转换为原生block_on的写法,毕竟main函数是不允许async的。

5.不要通过共享内存来通信,要通过通信来共享内存
在传统的并发编程中,共享内存是多个并发实体之间交换信息的一种方式。然而,直接共享内存存在潜在的问题,例如数据竞争、死锁、活锁等,并且在调试和维护复杂并发程序时更加困难。
通过"通过通信来共享内存"的思想,我们将共享数据抽象为消息的形式,并通过消息传递机制来实现并发实体之间的交互。这意味着每个并发实体拥有自己的私有状态,并且只能通过发送消息来与其他实体共享数据。
a.减少了对共享内存的直接访问,降低了数据竞争和并发错误的风险。
b.通过明确的消息传递来实现数据的同步和共享,使得程序的逻辑更加清晰和可控。
c.提高了程序的可扩展性,因为可以增加或减少并发实体之间的消息传递来调整并发级别,而不需要修改共享内存区域或锁的粒度。

四、tokio实战

你可能感兴趣的:(rust,开发语言,后端)