Rust and Concurrency

并发原理

在此之前,有必要区分并发和并行。并发是宏观上程序在同时运行,并行是程序的确在同时运行。一个单核 CPU 在不同进程、线程间反复切换,只能称作并发,不能算作并行。

现代计算机往往有多个 CPU 核心,所以程序免不了要合理并发利用 CPU 性能,也就是并发程序设计了。并发和并行对程序的要求迥然不同。并行程序对于开发者的能力要求更加严格。在并行程序的开发过程中,开发者需要考虑不同代码间协同,以及由此带来的锁、竞争等复杂问题。并发程序,(如果我们暂时把并行从中排除)看起来,就没那么硬核了。

这次我们将要讨论的是协同式多任务

协同式多任务

可能你会更喜欢 cooperative multitasking 这个名字。

所谓协同式并发,是与另一种并发——抢占式并发——相对而论的。

  • 抢占式并发。不同程序共同运行,并互相「争抢」资源,抢占到资源的程序得以运行。这是最常见的多任务形式,例如 Linux、OS X、Windows 都使用了抢占式。一般情况下,「争抢」的过程被操作系统接管,由操作系统使用某种策略在不同程序间分配资源。
  • 协作式并发。不同程序共同运行。程序在合适时自愿放弃资源,由需要资源的程序使用。

稍有常识的人就能看出,协作式并发是一种理想的并发方式。首先,程序自行让出资源,这意味着程序可以在让出前为自己恢复运行的时机做好准备(例如保存寄存器中自己使用的值)。这种准备是「精确」的,我们稍后会说明这一点。可惜的是在现实中,并不是所有的程序都值得信任。一旦一个程序占用资源不再吐出,所有的程序就都会被阻塞住。

抢占式便是基于「所有的程序不可信」这一点而做的。强行在不同程序间分配资源,使得所有程序都有可能运行。这也引入了一些问题,例如程序本身对抢占无感,所以备份上下文的任务就落到了调度器头上。调度器为程序做的保存并不是「精确」的,为了使得所有程序都可以正常恢复运行,策略一般是保存整个场景的数据,这意味着资源的浪费。

操作系统不会信任用户程序,不过我们能够「信任」自己编写的程序,在程序的层面上使用协作式并发似乎是可以的。

为什么需要这种奇怪的东西

一句话来讲,是追求压榨机器性能的需要。

让我们勉强列一下为抢占式并发所付出的代价:

  • 因为不知道程序具体需要什么,所以每次切换程序要保存所有上下文,包括但不限于 CPU 寄存器内容、栈数据。
  • 不同线程都需要维护栈结构,使用大量内存。

而借助协同式并发,我们正好可以解决这两个问题。程序自行维护最小的上下文,而依据协同的实现方式,我们可能不需要付出额外的栈空间。

既然这么好,为什么不用

在程序的层面上使用协作式并发,意味着我们需要改变代码的编写方式。这是在代码中使用新技术的一个重要阻力:将同步代码修改为多线程十分简单,只要将部分函数打发到不同 thread 运行即可,但后者需要大范围重构代码,并在程序层面引入调度器。

视不同语言的设计理念,协同并发的实现在语言层面上往往也会遇到巨大的阻力。常见的几种模式举例[2]

  • 事件式编程:代码在执行完成后调用 callback。一个典型的例子是 JavaScript[1]。其典型问题是「回调地狱」,即代码被打散到过多的嵌套级别里。
  • 协程:很多语言对「协程」的释义并不完全相同。但至少在 Rust 看来,协程是一种极其类似 Rust 并发设计的模式。但它隐去了太多的底层细节,不能用于 Rust 这种系统级编程语言。
  • Actor Model:并发单元抽象为独立的 actor,不同 actor 间采用消息传递。这是一种在游戏服务器中常用的模式,并且效率很高。但它并没有考虑很多语言细节,例如流程控制、错误重试逻辑等。

  1. 不过值得注意的是,JavaScript 的解释器一般为单线程。但这并不影响在语言层面上的并发抽象。
  2. https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html

Rust and Concurrency
https://blog.chenc.me/2021/01/02/rust-and-concurrency/
作者
CC
发布于
2021年1月3日
许可协议