持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情
前言
并发与并行
在讨论异步编程前,我想首先介绍清楚 并发(Concurrence)
和 并行(parallel)
的区别。
我们经常在相关操作系统的书里面听到:
并发是指两个或多个事件在同一时间间隔内发生。
并行性是指系统具有同时进行运算或操作的特性。
Erlang
语言之父 Joe Armstrong
(异步编程先驱)用一个简单的比喻解释了并发与并行的区别:
- 并发(Concurrent) 是多个队列使用同一个咖啡机,然后两个队列轮换着使用,最终每个人都能接到咖啡。
-
并行(Parallel) 是每个队列都拥有一个咖啡机,最终也是每个人都能接到咖啡,但是效率更高,因为同时可以有两个人在接咖啡。
定义
或许读到这里你还是有点疑惑,所以我们给他们下个定义:
- 并发是指系统支持两个或者多个动作同时存在。有多少线程队列就有多少并发量。
- 并行是指系统支持两个或者多个动作同时执行。CPU有多少核心就提供多少的并行量,每个核心用于某一时刻的计算。
💡 并发系统与并行系统这两个定义之间的关键差异在于 “存在” 这个词。
因此,所谓的并发编程,本质就是一个并发程序(即一个进程)能够开辟大于等于两个线程。而假设当前进程会一直运行在一个CPU的核心上,这个并发程序内部的多个线程将交替地换入或者换出内存,在外界看来这些线程是 同时“存在” 的。
而多核处理器在某一时刻,每个核心可以运行一个进程。不论这个进程是否是并发的,在外界看来这些进程都是 同时“执行” 的。这就是并行的。
💡 并行和并发之间是相互包含的。例如当进程数多于核心数时,此时进程又要通过并行(即轮转时间片)的方式来获得CPU核心的计算。对于并发来说,微观上这些进程之间是分时交替执行的,操作系统的并发性是通过分时得以实现的。
另外, “并行”概念是“并发”概念的一个子集。 凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。
并发编程模型
值得一提的是,由于各个语言的实现不同,所以导致各个语言的并发模型各不相同。我们都知道,当我们用某种语言编写、编译好一个程序之后,该程序在运行起来之后会占用一个进程。在这个进程内,可以由进程开辟出一些线程,这个线程是操作系统级别的。而在语言内部,程序员调用该语言创建的线程则是编程语言级别的。而这两者是否是一一对应,则要看该语言的内部实现:
-
OS原生线程:例如 Rust 语言是直接调用操作系统提供的API,所以最终程序内的线程数和该程序占用的操作系统线程数相等。
-
协程(Coroutines) :类似 Go 语言编写的程序内部的 M 个线程最后会以某种映射方式使用 N 个操作系统线程去运行。
-
事件驱动(Event driven):类似 JavaScript 通过
EventLoop
实现的并发模型。 -
actor模型:基于消息传递,对分解成的小块进行并发计算。Erlang语言。
-
async/await模型
异步编程
根据上面得出的结论,我们可以知道,异步编程就是一个 并发编程模型。只是每个语言都有自己的实现方式。本来我是想使用 JavaScript 来学习异步编程,但其实有关 JavaScript 的异步逻辑,市面上大量的文章都介绍过了,不论是 EventLoop,还是Promise、async/await。同时每一个前端在日常写代码时,Ajax请求天然的异步特性让所有前端都对这种异步编程有足够的认识。因此我是用 Rust 来学习异步编程,同时由于 Rust 的并发编程模型更接近操作系统层面,还能借此补充一下操作系统的知识。
Rust 编程模型
Rust 同时提供多线程编程和 async/await 两种编程模型:
- 前者在标准库中得到了实现,直接调用底层操作系统API,实现和使用简单。适合于量小的并发需求。
-
后者实现起来较为复杂,但 Rust 经过语言特性 + 标准库 + 三方库的方式实现和封装,让开发者能够不用关心底层实现逻辑,适用于量大的并发和异步IO。
Async与多线程
此时,我们需要搞清楚这两种编程模型的适用范围以及多线程编程与异步编程的区别。
- 对于
CPU密集型
任务,例如并行计算,使用多线程编程更有优势。 这是因为这种密集任务往往会让所在的线程长时间满负荷运行,同时你所创建的线程数应该等于CPU核心数,充分利用CPU的并行能力。此时不需要频繁创建和切换进程,因为任何线程切换都会带来性能损耗,所以你可以将线程绑定到CPU核心上来减少线程上下文切换。
- 而对于
IO密集型
任务,例如 web 服务器、数据库连接等等网络服务,使用异步编程更有优势。因为这些任务绝大部分时间都处于等待状态,如果使用多线程,那线程大量时间会处于空闲状态,再加上线程上下文切换的高昂代价,会损失大量性能。而使用async
,既可以有效的降低CPU
和内存的负担,又可以让大量的任务并发的运行。参考 Nodejs 中的实现,一个任务一旦处于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 panicked");
thread_two.join().expect("thread two panicked");
}
如果每次你只需要下载寥寥几张图片,这样做没有任何问题。但问题在于,当此你需要同时下载成百上千张图片的时候,一个下载任务就耗费一个线程,线程本身的资源消耗会被急速放大(线程还是太重了)。此时你就可以考虑使用 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相比多线程模型,在此时展现出的是在并行量不变的情况下,减少了创建和切换线程的花销。
总结
事实上,async
和多线程并不是二选一,在同一应用中,经常可以同时使用这两者。这点在我们之前对 Nodejs 的介绍中也有体现:async
编程关心于IO密集,并发编程关心于CPU密集。
这一节其实是对我们之前 Nodejs 的异步IO与多线程/进程编程的一节回顾,同时想要借此机会谈清楚究竟什么是并发编程。关于 JavaScript 的 Async,我们其实已经聊的很清楚了:无论是Event Loop
还是事件驱动。而 JavaScript 的并发编程,我们也讲解了集群管理和多线程编程。恰巧今天学到 Rust 的异步编程,决定借 Rust 来好好谈谈 aysnc
和并发编程,也算是同时巩固了Nodejs的学习。