Rust 并发编程初探

3,292 阅读11分钟

再次 Borrow 陈天老师的 Rust 学习路经图,欢迎进入 Rust 并发和异步的学(爬)习(坡)之旅。

「Rust 学习路径图」.jpeg

并发和并行

很多人分不清并发和并行的概念,所以学习在 Rust 异步编程之前,首先弄清楚清楚 并发(Concurrence)  和 并行(parallel)  的区别。

我们经常在相关操作系统的书里面听到:

  • 并发是指两个或多个事件在同一时间间隔内发生。
  • 并行性是指系统具有同时进行运算或操作的特性。
  1. 解释一:并发是指两个或多个事件在同一时间间隔发生,而并行是指两个或者多个事件在同一时刻发生。
  2. 解释二:并发是在同一实体上的多个事件,并行是在不同实体上的多个事件。
  3. 解释三:并发是在一台处理器上“同时”处理多个任务,并行是在多台处理器上同时处理多个任务。如分布式集群。

Golang 创始人之一的 Rob Pike,对此有很精辟很直观的解释:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

并发是一种同时处理很多事情的能力,并行是一种同时执行很多事情的手段。

我们把要做的事情放在多个线程中,或者多个异步任务中处理,这是并发的能力。在多核多 CPU 的机器上同时运行这些线程或者异步任务,是并行的手段。可以说,并发是为并行赋能。当我们具备了并发的能力,并行就是水到渠成的事情。

Erlang 之父 Joe Armstrong,用一张图片解释了并发与并行的区别:

上图很直观的体现了:

  • 并发(Concurrent)  是多个队列使用同一个咖啡机,然后两个队列轮换着使用,最终每个人都能接到咖啡
  • 并行(Parallel)  是每个队列都拥有一个咖啡机,同时有多个人在接咖啡,最终也是每个人都能接到咖啡,效率更高。

💡 当然,我们还可以对比下串行:只有一个队列且仅使用一台咖啡机,前面哪个人接咖啡时突然发呆了几分钟,后面的人就只能等他结束才能继续接。可能有疑问了,从图片来看,并发也存在这个问题啊,前面的人发呆了几分钟不接咖啡怎么办?很简单,另外一个队列的人把他推开就行了,自己队友不能在背后开枪,但是其它队的可以:)

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行处理。

并发和并行-concurrency.png

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行,同时处理。

并发和并行-parallel.png

当有多个线程在操作时,如果系统只有一个 CPU,则它根本不可能真正同时进行一个以上的线程,它只能把 CPU 运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态,这种方式我们称之为并发(Concurrent)。

当系统有一个以上 CPU 时,则线程的操作有可能非并发。当一个 CPU 执行一个线程时,另一个 CPU 可以执行另一个线程,两个线程互不抢占 CPU 资源,可以同时进行,这种方式我们称之为并行(Parallel)。

先给出一个结论:并发和并行都是对“多任务”处理的描述,其中并发是轮流执行(处理),倾向于处理能力,比如并发量,而并行是同时执行(处理),倾向于处理手段,比如任务并发

并发编程模型

我们知道各个语言的实现不同,所以导致各个语言的并发模型各不相同。当我们用某种语言编写、编译好一个程序之后,该程序在运行起来之后会占用一个进程。在这个进程内,可以由进程开辟出一些线程,这个线程是操作系统级别的。而在语言内部,程序员调用该语言创建的线程则是编程语言级别的。而这两者是否是一一对应,则要看该语言的内部实现:

  • OS原生线程:例如 Rust 语言是直接调用操作系统提供的API,所以最终程序内的线程数和该程序占用的操作系统线程数相等
  • 协程(Coroutines) :类似 Go 语言编写的程序内部的 M 个线程最后会以某种映射方式使用 N 个操作系统线程去运行
  • 事件驱动(Event driven):事件驱动常常跟回调( Callback )一起使用,这种模型性能相当的好,但最大的问题就是存在回调地狱的风险。
  • actor模型:基于消息传递,对分解成的小块进行并发计算。是Erlang语言杀手锏。
  • async/await模型:该模型性能高,还能支持底层编程,同时又像线程和协程那样无需过多的改变编程模型,但有得必有失,async 模型的问题就是内部实现机制过于复杂。

总之,Rust 经过权衡取舍后,最终选择了同时提供多线程async/await 两种并发编程模型:

  • 多线程在标准库中得到了实现,直接调用底层操作系统API,实现和使用简单。适合于量小的并发需求。
  • async/await 实现起来较为复杂,但 Rust 经过语言特性 + 标准库 + 三方库的方式实现和封装,让开发者能够不用关心底层实现逻辑,适用于量大的并发和异步IO。

Rust 中的异步编程

异步编程就是一个并发编程模型,异步编程允许我们同时并发运行大量的任务,却仅仅需要几个甚至一个OS线程或CPU核心,现代化的异步编程在使用体验上跟同步编程也几无区别。

