第五课: Rust异步编程入门

Posted by JoshSu Blog on August 29, 2021

大家好, 欢迎大家来参加这次公开课.

今天和大家一起学习”Rust异步编程”、”异步编程让程序不通过多线程达到类似多线程的效果”.

Rust异步编程的学习, 我们主要以Rust异步编程这本书为参考, 这本书在Rust官网上面是有的, 它是Rust官方社区发布的, 大家可以直接去搜索, 我们在Rust异步编程第一阶段, 主要是Rust异步编程入门阶段, 主要是以这本书为参考, 作为我们学习的资料, 好, 下面正式开发我们的学习.

首先我们通过一个例子, 来讲为什么需要异步.

假设有一个客户端, 它通过两个网站去下载, 比如 foo.com 这个网站 和 bar.com这个网站, 还有foo1.com、foo2.com网站, 很多个网站去下载, 下载这个功能, 用程序来演示, 它应该如何来下载, 有这么几种都可以来实现.

首先第1种. 客户端这边连接到服务器端网站, 向它发起下载的请求, 这个网站收到请求消息后, 它就做处理, 对吧, 给它做下载的处理, 然后呢 客户端收到这个结果, 接下来我们再发起第2个下载的请求, 到第2个网站然后等待结果, 完了后两个下载就完了, 这样一个处理过程, 我们看一下代码是怎样实现的.

首先我们看一下server端的代码, 这里用到Rust网络编程相关的知识, 通过TcpListener绑定IP和端口, IP是127.0.0.1, 端口是8080, 然后进行监听, 监听的内容, 我们用handle_client这个函数来处理, 如果有连接过来, 那么我们就去读, 读这个链接流里面的内容, 读到一个buf里面, 然后我们睡眠了一个时间, 这个时间是传进来的,睡眠了几秒钟, 然后我们把这个内容再给客户端, 写回去, 写到这个流里面, 完了之后我们写一个换行符, 这样就结束了. 这是我们server端模拟下载的过程, 就是仿真我们下载的过程, 当然这只是为了方便我们演示, 好我们来看这个server-foo, 我们给他的处理时间给的是3秒钟, 假定它处理这个下载任务需要3秒钟.

还有一个server-bar, 处理过程是一样的, 我们把处理时间设定为1秒钟。

这个就相当于是这样子, server-foo处理需要3秒钟, server-bar处理需要1秒钟, 我们再看客户端.

我们就需要用客户端来进行下载, 客户端里面实现了一个函数use_server, 这个函数里面首先我们要连到这个server端, 然后向server端写入一段内容, 然后我们就等待server端的返回, 就去读这个流, 读回来的内容, 我们就读到buf里面, 一直读到的结果就读到换行符为止, 然后我们把读到的内容打印出来, 这个就模拟我们 发起一个下载, 等待接收这个下载的内容, 然后我们这个内容打印出来, 大概是这样一个过程, 涉及到的知识都是Rust网络编程的内容. 主要是TCP相关的, 这个是我们客户端的实现.

main函数里面, 首先向server-foo发起一个下载, 然后呢, 再向server-bar发起一个下载, 这个就是在我们图里面向大家展示的. 下载等待结果,下载等待结果.

接下来我们先运行一下. 先启动server端, 然后再运行客户端, 运行的结果是不是先返回 server-foo, 然后再返回server-bar.

再来看看这个例子, 正常的下载比如我们用迅雷下载, 用迅雷软件来下载的时候, 我们是建立很多下载任务, 下载任务各自在运行, 它运行的过程并不是说第一个内容下载完后, 再下载第二个内容, 来进行下载第二个. 第二个下载完之后,再下载第三个. 它不是这样一个过程, 对不对, 而是它建立了下载任务后是并行的过程, 或者说第二个下载完了, 第二个下载的任务小一点, 或者说第一个需要1个小时才下载完, 它是这样一个过程, 很明显我们这样的实现就有问题, 那么如何实现刚刚我们说的像迅雷这样的一个过程, 我们就可以看一下,第二个我们的迭代演进.

这里就用到我们的多线程相关的知识, 如果我们下载第1个任务的时候我们启一下线程去做这个事情, 下载第2个任务的时候我们又启一个线程去做这个事情, 因为我们是用的多线程,这两个任务相互不会阻塞, 并不是说必须要等第一个下载完成后, 才能开始第二个, 所以这个效率是不是更高, 那我们来改动一下程序. 我们这里改动是不是只需要改动客户端就可以了.

