什么是异步编程?
这个问题并不是特定于 js 的,故而它的答案也是并特定于 js。任何一门编程语言都会面临一个命题:如果用户要执行一个很耗时的任务,我应该怎么执行用户的全部代码? 假设全部代码被分为两部分:一部分是用于执行耗时任务的代码 A,另外一部分是除了 A 之外的其他代码 B。那现在执行 A 之后,我们应该是等待 A 所代表的耗时任务执行完之后再执行 B;还是说执行 A 之后,不等待耗时任务执行完毕,而是马上就接着执行 B。只不过,我们会存下一些代码 C,用于耗时任务执行完毕后再执行,从而实现「通知用户任务执行完毕,并给到任务的执行结果返回给用户」的目的。
我相信,人类历史上的第一段的程序必定是采用前者的实现。因为它更符合人类大脑天生的基于顺序的思维模型。但是它有一个致命的缺点:那就是「在计算机资源利用方面,效率低下」。人类对于提升效率历来都是孜孜不倦的,在计算机领域也不例外。也不知道哪个聪明的人类,首先提出并实现了后者。反正我们现在有了这种能力了。
到这里,我们也不卖关子了。上面提到的两种代码执行模式,前者称之为「同步执行」,后者称之为「异步执行」。在同步执行的模式下,我们的代码只有一种时间概念,那就是「现在」 - 所有的代码都必须在「现在」执行完。而在异步模式下,我们的代码有两种时间概念,那就是「现在」和「未来」 - 「现在」的代码基于电流的速度下很快执行完了,此时会有一个我们看不见的人,他在系统执行我们的同步代码的同时,他也会在执行我们的耗时任务,当这个耗时任务完成后,他就会去执行我们之前传给他的代码 C。我们的这些代码 C 可以称之为「在未来执行的」。现在执行的代码跟未来执行的代码分别属于不同的时空,它们之间的时间间隔就被称之为「异步」。
如何编排同步代码和异步代码,这里面所需要的知识和技巧就是「异步编程」需要掌握的东西。
js 的异步编程原理
异步编程无非是为了提高代码执行的并行性。而在支持多进程或者多线程的语言中,它们可以有多个选择方案。所以说,异步编程一般会发生在单线程的执行环境里面。而 js 天生就是的单线程语言。所以,为了提高 js 对计算机资源的利用率,js 肯定要支持异步编程。在 js 中,异步编程的模式有好几种。无论哪一种模式,它其实都是基于 js 异步执行的底座 - event loop。
event loop 有三大要素:
- call stack
- micro task queue(微任务队列)
- macro task queue(宏任务队列)
我们可以这么简单地理解:「同步代码」就是在 call stack 去执行的代码;「异步代码」就是被 js 引擎之外的宿主环境推入到微任务队列或者宏任务队列里面的代码。 异步代码最终也是被推入到 call stack 里面去执行的,只不过相对于它们的代码书写期,它们的执行就是「异步」的。
这里值得一提的是,负责调度异步代码的是 js 引擎的宿主环境而不是 js 引擎。js 引擎最多也只是通知宿主环境:"嘿,宿主环境,我解析执行 js 代码的时候发现了一个异步代码块,你来调度它吧"。所谓的「调度」,其实就是宿主环境会在一个恰当的时机把异步代码推进微任务队列或者宏任务队列里面。异步代码的执行时机是由 js 引擎的 event loop 来决定的。
异步代码的调度竟然不是由 js 引擎来执行的?对于这一点,你可能感到很意外。不错,事实就是这样的。js 引擎只负责 js 代码的解释和执行。我们就拿浏览器这个宿主环境来说。众所周知,setTimeout()
API 是用于调度异步代码的,但是你又知不知道,setTimeout()
API 根本都不是 ECMAScript 语言规范的一部分,所以它的实现并不是由 js 引擎来做的,而是各个浏览器自己有自己的实现。
While famously known as "JavaScript Timers", functions like setTimeout and setInterval are not part of the ECMAScript specs or any JavaScript engine implementations. Timer functions are implemented by browsers and their implementations will be different among different browsers.
关于这一点,不妨看看这篇文章:JavaScript Timers: Everything you need to know。
之所以提及这一点,是因为要想完整地去理解 js 实现异步编程的原理,必须补上「宿主环境负责异步代码调度」这一环的理解。
综上,理解 js 中异步编程原理完整的心智模型应该是这样的:
js 异步编程的过去
事件模式
Event handlers are really a form of asynchronous programming: you provide a function (the event handler) that will be called, not right away, but whenever the event happens. MDN 如是说道。
由于 js 这门语言天生是事件驱动的。所以,基于事件来进行异步编程是刻在在 js 的基因里面的。基于事件来进行异步编程实在是太常见了,以至于大家都熟视无睹,很少人想到这个模式其实就是异步编程。js + 宿主环境的组合中,实在有太多事件了。我就拿最常见的「click」事件来说吧。下面的代码就是一种异步编程:
js
<script>
console.log('我是同步的 log1');
// 假设我们在这里肯定能获取到 testEle 这个 DOM
const testEle = document.getElementById('test');
testEle.onclick = function(){
console.log('我是异步的 log');
}
console.log('我是同步的 log2');
</script>
浏览器会自上而下地解析 HTML,从 js 方面来看,所有的 <script>
的代码都会被汇总为 main line code。主线代码可以看做 event loop 的第一个宏任务。假设我们现在的页面只有上面这么一个<script>
那么。那么执行上面的代码就是依次把它们先推入 call stack,执行完毕之后,就弹出 call stack:
console.log('我是同步的 log1')
先入栈,执行完之后弹出调用栈,此后调用栈为空;document.getElementById('test')
入栈,执行完之后把结果保存在变量testEle
中,此后调用栈为空;- 当执行到
testEle.onclick = function(){ console.log('我是异步的 log'); }
的时候,js 就通知浏览器的 UI 进程,告诉它对testEle
保持点击事件的监听。这句代码其实是没有进入调用栈去执行的; - 就像步骤 1 一样;
- 当用户点击界面的时候,UI 进程就会把
function(){ console.log('我是异步的 log'); }
放到微任务队列里面去。然后,event loop 会执行任务代码之前去检查一下调用栈是否为空。当下,我们的调用栈是空的,那么它就把微任务队列里面唯一一个微任务推入到调用栈去执行。然后,点击事件的处理器就会被执行,打印出'我是异步的 log'
, 最后,把这个函数帧弹出,调用栈恢复为空。
综合下来,上面的代码的打印结果是:
js
我是同步的 log1
我是同步的 log2
我是异步的 log
基于事件的异步编程模式因为是跟 js 这门语言相伴相随生的,所以我们甚至不能说它是 js 早期的异步编程模式。它是流转在 js 血液中,刻画在 js 基因里面的东西。我相信,在 js 这门语言的未来很长的一段时间里面,我们还是能看到它,并且需要使用它。
虽然它十分的根正苗红,但是事件模式有一两个微妙的不足点:
- 它难以组合;
- 不宜异步注册事件处理器;
难以组合
在异步编程里面,有一个频率很高的应用场景,那就是需要把几个异步任务串联执行(串行)。
什么情况下,需要把几个异步任务串行呢?答曰:"异步任务之间有依赖关系。这种依赖关系大概可以分为两种:1) 任务结果的依赖。也就是说后者的任务需要依赖前者的任务执行结果;2)时序上的依赖。也就是说,后者的任务必须要等到前面的任务执行完毕才能开始执行。"
比如上面的示例代码中。现在,我们制造一个假需求:"现在,我们需要在 testEle
被点击后,在去对另外一个 DOM 节点 testEle2
去做它的点击事件的监听。" 这个时候,代码可能这么写:
js
<script>
console.log('我是同步的 log1');
// 假设我们在这里肯定能获取到 testEle 这个 DOM
const testEle = document.getElementById('test');
const testEle2 = document.getElementById('test2');
testEle.onclick = function(){
console.log('我是异步的 log');
testEle2.onclick = function(){
console.log('我是异步的 log2');
}
}
console.log('我是同步的 log2');
</script>
这么写是有问题的。随着 testEle
的点击事件不断地触发,testEle2
相同的点击事件处理器会不断地被注册,长久以往,这会导致内存泄露。为了解决这个问题,我们要么是加多一个开关去做防守,要么就采用 DOM2 的 removeEventListener()
来在注册事件处理器之前先移除之前的事件处理器。
虽然在实际应用中,我们不太会有这样的需求。但是,不管怎么说,这也证明了基于事件模式的异步编程不太欢迎我们去组合代码。一般来说,所有的事件监听都是在同一层次的代码上完成的。
不宜异步注册事件处理器
现代的 js 引擎和它的各种宿主环境都实现了「事件的触发与事件的监听在时序上无关」。这句话什么意思呢?假设你要监听图片加载的 load 事件,你的代码既可以这么写:
js
const img = new Image();
// 事件监听的代码在前面
img.onload = ()=>{console.log("图片加载成功")}
// 事件触发的代码在后面
img.src = "https://developer.mozilla.org/pimg/aHR0cHM6Ly9zLnprY2RuLm5ldC9BZHZlcnRpc2Vycy9kYjQwZjA0NDVhYTE0YmY0OTNhY2VmY2ZiYWZhZTUyYS5qcGc%3D.CGl3ytMDfvm40t24fwKsJJU2C2E54yqm%2Bov9hqCzADs%3D";
也可以这么写:
js
const img = new Image();
// 事件触发的代码在前面
img.src = "https://developer.mozilla.org/pimg/aHR0cHM6Ly9zLnprY2RuLm5ldC9BZHZlcnRpc2Vycy9kYjQwZjA0NDVhYTE0YmY0OTNhY2VmY2ZiYWZhZTUyYS5qcGc%3D.CGl3ytMDfvm40t24fwKsJJU2C2E54yqm%2Bov9hqCzADs%3D";
// 事件监听的代码在后面
img.onload = ()=>{console.log("图片加载成功")}
从语义上来讲,下面的步骤
- 先注册事件处理器;
- 然后再去触发事件 才是正确的做法。但是,现在的 js 引擎利用 event loop 已经实现了这两个步骤的时序无关性。也就是说,事件监听的代码放在后面也是可以的,也能实现事件监听的目标。
是不是事件监听放在哪里都能监听成功呢?不是的。事件的监听不能放在异步代码里面。更严谨点来说,是不能放在时间间隔有点长的异步代码里面。请看下面的代码:
js
const img = new Image();
setTimeout(()=>{
img.onload = ()=>{
console.log('image loaded!')
}
},1000)
img.src = "https://developer.mozilla.org/pimg/aHR0cHM6Ly9zLnprY2RuLm5ldC9BZHZlcnRpc2Vycy9kYjQwZjA0NDVhYTE0YmY0OTNhY2VmY2ZiYWZhZTUyYS5qcGc%3D.CGl3ytMDfvm40t24fwKsJJU2C2E54yqm%2Bov9hqCzADs%3D"
上面的代码能在 1 秒之后打印出image loaded!
不能。它会在 img 的下一个 load 事件发生的时候打印出来。但是,当你把上面的 timeout 时间改为 0
的时候,然后重新执行一下代码,我们又能得到我们的预期结果。所以,这里就有一个坑,当事件监听发生在异步代码里面的时候,而我们又想事件处理器的执行是发生在事件触发的同一个 event loop tick 里面的话,那么里面的这个时间差是很难把握的。所以,这种类似于 bug 的行为表现,我们就说「不宜异步注册事件处理器」(当然,你如果想事件处理器在下一个 event loop tick 被调用,那这么做是可以的)。
回调模式
随着时间的推移,js 的异步编程引入了「回调模式」。说到回调模式,相信大家都不陌生。setTimeout(callback, timeout)
接口就是典型的异步回调模式。类似的回调模式代码还有很多,jquery 时代随处可见的 ajax(url, callback)
这种写法也是。在 nodejs 中,ES6 还没有问世之前,几乎所有的文件读写 API 都是采用了回调模式来实现。我们甚至可以说,上面的事件模式本质上也是回调模式。
回调模式下的异步编程的本质是什么?本质就是利用 js 中「函数可以被当做值来传递」的特性,把回调函数传给第一方(自己编写的代码)或者第二方(宿主环境)又或者第三方(导入的外部工具代码),然后交由他们来在一段间隔时间之后去调用。
从实现功能的角度来看,回调模式是没有问题的。我们甚至可以说它实现起来的成本很低。但是,软件编程并不是一件只关乎「功能实现」的事情。还有很多要素需要我们考虑。如此说来,回调模式有以下的问题:
- 有非功能方面的问题;
- 信任问题 ;
- 错误传递问题 - 第三方工具库有可能吞掉异步任务执行过程中出现的错误
有非功能方面的问题
这个非功能方面的问题就是指「在多个异步任务串行的场景下,基于回调模式所实现的代码的可读性和可维护性都很差」。我们拿上面提到的 ajax()
API 举例。假如现在我们必须等第一个请求把结果获取回来,然后作为第二个请求的参数来发起第二个请求;最后必须等第二个请求把结果获取回来,然后作为第二个请求的参数来发起第三个请求。第三个的请求结果才是我们最终想要的结果。那么实现这个串行的异步任务流的基于回调模式的代码就是应该这么写的:
js
// 上面还有代码 A
ajax('/api/task1', (v1)=>{
ajax('/api/task2?query=' + v1, (v2)=>{
ajax('/api/task3?query=' + v2, (v3)=>{
console.log('final result: ', v3)
})
})
})
// 下面还有代码 B
看到上面的代码,你想到了什么没有?聪明如你,肯定一眼就想到了这就是人尽皆知的「回调地狱」或者「厄运金字塔」。从功能的角度来看,回调地狱是没有问题的。但是从代码的其他维度来看,它就有着『可读性和可维护性差』的问题。
为什么这么说呢?因为人类大脑天生是遵循线性的,顺序的,阻塞型的思维模式。按照这种思维模式来想,人类的大脑才会感觉到自然,身体也觉得舒畅。倘若违背了,大脑就开始卡顿,身体也会感觉到吃力。基于回调模式的异步编程模型在多个异步任务编排的应用场景下会把『现在执行的代码』和『未来要执行的代码』依次交替到一块,这会使得代码的阅读和定位成本变高。
倘若我们要改动代码,必须要圈定改动代码的界限。要想圈定要改动的代码,首先得通过阅读找出界限。大脑在阅读上面的代码的时候,它首先要找出两种类型界限:
- 任务与任务之间的界限
- 现在代码与未来代码的界限
所以,大脑是这么读上面的代码的:
html
现在(界限)未来
(界限)未来的现在(界限)未来的未来
(界限)未来的现在的现在(界限)未来的现在的未来
而在众多类似的代码中定位到对应界限内的代码,开发成本并不低。所以我们说(在复杂的应用场景下)基于回调模式的代码的『可读性和可维护性差』。
信任问题
回调模式下,我们的代码是被其他方代码所调用的。如果这一方是第二方,也就是说 js 引擎所在的宿主环境,那还好。因为这些平台的代码毕竟是出自全球一流人才之手,代码质量绝对是一流的。我们可以放心地信任他们 - 觉得它们肯定是如期地调用我们的代码。但是,如果这一方是第三方,如果还说是一个陌生的,小众的,新兴的第三方合作平台或者工具库的话,那么就很有可能会面临下面的信任问题:
- 没有调用我们的代码
- 过早或者过晚调用我们的代码
- 调用次数调多了或者调了
- 调用我们的代码后没有给到我们想要的结果
对于我们来说,第三方合作平台或者工具库就是黑盒。我们根本不知道现在还好好的代码会不会在下一秒,等它们发布了一个带 bug 的新版本,然后我们也重新构建了生成环境(意味着引入了他们的新代码),在这个时候却产生了上面的不信任问题。
也许你会说,出现上面的问题也没啥问题吧。好吧,设想我们有一个电商应用,我们接入了第三方(类似于谷歌的 GA)的数据分析平台,现在我们需要利用它的接口来先把用户的订单数据上传到自己的平台,然后回调我们自己的支付接口:
js
analytics.report(orderData,()=>{
chargeCreditCard();
displayOrderSummary();
})
在我们应用初次上线的时候,我们测试过了,是没有问题的。但是,某个周末,当你准备继续睡懒觉的时候,老板的一封电话打过来了,他气急败坏地说:"你赶紧过来公司一趟,有一个客户打电话骂我们了,说自己的信用卡被反复扣钱了。这个问题很严重,如果不处理好,大家明天都不用来公司了。"
于是乎,你一个鲤鱼打挺,连牙都没有刷,脸也没有洗,打了一辆车直奔公司。途中,你一脸黑人问号,在想:"干,之前不都是好好的吗?我最近也没有动过代码,发过版啊!到底是因为什么呢?"
回到公司,一番排查下来,你终于发现就是回调函数的问题。你自己也下了血本,买了一单,结果发现我们的负责调用支付接口的回调函数会被间隔性地调用了好几次。于是乎,你想杀人的心都有了。
通过上面的案例,你感受到了回调函数的可信任性是有多么重要了吧。假如你觉得问题不大,那是因为你的回调跟钱没有挂上钩。一旦挂钩了,问题就大得去了。
我们的代码自从交由第三方去调用的那一刻,实际上我们就失去了对代码的控制权。我们自己写的代码并不是由我们自己控制,此谓之「控制反转」。这种信任问题,按理说,无论选择了多么权威的第三方都是存在的。不同的第三方只不过意味着出现信任问题概率是不同的而已。
总的来说,基于回调模式下的代码会因为控制反转而容易出现了信任问题。
错误传递问题
基于回调模式的异步代码该怎么处理错误呢?出个题目:"请问,下面的代码能不能成功捕获错误呢?"
js
try{
analytics.report(orderData,()=>{
chargeCreditCard();
displayOrderSummary();
})
}catch(e){
console.error('数据上报过程中出现错误了', e);
}
答案是:"不能"。稍微深入学习过 js 错误处理的人都知道,try..catch
只能捕获同步代码里面的错误或者异常,对异步代码无能为力。
倘若错误是来自于我们自己的回调函数,我们可以在回调函数里面用try..catch
去捕获,这没有问题:
js
analytics.report(orderData,()=>{
try{
chargeCreditCard();
displayOrderSummary();
}catch(e){
console.error('回调函数执行出现错误了', e);
}
})
但是如果我们想要捕获执行异步任务过程第三方自己的错误呢?对不起,如果对方没有回传给我们,我们是无能为力的。如果遇到一些在代码设计比较粗糙的第三方,那么他们很有可能是不会向你暴露错误信息的 - 要么是他们自己吞掉了,要么是他们根本就没有错误处理方面的处理。
遇到好一点的第三方,可能会采用类似于 nodejs 所有异步接口所采用的 error-first 一样的接口设计,比如:
js
analytics.report(orderData,(err, data)=>{
if(err){
throw(err)
}
// do something
})
也有可能采用 callback-split 的接口设计,比如:
js
ajax('https://api/test', function onSuccess(result){
// ...
}, function onError(err){
// ...
})
如此一来,我们就能得到第三方在执行异步任务过程中所产生的错误。
假如所有的第三方都提供了这样的错误暴露接口设计,那是不是意味着回调模式下的错误传递就是没有问题了呢?不是的。
试想一下,我们现在处于多个异步任务串行的业务场景下,那么为了处理错误,我们必须要重复地,逐个逐个地对异步任务中出现的错误进行处理:
js
function processError(error){
// do something with the error
}
ajax('/api/task1', (err, v1)=>{
if(err){
processError(err);
retrun
}
ajax('/api/task2?query=' + v1, (err, v2)=>{
if(err){
processError(err);
retrun
}
ajax('/api/task3?query=' + v2, (err, v3)=>{
if(err){
processError(err);
retrun
}
console.log('final result: ', v3)
})
})
})
事实上,我们只想要处理一次错误情况就好。
以上就是我们所说的「错误传递问题」。现在总结一下这种问题可以划分为三种情况:
- 异步代码所出现的错误无法用
try..catch
来捕获; - 回调模式在第三方工具代码中没有统一的规范实现,第三方很容易吞掉错误或者不回传错误;
- 即使采用了 error-first 或者 callback-split 的接口风格实现了错误回传,但是在复杂的异步场景下,处理错误的成本还是不够高效。
总结
无论是事件模式」还是「回调模式」,它们所能提供的异步编程生产力已经无法匹配开发者在大规模项目对这方面能力的要求。幸运的是,JS 社区众人拾柴火焰高,同心协力推出了 ES6。至此,我们进入了 modern javascript 的历史新阶段。
在现代 js 中,ECMAScript 给我们带了 js 异步编程的「新配方」。那就是:
- promise(ES2015/ES6)
- generator(ES2015/ES6)
- async..await(ES2017/ES8)
不过话说回来,不管是哪个模式,迄今为止,js 异步编程的能力都是建立在一个共同的底座之上 - event loop。
在下篇中,我们一起来看看这些新配方是如何解决我们上面所提到模式的不足点,从而极大地提高我们异步编程的生产力的。