目前已经有诸多语言都通过 async 的方式提供了异步编程 ,但 Rust 在实现上有所区别:

  • Future 在 Rust 中是惰性的,只有在被轮询(poll)时才会运行, 因此丢弃一个 future 会阻止它未来再被运行, 你可以将Future理解为一个在未来某个时间点被调度执行的任务。
  • Async 在 Rust 中使用开销是零, 意味着只有你能看到的代码(自己的代码)才有性能损耗,你看不到的(async 内部实现)都没有性能损耗,例如,你可以无需分配任何堆内存、也无需任何动态分发来使用 async ,这对于热点路径的性能有非常大的好处,正是得益于此,Rust 的异步编程性能才会这么高。
  • Rust 没有内置异步调用所必须的运行时,但是无需担心,Rust社区生态中已经提供了非常优异的运行时实现,例如大明星 tokio
  • 运行时同时支持单线程和多线程,这两者拥有各自的优缺点, 稍后会讲

Async 异步与多线程的选型

虽然 async多线程都可以实现并发编程,后者甚至还能通过线程池来增强并发能力,但是这两个方式并不互通,从一个方式切换成另一个需要大量的代码重构工作,因此掌握二者的区别和适用范围,然后提前选型相当重要。

  • 对于 CPU密集型 任务,例如并行计算,使用多线程编程更有优势。 这是因为这种密集任务往往会让所在的线程长时间满负荷运行,同时你所创建的线程数应该等于CPU核心数,充分利用CPU的并行能力。此时不需要频繁创建和切换进程,因为任何线程切换都会带来性能损耗,所以你可以将线程绑定到CPU核心上来减少线程上下文切换。

  • 而对于 IO密集型 任务,例如 web 服务器、数据库连接等等网络服务,使用异步编程更有优势。因为这些任务绝大部分时间都处于等待状态,如果使用多线程,那线程大量时间会处于空闲状态,再加上线程上下文切换的高昂代价,会损失大量性能。而使用 async,既可以有效的降低 CPU 和内存的负担,又可以让大量的任务并发的运行,一个任务一旦处于IO或者其他等待(阻塞)状态,就会被立刻切走并执行另一个任务,而这里的任务切换的性能开销要远远低于使用多线程时的线程上下文切换。

💡 async 底层也是基于线程实现。但是它基于线程封装了一个运行时,可以将多个任务映射到少量线程上。其实就是将大量并发的IO密集事件丢到少量线程中,并通过事件来进行高效通信。

代价就是这样做会增大 Rust 程序的运行时(运行时是那些会被打包到所有程序可执行文件中的 Rust 代码),造成编译出的二进制可执行文件体积显著增大。

用一个简单的例子说明两者的区别:比如我们想要下载两个文件。我们可以一个一个的 download(串行方式),但显然这样不是最快的。此时我们会很自然地想到使用多线程并行来下载:

多线程编程:

fn download_two_files() {
    // 创建两个新线程执行任务
    let thread_one = thread::spawn(|| download("URL1"));
    let thread_two = thread::spawn(|| download("URL2"));
    // 等待两个线程的完成
    thread_one.join().expect("thread one panic");
    thread_two.join().expect("thread two panic");
}

如果每次你只需要下载一两个文件,这样做没有任何问题。但问题在于,当此你需要同时下载成百上千个文件的时候,一个下载任务就耗费一个线程,线程本身的资源消耗会被急速放大(线程还是太重了)。此时你就可以考虑使用 async

async 异步编程:

async fn get_two_sites_async() {
    // 创建两个不同的future
    // 你可以把future理解为未来某个时刻会被执行的计划任务、JS中的Promise
    // 当两个future被同时执行后,它们将并发的去下载目标页面
    let future_one = download_async("URL1");
    let future_two = download_async("URL2");
    // 同时运行两个`future`,直至完成
    join!(future_one, future_two);
}

💡 Async 相比多线程模型,在此时展现出的是在并行量不变的情况下,减少了创建和切换线程的花销。

总结

并发和并行都是对“多任务”处理的描述,其中并发是轮流处理,而并行是同时处理并发编程代表程序的不同部分相互独立的执行,而并行编程代表程序不同部分于同时执行。在并发编程模型上,Rust 中由于语言设计理念、安全、性能的多方面考虑,并没有采用 Go 语言大道至简的方式,而是选择了多线程与 async/await 相结合,优点是可控性更强、性能更高,缺点是复杂度并不低,当然这也是系统级语言的应有选择:使用复杂度换取可控性和性能。

事实上,async 和多线程并不是二选一,在同一应用中,经常可以同时使用这两者。虽然 async 和多线程都可以实现并发编程,后者甚至还能通过线程池来增强并发能力,但是这两个方式并不互通,从一个方式切换成另一个需要大量的代码重构工作,因此提前为自己的项目选择适合的并发模型就变得至关重要。

总之,async编程适合 IO 密集,多线程适合 CPU 密集。简单总结下选用规则:

  • 有大量 IO 任务需要并发运行时,选 async 模型
  • 有部分 IO 任务需要并发运行时,选多线程,如果想要降低线程创建和销毁的开销,可以使用线程池
  • 有大量 CPU 密集任务需要并行运行时,例如并行计算,选多线程模型,且让线程数等于或者稍大于 CPU 核心数
  • 无所谓时,统一选多线程

参考