这里用到我们多线程相关的知识, 我们来看看多线程是怎么实现的, 通过thread spawn创建一个线程, 在这个线程里面做第一个下载任务, 然后呢又启动了一个线程, 做第二个下载任务, 注意啊, 创建了线程后, 要在主线程中等待这两个线程完成, 所以创建线程后, 将handle放在了vector里面, 后面用一个循环, 等待线程的结束, 这样实现之后, 是分别启了两个线程, 去做这个下载的任务, 这两个下载任务之间没有相互阻塞, 不是说第一个任务做完之后,才能做第二个, 我们来运行一下. 上面这个是第一个server先返回, 第二个server后返回, 那么后面这个server那应该是 第二个server先返回, 第一个server后返回, 因为第二个server的时间要短一点, 我们看看是否符合我们的预期, 果然是第二个server先返回, 第一个server后返回.

这个呢就说明我们使用这个多线程就基本符合了我们的这个预期, 实现了我们想要的功能, 但是我们现在想一想, 如果我们这样实现的话,我们的下载任务有点多, 比如我们这个客户端它的下载任务有2000个, 或者有20000个, 这个时间我们不可能说创建20000个线程, 或者说创建20000个线程这个效率是很低的, 这个线程之间切换开销这些很麻烦, 那么这个时间我们想到的是什么, 首先我们想到的是线程池, 但是啊这个线程池还是有一个问题, 比如说我们这个下载任务本身发起一个请求, 主要是服务端那边进行处理, 客户端这边线程做的事情是很少的, 启一个线程做的事情是很少的, 但是呢做的事情虽然很少也要启一个线程, 还是要启一个线程,还是要用多线程, 那么我们能不能够用一个线程要做这个事情呢, 对不对, 或者我们用线程池来实现的话, 用线程池里面的这些线程来实现, 我们也要维护里面这些线程的状态, 还是会进行一些切换, 对不对,既然切换这些线程也还是会有一些开销, 这个说明多线程还是有一些问题, 既然我们说了每个线程它做的事情很少, 我们能不能不用多线程, 就用一个线程来实现这个东西呢。

好, 下面我们就进入到这个异步编程, 好,什么是任务编程? 首先什么是Rust异步编程, 这里我们不对Rust异步编程概念上下一个定义, 在这个例子中先通过Rust异步编程让大家了解一下大概能达到什么效果? 那么能达到一个什么效果呢? 能达到我们不通过 多线程 达到类似于多线程的效果. 具体到我们程序当中, client实现当中, 就是什么呢,就是向server-foo请求下载的时候, 和 server-bar下载的时候, 我们这两个只要发起请求后, 并不是说server-foo响应这个下载后, server-bar这个任务不能启动, 不是这样的,只要这两个发起异步编程的时候,它们两个都可以马上进行, 从用户层面或者从编写程序的层面, 我们看到的就类似于这两个任务在并行执行一样, 就是说不是在顺序执行而是在并行执行一样, 从用户层面看到的效果, 它背后的原理我们先不管, 这个是Rust异步编程能达到的效果, 或者是我们希望Rust异步编程能达到的效果, 为什么我这么说呢,或者说为什么这里用这个例子, 这也是我们官方教程为什么使用Rust异步编程这节里面它使用的这个例子.

接上来我们来看一下Rust异步编程的代码.

异步编程,我们需要futures这个包, 导入join、和executor 至于这两个是干什么用的,我们先不管, 这里我们主要是跟大家展示一下就可以了, 首先我们看一下语法是怎么的,首先我们要使用futures这个包, 在cargo.toml里面增加futures这个依赖, 然后我们在main.rs里面看一下怎么实现的, 首先我们定义了异步的这个函数, 使用了async这个关键字, 这里面其它的定义方式都和普通函数的定义方式都一样, 传的是IP、端口和我们要写入的内容, 用use_server这个函数, 向server端建立连接, 然后呢向server端写内容, 然后读取内容,

