这篇文章采用一问一答的形式, 主要介绍Rust异步相关的"基础"问题. "异步编程, 是一种越来越多语言都提供支持的并发模型, 可以在少量的系统线程上并发处理大量的任务, 而且借由async和await语法, 有着和同步一样的编程体验". 这是async-book里的一段话, 我们的文章也由此开始.

Asynchronous programming, or async for short, is a concurrent programming model supported by an increasing number of programming languages. It lets you run a large number of concurrent tasks on a small number of OS threads, while preserving much of the look and feel of ordinary synchronous programming, through the async/await syntax.

Q: 异步编程(Rust里是Future模式)指的是什么?

A: 异步是编程语言提供的一种并发编程模式.


Q: 所以Future是Rust提供给开发者实现并发的一种方式, 是这样吗?

A: 是的, 标准库提供了async/await关键字和Future特征, 第三方库则提供运行时实现和相关的工具组件


Q: 并发模型里的并发指的又是什么?

A: 并发指可以同时处理多个任务, 而不必等到完成一个再进行下一个.

例如我们有个服务是这样: 接收请求 -> 中间处理 -> 返回响应, 这个过程就可以看作一个任务.

逐个处理: 接收请求A -> 中间处理A -> 返回响应A -> 接收请求B -> 中间处理B -> 返回响应B

并发处理: 接收请求A -> 中间处理A -> 接收请求B -> 中间处理B -> 返回响应B -> 返回响应A

上面只是并发处理的一种可能情况, 实际的处理过程可以是完全不同的顺序. 不同于逐个处理的情况, A和B两个任务在时间上可以有重叠的部分, 也就是我们可以"同时"进行A与B两个任务.


Q: 为什么我们需要并发这样一种看上去更复杂的模式呢?

A: 简单地说是为了提高程序的运行效率, 特别是像网络服务这样的程序, 需要同时面对很多的连接, 处理很多的请求.


Q: 并发又是怎么提高程序运行效率的呢?

A: 问题的根源在于任务需要"等待", 比如上面 接收请求 这一步, 就需要等待网络数据先达到, 我们的任务在这边只能被动地等待. 而并发模式的目地就是让我们的计算机(这边我们考虑CPU)尽可能地忙碌起来, 如果一个任务需要等待了, 就先去执行其他的任务, 之后在适当的时候再回来继续执行.


Q: 也就是说, 并发是把CPU空闲的时间利用起来了, 对吗?

A: 是的, 如果我们的任务都是"计算型"的, 比如bcrypt这样需要CPU一直工作的, 那并发的作用就比较小了. 而且实现并发本身也是有开销的, 如果CPU已经很忙了, 再用并发会加重负担, 反过来降低程序的运行效率.


Q: 但我的程序里并没有调用sleep或者wait函数, 是不是就不需要并发了?

A: 不止这些"显然"会让任务等待函数, 像发送网络数据, 读取终端输入, 接收系统信号, 进入互斥量等等, 都会让任务开始等待. 会导致任务等待的阻塞(blocking)调用是如此普遍, 相当的程序都要与之打交道, 所以并发也就有了用武之地.


Q: 如果不用Rust, 换成其他语言, 比如C, 还需要考虑并发的问题吗?

A: 这些阻塞调用是操作系统提供的, 包括Linux, Windows, Unix等都是如此. 我们的程序通常都是运行在操作系统上的, 所以"无论"用什么语言都避免不了阻塞调用的问题. 像Go这样可以不用去考虑阻塞的问题, 是因为在Go在语言层面帮我们处理好了, 但也不是完全不用考虑, 比如有时候需要配置更合理的GOMAXPROCS的.


Q: 操作系统提供的这些调用为什么是阻塞的呢?

A: 像读取终端输入, 接收网络数据这样"不确定"的事件, 操作系统也没有魔法避免等待, 而能马上拿到结果. 阻塞调用在这边是指线程会暂时被挂起. 其实系统也提供了非阻塞的调用方式, 比如Linux下的O_NONBLOCK, epoll, io_uring等这些内容, 不过阻塞的方式更加成熟和完善.

时间事件(定时, 超时等)在某种角度也可以看作不确定事件, 因为时间要依赖于时钟系统的正确工作


Q: 也就是说这些不确定事件导致等待, 而阻塞调用会让线程陷入等待, 是这样吗?

A: 是的, 不确定事件是需要等待的原因, 而阻塞调用这样方式选择了让线程陷入等待. 当线程执行阻塞调用的时候就会被挂起, 然后其他的线程会被系统调度到CPU上执行. 所以如果让多个线程来处理任务, 也可以实现并发, 这就是多线程模式. 大多数系统都能直接支持多线程, 所以多线程是使用最广泛, 兼容性最好的并发方式.


Q: 多线程方式听起来也不错, 为什么Rust还要提供自己的并发模型呢?

A: 多线程模式有兼容性好, 易于上手, 容易理解等优点, Rust当然也支持这种模式, 调用标准库提供的std::thread::spawn(...)就可以开启多线程了. 但多线程模式也有缺点, 主要有两点, 一是系统线程占据的内存空间比较大, 二是线程竞争和切换会带来额外开销. 这使得创建太多线程会产生问题, 也就限制了并发的规模, 而Future方案就可以避免这些困扰.


Q: 除了多线程和Future, 还有其他的并发模型吗?

A: 当然, 比如Erlang的Actor模型, Go的Goroutine协程模型, JS的事件驱动模型等等. 模型本身是不限于用哪种语言实现的, 也不限于是程序实现的还是语言实现的, 不过这些语言的例子比较有代表性.


简单总结一下:

  1. 并发是同时处理多个任务, 可以提高程序的运行效率, Rust提供了Future的并发模型
  2. 不确定事件是等待的原因, 并发是为了让计算机少等待, 阻塞的调用会让线程陷入等待
  3. 多线程可以用来实现并发, 操作系统会进行线程调度, 但存在内存占用和竞争切换问题

还可以从阻塞的粒度的角度来看批处理, 多线程和Future这三种模式. 对于读取终端输入或者接收网络数据这类不确定的事件, 被动地等待是无法避免的. 但"谁"去等待, 是CPU核心, 还是系统线程, 还是用户任务, 也就是阻塞的粒度是可以变化的.

批处理模式下, 任务陷入等待, 线程也被阻塞了, CPU核心也被阻塞了; 多线程模式下, 任务陷入等待, 对应的线程会阻塞, 但CPU核心可以运行其他线程; 而由于系统线程的开销, Future模式进一步缩小阻塞粒度, 只阻塞用户任务本身, 而线程和CPU核心都可以去做其他事情. 这和锁的优化过程很像, 通过对粒度控制, 提高程序的效率.


-> 如果文章有不足之处或者有改进的建议,可以在这边告诉我,也可以发送给我的邮箱