本文约10000字,建议阅读时间25分钟
客户端和后端编程中,异步或消除阻塞算是最大共同的特点之一,也是程序员的一个难点。客户端面对网络请求、I/O等耗时任务时,异步编程是避免主线程卡死的常见解决方案。服务端响应多个客户端请求,需要通过异步编程来实现并发。
作为程序员,我们大概率已经知道了回调、线程、协程等等实现异步编程的方式。可能也掌握了callback、thread、promise、async/await、suspend、future、goroutine等等异步编程的关键字的用法。但是到底什么异步编程,各种异步编程方式有什么差别?回调、promise、响应式编程与线程、协程有什么关系?本篇文章,我用一种全新的方式,拆解、辨析、总结异步编程,把它形成一种编程范式。虽然在准备过程中,我深入去理解Java、C++、JS、Dart、Python等语言的异步编程,也大概了解Go、Swift等语言的Promise模式。但讲解过程中,抽取他们的共性,尽量不涉及到具体的编程语言。方便读者更容易更深入的理解异步编程。
首先,我们来了解一些异步编程的周边概念:同步、异步、并行、并发、阻塞、非阻塞。理解这些概念以及了解其根本意义,才能更好的理解后边的内容。
同步:一般指按照预定的顺序依次执行任务,只有当上一个任务完成后,才开始执行下一个任务。
异步:与同步相对应,异步指的是让CPU暂时搁置当前任务,先处理下一个任务,当收到回调通知后,再返回上个任务继续执行,整个过程无需第二个线程参与。
并行:物理层面并行,一般指并行计算,同一时刻有多条指令同时被执行,这些指令可能执行于同一CPU的多核上,或者多个CPU上,或者多个物理主机甚至多个网络中。并行往往用来执行多个任务,节省执行时间。与并行相对的是串行,在物理层面上,只能依次执行任务。
下图为并行、同步、异步的直观体现。
并发:逻辑意义并行,通过时间分轮转等算法实现多任务调度。虽然在CPU层面,某一时刻只有一个任务运行,但是感官上可以做到并发。在实际编程中,一般我们把异步和并发作为相同概念。
下图是并发和并行的差异
阻塞:当开始一个任务时,其他的任务必须等待这个任务完成,才能继续执行。在等待事件内,其他任务在这期间都不能执行。
非阻塞:启动一个任务后,当前任务或者程序可以继续执行,不必等待。
这样看来,阻塞和同步,非阻塞和异步,不就是一样的意义吗?事实并非如此,同步和异步是从被调用者的角度说的,是新任务相对于当前任务的执行方式。阻塞和非阻塞是从调用者角度说的,代表当前任务和程序的状态。大多数时候,同步调用是阻塞的,异步调用是非阻塞的。同步改异步也是把阻塞式调用改成非阻塞调用的通用手段。
我们都了解这个事实:不管异步还是同步执行,CPU计算都是串行的,并不能同时执行两个任务,那么问题来了:为什么常常为了节省时间,把同步改为异步。比如将任务拆分成多个任务,放到多线程里进行异步并发执行。
要从根上搞清楚这个疑问,我们要了解程序的运行机制。程序的运行,重点涉及两个硬件:内存、CPU。程序启动时,会把代码和数据从硬盘等辅助存储器中读入内存,CPU从内存中读取这些指令和数据,对其进行处理,最终将处理结果写回内存。
不出意外的话,意外就来了,CPU的执行指令和寄存器的存取速度极快,远超内存、硬盘和网络传输数据的速度。我们程序的运算几乎都会依赖内存、硬盘这些“慢”硬件。
这就不得不面临一个问题,由于某条指令依赖于从硬盘或网络加载的数据,CPU 为了执行这条指令就不得不等到硬盘数据加载完。从 CPU 的角度来说,其它硬件的速度太慢了,CPU为了执行一次,可能要等到天荒地老。那怎么办?如果是单个计算任务,还可以理解,毕竟CPU执行完了就完了。如果多个任务要执行,可以一边不断的去加载数据,加载数据的过程一般用不上当前进程的CPU,一边把加载好的数据喂给CPU,尽量让CPU等待的时间减少一点。下图就是异步在内存和CPU中的体现。异步的目的不是让单个任务执行得更快,而是为了让计算机在相同时间内可以完成更多任务。
在代码层面,异步编程可以消除阻塞,便于继续执行后边的代码。除此之外,异步编程还有以下好处:
逻辑清晰:实现异步编程时往往需要良好的控制流管理,当你需要在某些条件下跳过某些异步操作或者在异步操作完成后跳转到特定的代码块时,通常会采取堆栈、队列、Promise链或者状态机等更加结构化的代码控制流方案。提高了代码的可读性和可维护性,代码逻辑更清晰。这有助于开发者写出更健壮,更少bug的代码。
代码解耦:将一部分代码单独封装起来,这使得代码可以按照功能或操作进行模块化,分离了代码,降低代码的耦合性。
业务灵活:满足复杂系统的需求,使系统更为灵活。
需要异步执行的任务,一般也是耗时任务。我们将耗时任务按照消耗硬件的方式分成两类:
1、 大量消耗CPU算力的计算密集型任务;
2、 频繁或者大量从内外部存储器进行输入或输出的I/O密集型。
数据获取暂未分类,因为数据获取,需要根据实际的业务的来判断是哪种类型,比如从本地数据库读取大量的数据,可能是计算密集型,也可能是I/O密集型。
我们做了大量的铺垫和准备,就是为了把拆解工具磨砺得更为锋利,拆解更为顺利。代码世界是源于对物理世界的抽象。在古早的互联网中,曾经流传了一个温馨又无厘头的梗:贾君鹏,你妈妈喊你回家吃饭,最后演变成了一场大型互联网行为艺术。到今天我们仍然不知道贾君鹏有没有回家吃饭。但并不耽误我们从这个梗开始,拆解异步编程。
离家出去前,告诉了妈妈喊你回家吃饭。要想吃到饭,我们需要解决两个问题:
1、怎么喊你回家?
2、饭怎么做?
拆成以下思维导图。喊的方式多种多样的,同样做饭的方式也是多样的。
整件事情其实是个异步过程,你可以继续干自己的事情,妈妈在家准备饭。妈妈做好饭了,再喊你回家。异步编程也可以这样拆成两步。
1:执行函数,对应怎么做饭。执行任务的时候,用异步方法,例如新线程,消除阻塞实现并发。
2:返回结果,对应怎么喊。执行完成后,可以用不同的异步模式,将数据或者状态返回。
异步方法,也可以理解成实现异步的手段,根据实际的需求,创建或者调用新的进程、线程和协程。异步模式主要有回调、Promise、发布/订阅、消息驱动。
经验丰富的程序员,这个时候可能会有疑问,实现异步的方式不是还有future、async/await、响应式编程、EventBus、事件驱动这些吗?是的,完全没有错。这些不在这里列出,是因为这些异步模式从回调、Promise、发布/订阅、消息驱动这几种模式演化出来的。
我们把异步编程,拆分成异步方法和异步模式,而不是眉毛胡子一把抓,主要是这样便于理解,这是跟其他人讲异步编程不一样的地方。下边开始依次讲解回调、Promise、发布/订阅、消息驱动。
回调无疑是异步编程中最常使用的一种模式了。在维基百科中这样定义回调函数:一段以参数的形式传递给其它代码的可执行代码,且这段代码在适当的时候被调用或执行。
举个例子:
function add (x, y) {
return x + y
}
function addFive (x, addReference) {
return addReference(x, 5) // 执行回调。
}
addFive(10, add) // 15
addFive是执行函数,add是回调函数。将add函数作为addFive函数的参数,addFive函数在运行的过程中,执行通过传参的方式传递过来的add函数。
为什么可以将函数作为参数进行传递且执行?回调函数是一种软件设计上的概念,和某个编程语言没有关系,几乎所有的编程语言都能实现回调函数。其实回调函数就是一个函数,和其它函数没有任何区别。我们来看看函数调用在内存模型上,如何实现函数的调度,也就是控制权的转换。
在此之前,复习以下内存模型。以两个主流的编程语言Java和C++为例:Java和C++的内存模型中,堆(Heap)和栈(Stack)是两个最基本的区域,而且其在Java和C++中的基本概念是相似的,只是其使用和管理方式有所不同,其他的语言内存模型也大同小异。堆是由程序员通过new等方式分配的,在Java中由其GC机制自动回收,而C++中需要程序员手动回收(如delete),否则发生内存泄露。栈是由编译器自动分配释放,存放函数的参数值,局部变量。函数的调用和控制权转换,都是发生在栈上的。所以方法的执行过程是借助方法调用栈实现的,每一次函数调用都会创建一个新的栈帧,包含局部变量、额外新的以及函数返回地址。调用一个子方法时,该子方法会以栈帧的形式入栈。如下图所示:
funcB(){
return ret;
}
funcA(){
b = funcB();
return b;
}
以上代码实现在函数A中调用函数B。我们知道当函数A调用函数B的时候,控制从A转移到了B,所谓控制其实就是指CPU执行属于哪个函数的机器指令,CPU从开始执行属于函数A的指令切换到执行属于函数B的指令,我们就说控制从函数A转移到了函数B。
在哲学上,有三个终极问题,我是谁,我从哪里来,要到哪里去。控制从函数A转移到函数B,也要解决这三个问题,我是谁已经很清楚了,毕竟代码在那里。还有另外两个问题,那么我们需要有这样两个信息:
我从哪里来 (跳转)
要到哪里去 (返回)
当前,CPU执行函数A的机器指令,该指令的地址为0x400564,接下来CPU将执行下一条机器指令就是:
call 0x400540
该地址就是函数B的第一条机器指令,从这条机器指令后CPU将跳转到函数B。现在我们通过控制跳转,解决了函数B的“我从哪里来”问题,当函数B执行完毕后怎么返回呢?
原来,call指令除了给出跳转地址之外还有这样一个作用,把call指令的下一条指令的地址,也就是0x40056a push到函数A的栈帧中,如图所示:
让我们来看一下函数B最后一条机器指令ret,这条机器指令的作用是告诉CPU跳转到函数A保存在栈帧上的返回地址,这样当函数B执行完毕后就可以跳转到函数A继续执行了。解决了函数B“要到哪里去”的问题。
到目前为止,我们弄清楚了函数调用在内存模型上的表现。拆解回调函数,还需要解决一个问题,那就是回调是同步还是异步的?如果没有上边的铺垫,很多人可能就会脱口而出,回调是异步的。我们上边把异步分成异步方法和异步模式。回调只是异步模式,并不是程序异步执行的方式。如果要实现异步,那么需要在调用的子函数中,使用进程,线程或者协程去实现耗时操作,避免阻塞。下边我们来看看同步和异步回调的区别
同步回调流程图示例:回调函数执行完后并返回结果后,当前函数才继续执行
异步回调流程图示例:不需要等回调函数执行结果,继续执行当前代码。
我们已经把回调的秘密全部都挖出来了。事实上,使用回调的场景很多,比如常规的网络请求,UI操作时获取按钮点击事件等等。这些都是比较单一的场景,或者没什么过多层次的依赖。如果出现下边这种情况,假设处理某项任务我们需要调用四个服务,每一个服务都需要依赖上一个服务的结果。实现的伪代码如下:
//同步方式
a = GetServiceA();
b = GetServiceB(a);
c = GetServiceC(b);
d = GetServiceD(c);
如果是非耗时操作,同步方式无可厚非。但如果是耗时操作,那么显然要改成异步方式。我们将其改成回调,至于回调里怎么实现消除阻塞,我们先不管。先只看用回调这种异步模式怎么实现。
//异步回调
GetServiceA(function(a){
GetServiceB(a, function(b){
GetServiceC(b, function(c){
GetServiceD(c, function(d) {
//省略处理过程
});
});
});
});
我们会发现这种回调嵌套太深。这会导致一些问题。
1、多个异步操作嵌套时,导致代码变得混乱和难以维护。
2、回调嵌套层次深,代码缺乏结构,难以理解和调试。
这个我们称之为回调地狱。除此之外回调还存在以下一些问题。
不易处理异常,如果其中一层回调发生异常,异常的传递和处理都比较困难。需要传入异常处理的回调函数,例如网络请求时,除了需要考虑正常返回,还有异常返回,网络异常都要回调出去;
对于回调执行的线程容易出错,常见的问题是回调在子线程执行但是却操作了UI更新;
对 for/while、 try-catch 等场景不友好;
回调地狱的主要原因是层次太多,而且嵌套,如果解决这两个问题,并且引入状态控制,那就完美了,因此按照以下思路分析。
回调封装:回调层次太多,引入一个特性,将回调函数进行注册,放到队列里进行管理。
链式调用:利用这个特性将横向回调改为纵向链式调用,解决回调地狱问题。
状态控制:根据不同的状态如执行中、成功执行结束、异常结束,调用对应的回调方法。
事实上,已经有人这么干了,这就是Promises/A+ 规范。Promise规范最早在2011年由CommonJS规范提出,在此之后经过几年的沉淀和发展,最终在2015年被正式添加到了ECMAScript 6(ES6)中。此后,Promise成为了JavaScript中处理异步操作的主流方式。同时也被其他语言接受。具体规范见《Promises/A+ 规范》
(http://malcolmyu.github.io/malnote/2015/06/12/Promises-A-Plus/)
,该规范只有短短的6000来字,所以实现起来也并不太复杂。我们来实现一个极简的Promise。
注意:以下代码都是近似js的一种伪代码,只是用来阐述编码思路,不能直接编译或者运行。
function Promise(fn) {
var state = 'pending', //状态
value = null,
callbacks = []; //回调函数列表
this.then = function (onFulfilled) {
if (state === 'pending') {
callbacks.push(onFulfilled);
return this;
}
onFulfilled(value);
return this; //返回this,实现链式调用
};
function resolve(newValue) {
value = newValue;
state = 'fulfilled';
setTimeout(function () { //异步执行
callbacks.forEach(function (callback) {
callback(value);
});
}, 0);
}
function reject(reason) {
state = 'rejected';
value = reason;
execute();
}
fn(resolve,reject); //执行函数
}
state代表函数执行状态,pending是初始状态以及执行中,fulfilled代表成功执行结束,rejected代表异常返回。
callbacks是回调函数列表。
上述代码大致的逻辑是这样的:
1、调用then方法,将想要在Promise异步操作成功时执行的回调放入callbacks队列,其实也就是注册回调函数;
2、创建Promise实例时传入的函数会被赋予一个函数类型的参数,即resolve,它接收一个参数value,代表异步操作返回的结果,当异步操作执行成功后,用户会调用resolve方法,这时候其实真正执行的操作是将callbacks队列中的回调函数一一执行。
现在我们将嵌套回调用promise来优化,以下是使用promise优化后的代码:
GetServiceA()
.then((a) => GetServiceB(a))
.then((b) => GetServiceC(b))
.then((c) => GetServiceD(c))
除了JavaScript之外,各大语言均有对Promise模型的使用,只是命名方式不一样。目前几种主流的编程语言对Promise模型均有支持。
主流的编程语言基本上都使用Promise或者Future。这里有些历史渊源,Promise最初提出于1976年,紧接1977年又有人提出了一个概念相似的Future。我们暂不用理会其中的差异,通常把Promise和Future当成同个概念使用。
Java和Kotlin的命名方式有些异类,因为 Kotlin 是一个跨平台的语言,可以和 Java、Swift 等进行混合编程,甚至可以导出 JavaScript 代码,为了避免引起混乱,最好避开 Promise、Future 这些已有名词,从而定了个 Future 的近义词 Deferred。那Java又是怎么回事呢?Future是Java5新加的一个接口,它提供了一种异步并行计算的功能。如果主线程需要执行一个很耗时的计算任务,我们就可以通过Future把这个任务放到异步线程中执行。主线程继续处理其他任务或者先行结束,再通过Future获取计算结果。Future不阻塞当前线程,但是会阻塞当前任务的后续执行,所以Future类获取返回结果的get方法是阻塞的。这个并不符合Promise的异步理念。因此,JDK8设计出了CompletableFuture,以符合Promise规范。
Then方法,在不同平台上 API 命名也有差异,有的为 thenCompose()、thenAccept() 等等,这里就不再列举,基本上也是大同小异;
Promise虽然解决了回调地狱问题,但是缺点是有不少的样板代码,并且写代码时候还是通过then注册回调方式。显式的回调毕竟还是很还是异步,违反直觉的。相对于对比于异步,同步更容易被人类的大脑所理解,毕竟大脑一次也只能处理一件事情。async、await是语法糖,也是一种异步编程模型,它建立在Promise之上,更直观和易读。使异步代码看起来像同步代码,让开发者以写同步代码的形式写异步逻辑。
const asyncTask = async () => {
a = await GetServiceA()
b = await GetServiceB(a)
c = await GetServiceC(b)
d = await GetServiceD(c)
}
await关键字需要和async一起使用。如果我们将await关键字放在异步函数调用之前,则当代码执行到此处的时候,代码将会暂停,等到异步函数执行完成后,才会继续向下执行。不只是在javascript和typescript中,在Dart中也是如此。
对于一个异步函数来说,其返回时内部执行动作并未结束,因此需要返回一个 Future 对象,供调用者使用。调用者根据 Future 对象,来决定以下两种情况的其中一个
1、 在这个 Future 对象上注册一个 then,等 Future 的执行体结束了以后再进行异步处理;
2、 一直同步等待 Future 执行体结束。
对于异步函数返回的 Future 对象,如果调用者决定同步等待,则需要在调用处使用 await 关键字,并且在调用处的函数体使用 async 关键字。Dart的await和async应用示例如下:
//声明了一个延迟3秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2023
Future<String> funcTest() =>
Future<String>.delayed(Duration(seconds:3), () => "Hello").then((x) => "$x 2023");
main() async{
print(await funcTest());//等待Hello 2019的返回
}
通过以上,我们可以总结出来async/await有两个特点:
1、 返回的是一个Promise/Future对象;
2、 await会阻塞代码的执行,在异步执行完成后,继续向下进行;
这个又有点类似Java中的Future了。其实async/await 模式在C#、JavaScript、Rust 以及 Swift 等编程语言中都有实现。基本上都是在Promise的基础上结合迭代器和状态机机制来实现的。
Promise使用起来比回调顺手。Promise 实际上基于回调改造的,是利用编程技巧将回调函数的横向加载,改成纵向加载,达到链式调用的效果,避免回调地狱。最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,语义变得很不清楚。所以除了回调地狱外,回调模式有的问题,Promise也或多或少的存在。主要问题以下:
1. 不符合同步编程习惯,对 for/while、异常处理不友好;
2. 对每一步的返回值类型有特殊要求,必须是 Promise,而不能是实际的数据类型;
3. 错误处理变得复杂,不易将不同阶段产生的错误一路传递下去;
4. 不同阶段之间共享数据困难;
之后使用await和async模式进行优化,降低了代码理解成本和减少部分样板代码,但依然存在问题2、3、4。
技术的进步,就是建立在解决问题的路上。要避免回调地狱,又要突破Promise/Future的局限。解决获取异常以及异常的处理,返回调用者需要的数据类型,同时还能对不同阶段的数据进行共享和处理。这是响应式编程的地盘。为了从根上消除回调带来的问题,响应式编程采用发布/订阅模式,来实现返回数据和状态通知。
发布/订阅模式也称为观察者模式,以下行文时,为了方便,我都将使用“观察者模式”。观察者模式也是代码设计模式中行为型模式中的一种,使用非常广泛。其目的是:在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。观察者模式角色有三个:观察者,被观察者,注册。
观察者:也称为订阅者,接受被观察者的状态改变,进行相应的响应。
被观察者:也称为发布者。内部状态发生改变时,向观察者发出通知。
注册/订阅:观察者向被观察者进行订阅或者登记
观察者模式的类图如下:
抽象被观察者角色Subject:它把所有对观察者对象的引用保存在一个集合中,每个被观察者都可以有任意数量的观察者。抽象主题提供一个接口,可以增加和删除观察者角色。
抽象观察者角色Observer:为所有的具体观察者定义一个接口,在得到被观察者通知时更新自己。
具体被观察者角色ConcreteSubject:也就是一个具体的被观察者类,在被观察者的内部状态改变时,向所有登记过的观察者发出通知。
具体观察者角色ConcreteObserver:实现抽象观察者角色所需要的更新接口,收取被观察者发出的通知并更新自己。
以上类图的代码实现如下:
public interface Subject {
void registerObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(Message message);
}
public class ConcreteSubject implements Subject {
private List<Observer> observers = new ArrayList<Observer>();
public void registerObserver(Observer observer) {
observers.add(observer);
}
public void removeObserver(Observer observer) {
observers.remove(observer);
}
public void notifyObservers(Message message) {
for (Observer observer : observers) {
observer.update(message);
}
}
}
public interface Observer {
void update(Message message);
}
public class ConcreteObserverOne implements Observer {
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println("ConcreteObserverOne is notified.");
}
}
public class ConcreteObserverTwo implements Observer {
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println("ConcreteObserverTwo is notified.");
}
}
public class Demo {
public static void main(String[] args) {
ConcreteSubject subject = new ConcreteSubject();
subject.registerObserver(new ConcreteObserverOne());
subject.registerObserver(new ConcreteObserverTwo());
subject.notifyObservers(new Message());
}
}
由此可见,观察者模式是通过注册/订阅-registerObserver的方式实现获取状态和数据,摒弃了回调。同时实现了在对象之间定义一个一对多的依赖。响应式编程就是基于观察者模式,结合迭代器模式和函数式编程,核心思想是将一切都当做是可观测的数据流。通过一系列的拓展方法(操作符,见下图)和背压策略,满足对数据流的处理和观察,对异常的检测和处理。其中代表就是Rx系列,如RxJava。另外很多框架中应用的EventBus也是基于观察模式实现的异步事件流。
以响应式编程为代表的观察者模式,解决了Promise和回调的缺陷,同时具备以下优点。
使用简单:将各种扩展进行封装,使用非常简单。可读性和健壮性都很好
操作符全面:通过操作符和完全避免了promise规范下的各种缺点,如错误处理,数据处理,数据共享
当然没有万金油式的解决方案。响应式编程的缺点也很明显。
学习和使用成本高:由于操作符过多,有较陡的学习曲线,使用时容易出错。好歹各个代码实现的Rx库中,操作符都是相似的,了解其中一个,便可进行知识迁移。
不能跨模块使用:难以使用到大型系统内部和跨模块的异步。
每一次提出当前模式的缺陷,都是为下一个异步模式出场做准备的。
Windows和Linux都是多任务操作系统,即允许同一时间运行多个应用程序,系统支持多任务管理和任务间的同步和通信。其中任务之间的同步和通信,主要依赖其在内核或者框架底层实现的消息传递机制。而消息传递机制又分为两种:消息驱动机制和事件驱动机制。
“消息驱动”利用消息循环机制来实现,当某个事件发生时,例如鼠标点击,产生消息,消息数据放到消息队列中,等待分发后送往明确的目的地,有固定导向;
“事件驱动”是基于观察者模式,结合回调模式。回调函数是预先定义好的,并且在程序的运行过程中,每当一个特定的事件发生时,立即向观察者发送数据,它就会触发一个回调函数。没有固定导向,只有被观察的数据。事件驱动的响应能力更强。但在大型系统中,事件驱动缺乏灵活性,回调的顺序也难以控制。消息驱动机制可以很好的弥补这个缺陷。
在操作系统和大型架构层面,消息驱动机制是实现异步和解耦的常见方式。在互联网应用中,消息通信被认为是实现系统解耦和高并发的关键技术体系。现实世界的邮政系统就是一个典型的消息机制,我们通过下图的角色对比和映射来理解消息机制的5个角色。
结合Android系统的消息机制,我们来看看消息5个角色作用以及消息机制是如何运作的。
消息发送和接收 Handler:
消息辅助类,主要功能向消息池发送各种消息事件(sendMessage())和处理相应消息事件(handleMessage());
Handler的sendEmptyMessage()、sendMessageDelayed()等系列方法最终调用MessageQueue.enqueueMessage(msg, uptimeMillis),将消息添加到消息队列中。
消息队列 MessageQueue:
消息队列的主要功能向消息池投递消息(enqueueMessage())和取走消息池的消息(MessageQueue.next());
消息分发 Looper:
不断循环执行(Looper.loop),按分发机制将消息分发给目标处理者
消息体 Message :
Message :分为三类:同步消息、异步消息、障栅
Message关键成员变量:target、what、 arg1、arg2、 obj
消息循环机制其实很容易理解,基于消息循环机制实现的消息驱动模式,应用非常广泛,最典型的就是上边说的Windows操作系统。除此消息循环机制之外,我们知道浏览器中运行的JavaScript和Flutter的Dart也使用循环机制,实现在单线程中异步执行任务。看到这里可能会有点疑问,异步一般都在多线程中实现的,单线程中如何实现异步。如果有此疑问,建议回头看看前边程序运行机制。异步是为了不阻塞当前任务,异步与多线程并不是一个同等关系:异步是目的,多线程只是我们实现异步的一个手段之一。我们要把JS/Dart和应用区分开。JS和Dart是单线程的,但是应用中不仅仅有JS/Dart运行线程,还有其他的线程和进程,耗时任务可以交由其他线程或者进程执行。举个例子,以浏览器中执行JavaScript为例,进行网络接口请求时,这是一个IO任务,JS运行线程会将网络请求任务发出给Browser 进程处理,网络请求有了结果后,再通知回JS运行线程。JS运行线程在网络请求过程的等待过程中,并不会阻塞等待,而是继续执行当前或者其他任务。收到网络请求结果回调后,才回头进行处理。
在Dart语言中,基本原理也是如此。Dart 在单线程中是以循环机制来运行的,其中包含两个任务队列,一个是“微任务队列” microtask queue,另一个“事件队列” event queue。入口函数 main() 执行完后,循环机制便启动了。首先会按照先进先出的顺序逐个执行微任务队列中的任务,当所有微任务队列执行完后便开始执行事件队列中的任务,事件任务执行完毕后再去执行微任务,如此循环往复。如下图:
细心的我们,这个时候又发现一个问题。在Windows中,消息循环一旦启动,就不会停止。我们知道Dart中,事件循环机制从进入main后就开始了,但是此时还需要进行主线任务,我们说过Dart是单线程的,那是如何实现异步并发的?实际上,事件循环(Event Loop)理论上是一直在运行的。但在实际应用中,如果没有任务需要执行,它不会一直进行空转,而是处于等待状态,直到有新的任务进入队列。而当任务队列被清空时,引擎将再次进入等待状态。
以上我们将异步编程范式中的异步模式:回调、Promise、发布/订阅、消息驱动逐个分析了。下边就来个简单的总结:
实现异步方法的进程、线程、协程在这里就不拆解了。一是篇幅过长,不宜再展开,二是这几个异步方法网上的资料很多。进程、线程、协程在应用层面,主要区别在切换效率、硬件资源开销上,根据不同的场景选择适合的异步方法。
进程的切换耗时最多,占用的硬件资源也是最多的,适合CPU密集型的任务,以及执行独立且大型的任务,出现异常时不会影响到其他进程的运行,一个系统中可运行的进程数极其有限,一般几十到几百个。
线程切换效率和占用资源居中,常规的任务和计算都可以使用,Linux中默认支持线程数14553,一个32位进程中理论上支持线程数几百,64位进程可达到一千多万,但实际上一般使用线程几个到几十个,上百的场景非常稀少。
协程占用的资源极少,一般占用内存少于2KB,由于在用户态切换,协程切换效率也极高,一般适用于适用CPU极少,I/O密集型的场景,实际应用中,可以轻松使用几百到上千个协程。
我在这里主要将异步拆解后再总结,形成范式。依据程序运行机制,以及函数在内存中调度的方式,把异步编程拆解成异步模式和异步方法。异步模式是实现数据同步的手段,异步方法是实现异步执行的手段。读到这里,如果你对异步编程理解更加清晰了,那么这边文章的目的就达到了。
函数运行时在内存中是什么样子?- 知乎 (https://zhuanlan.zhihu.com/p/339866296)
协程简史,一文讲清楚协程的起源、发展和实现 (https://mp.weixin.qq.com/s?__biz=MzA5MzI3NjE2MA==&mid=2650269180&idx=1&sn=514be311c0b2b9257b236d4ee43a149f&chksm=88631493bf149d85611816c46051af94c892f7bdcf15aedec60252801ee0e869fa69a9daf5f9&mpshare=1&scene=1&srcid=02170XtlBDX8F0fHM8Ly20Hu&sharer_sharetime=1676628053816&sharer_shareid=41ab744df086d7f71cb5098ed7b66e00&version=4.1.9.6012&platform=win#rd)
Promise A+ 规范 | malcolm-blog (http://malcolmyu.github.io/malnote/2015/06/12/Promises-A-Plus/)
从根本上了解异步编程体系 (https://mp.weixin.qq.com/s/q6BfOINeqgm5nffrHu4kQA)
进程、线程基础知识全家桶,30 张图一套带走(https://www.cnblogs.com/xiaolincoding/p/13289992.html)
扫码关注 了解更多