用join把f1、f2传进去, 在main函数里面是怎样实现的呢? 也是同样的定义了一个f等于use_all_server(), 然后我们用 execute的block_on方法来执行这个f, 好,这个就是我们这个异步编程的一个语法, 简单的跟大家演示一下, 接下来我们来运行一下, 那我们刚刚的预期是什么, 不通过多线程来实现一个多线程的效果. 也就是向server-foo下载和向server-bar下载相互不阻塞的一个效果, 我们来运行一下, 是否符合我们的预期, 大家睁大眼睛看,有可能和我们的预期不一样, 我们来看一下,按照我们的预期是不是它应该先返回 server-bar 的内容, 为什么会这样子, 这里就是初学者不容易想明白的地方, 或者很难想明白的地方, 这里呢我们留一个悬念, 为什么会这样子, 到目前为止我们只是给大家简单介绍了为什么需要Rust异步编程, 以及Rust异步编程简单语法, 在这个例子说明了如果使用多线程来实现, 开销太大, 不想用多线程来实现, 就想用Rust异步编程来实现, 这就是为什么需要Rust异步编程的一个原因, 也是书里面这个例子讲解的, 这里也是简单的介绍了一下这个Rust异步编程的语法, 实际上也没有达到真正的预期, 为什么达不到预期, 后面大家就跟着公开课认真的学, 后面会讲.

接下来, 我们介绍 async await 语法.

“async将一个代码块转化为实现了future特征的状态机” 注意啊, 这句话有几个点, async是什么作用, 它是”转化”, 将代码块转化成状态机, 它是这样一个作用, 这个状态机是什么状态机呢, 是实现了future特征的状态机, 至于future是什么, 后面会讲, 那么特征是什么, 是trait,

转化成future后有什么作用呢? 念一下PPT上面的这句话, 这句话是什么意思, 我们接下来慢慢讲》

首先我们讲一下什么是状态机.

比如说,我现在有一个自动门, 它有一个什么效果呢, 比如它有一个按钮,按一下它就打开, 再按一下它就自动关闭, 这个是我们的自动门, 如果我们要用状态机来描述一下的,应该怎么来描述呢.

首先对应的四要素: 状态机肯定要有状态, 动作、事件、跳转, 按一下按钮它就打开了, 肯定要有一个打开状态, 再按一下它就关闭了,就是关闭状态, 自动门就两个状态,一个是打开状态,一个是关闭状态, 那么我们要让自动门从打开状态到关闭状态,我们肯定要按按钮,按按钮这个操作肯定是一个事件, 按完按钮门会作一个动作,作一个关门的动作, 通过这个关门的动作, 它会变成关门的状态, 这个动作是一个跳转, 从打开状态跳转到关闭状态, 从关闭状态,再按一下按钮, 会进行一个开门的动作, 通过开门的动作会从关闭状态跳转到打开状态, 这个就是通过状态机来描述自动门的一系列事件, 一系列的现象,这就是简单的状态机.

那么刚才说的 “async将一个代码块转化为实现了future特征的状态机” 那么状态机就对应着有四要素, 我们先有这样一个概念,具体在程序中是怎么用的, 它背后或者展开的代码是一个什么东西,后面会慢慢去讲, 学异步这块确实比较复杂,不要期望一下子就完全弄明白, 慢慢的来最后肯定能弄明白, 好,刚才我们讲了一下什么是状态机,什么是future我们后面会慢慢的讲。

“那么转化为future后有什么作用呢?” -> 在同步方法中调用阻塞函数(async转化的函数), 会阻塞整个线程, 但是, 阻塞的future会让出线程控制权, 允许其它future运行. 这句话是什么意思, 通过一个图来说明白.

现场画一个图, 比如说,我这里有一个future1, 然后呢,我这里有一个future2, 再来一个future3, 当然在future3里面我们要先执行future1然后再执行future2, 在future3之后有一个future4, 程序执行的时候,假设在一个main函数里面, 我们要执行future, 我们也不需要先搞懂什么是future, 大概知道一下就可以了, 要执行一下future, 就简单理解成一个函数, future1\future2\future3\future4都是一个函数, 假设都是一个函数, 正常是怎么执行的呢, 先执行future3, 执行future3里面的future1,future2, 假设future1这里阻塞了, 阻塞后它会做一个什么事情呢, 通过async转化成future后, 不会阻塞后面的future4, 它会跳转到这里执行, 它会让出当前的一个线程, 开始执行的时候,都是在主线程里面执行, 如何阻塞了, 它会让出这个线程转而去执行future4, 大概是这样一个过程, 这里有一个动作, 让出线程, 这里就有一个作用, 转化成future后的一个作用, future2到future4之间弄成虚线, 执行完future4之后, 发现这里future1这里OK了, 又来执行, 再来执行,始终呢这个地方只有一个线程, 这个过程呢就是我们的异步编程, 或者说转化成future之后的一个作用, 是不是有一个很简单的描述, 对这个异步有一个简单直观的一个印象, 当然背后的原理,我们还不太清楚, 目前这个程序可以了,后面随着继续学习就会完全的弄清楚, 这里就是简单说的,转化成future后有什么作用。

接下来,我们再演示一下,如何使用async和await.

先来看这个hello普通的函数, 如果我们给他加上async关键字, 它会什么, 这个代码根据刚才讲的,它会变成实现了future特征的状态机, 对吧,它会变在这样一个东西, 这是async关键字的作用, 那么它等价于什么呢, 或者用不准确的说法, 它等价于这个. 上面的代码等阶于这个东西. 这个Future它就是一个trait, 跳进去我们看一下, 返回的就是一个trait对象, 这个trait对象是实现了future的对象, 里面还是一个打印, 打印一个hello, 这个是我们async关键字的作用. async关键字的作用就讲了, 这里怎么调用呢, 实现了异步函数如何调用呢? 函数的调用也比较简单, 通过block_on来进行调用. 可以直接写成这样. 来看看这个例子,是不是很简单, 使用了futures executor, 然后呢,我们定义了一个异步函数, 通过async关键字, 然后我们执行这个异步函数, 执行如何执行呢, 通过executor block_on来执行. 后面继续学习中, 我们会知道executor是执行器, 讲到背后的原理的时候. 一定要通过这个来执行. 这里只讲了一种执行方法, 大家注意了, 这只是一种调用方法, 而且它是阻塞式调用, 通过block_on不会让出线程的控制权,它会在这里一直等.

接下来, 我们来介绍await的方式.

接下来, 我们就用await执行的例子

我们还是官方教程上面例子来讲解.

有三个函数, 学习 learn_song 学习唱歌, 学完唱哥后, 就能唱哥了, sing_song(), 然后我们还能跳舞这个动作, 唱歌这个动作之前,必须要先学会唱哥, 所以他们有一个先后顺序, 跳舞这个事情不用等唱哥都可以先做, 单线程的函数里面一般怎么执行,是不是这样执行. 先进行唱哥,再进行跳舞. 如果用异步编程来实现的,是不是应该这样. 简单的实现几个函数. 先定义3个函数, 这个事情必须先学会然后再唱歌. 还需要一个async_main方法, 将learn_and_sing_song(), 和 dance(), 放在一起, 这里使用join!宏来加入.

然后在main函数里面还是通过 block_on 来进行执行. 在 learn_and_sing_song 函数里面,使用了一个await, 在async_main函数里面通过一个join!宏合起来执行. join!这个语法, 表示这两个可以并发的执行. 简单来说就是能并发的执行. learn_and_sing_song 里面, 这里的await它一定会阻塞, learn_song()没有执行完的话,一定会阻塞 sing_song()的执行. 但是呢 在 async_main函数里面, learn_and_sing_song 这个函数如果是阻塞的话, 那么它就会让出线程, 转而会去执行这个跳舞这个事情. 可能这里说起来比较绕,没有关系,这里把图一画就清楚了.

从代码上面我们看到的,真实的场景中, 唱哥和跳舞可以同时执行的, 在learn_and_sing_song里面通过await语法, 通过await sing_song是会阻塞的. 但是后面的 跳舞不会被阻塞, 但是这里一定要用join, 这个join表示, 先执行上面, 如果阻塞, 转而执行dance.

不符合我们的预期, 这里留一个悬念, 为什么老是说留一个悬念,因为以我们现在掌握的知识, 大家也听不明白, 为什么这里不符合我们的预期,我们要在后面慢慢学习后才能学习明白. 当然我们在这里需要一些点,也需要一些困惑, 大家看书看到这里会有困惑,困惑是什么, 后面会跟大家解释。当然前面必须要把这些点点出来. 后面把这些知识学习后,会把这些搞明白.

刚刚那个例子或者说刚刚那个写法是达不到这个效果的

异步编程模型

循环是不是有一个问题, 如果我这个线程需要1个小时才完成, 就需要3600秒, 单独抽出来, 我们想想需要一个什么机制

reactor它有办法知道, 某个任务是否Ready.

exector就相当于main主线程,