同步中的异步
在ES6中新增了asgnc...await...
的异步解决方案,对于这种方案,有多种操作姿势,比如这样
const asyncReadFile = async function(){
const f1 = await readFile('/etc/fstab')
const f2 = await readFile('/etc/shells')
console.log(f1.toString())
console.log(f2.toString())
}
或者是这样
async function f(){
try{
await new Promise.reject('出错了')
} catch(e){
}
return await Promise.resolve('hello yerik')
}
是否能发现这两种使用方式的各自的特点:
-
async...await...
异步解决方案支持同步的方式去执行异步操作 -
async...await...
异步解决方案支持通过try...catch...
进行异常捕获
对于第一点来说还好理解,但第2种说法就很费解了,以至于有一种颠覆以往理解的绝望感,对于js的世界观都已经灰色。对于try...catch...
来说,不都是同步执行过程中捕获异常的吗,为何在async...await...
中的try...catch...
可以捕获异步执行的异常呢?
这个时候就去翻一下阮一峰老师的ES6教程,还以为是我当年看书走眼了,忘了啥,查漏补缺,结果阮老师就这么轻飘飘一句话
┑( ̄Д  ̄)┍
时间和空间上的分离
阮老师,您说的是平行时空么?还是错位空间?
我吹过你吹过的晚风
那我们算不算 相拥
我遇到过你发现的error,那我们算不算相拥,反正我读完也是挺郁闷的,阮老师那种在大气层的理解,对于普通人的我还是需要一层层剖析才能理解,那就先按照自己的理解来说吧,大家一起探讨一下,看看有没有道理
我们知道对于nodejs
的异步实现都是借助libuv
其他线程完成的。正常情况下,当eventloop
通知调用栈处理异步回调函数的时候,原调用栈种的函数应该已经执行完了,因此调用函数和异步逻辑是由完全不同的线程执行的,本质上是没有交集的,这个时候可以理解为空间上是隔离的。异步回调被触发执行时,调用函数早已执行结束,因而,回调函数和调用函数的执行在时间上也是隔离的
好了,时空隔离的问题,勉强解释通了,但是async...await...
又是怎么打破这种隔离,让其中的try...catch...
可以捕获到异步操作中的异常?曾经大胆猜测,async...await...
可以强行拉长try...catch...
作用域,让调用函数的生命周期可以尽量延长,以至于可以等待直到异步函数执行完成,在此期间如果异步过程出现异常,调用函数就可以捕捉到,然而这个延长函数生命周期并等待异步执行结束,这不就是相当于是在阻塞线程的执行?阻塞执行——这跟JS的非阻塞的特质又是背道而驰的。
至此我总觉得在调用函数和异步逻辑之间存在某种诡异的tunnel,对!说的就是那股风!其可以在主函数和异步函数这两个不同时空互相隔离的生物进行消息传递,比如说在时空A中捕获了时空B里面的异常消息,这样它们就可以相拥
怎么想都觉得这个过程离大谱!
try…catch…不能捕获异步异常
try...catch...
能捕获到的仅仅是try
模块内执行的同步方法的异常(try执行中且不需要异步等待),这时候如果有异常,就会将异常抛到catch
中。
除此之外,try...catch...
执行之前的异常,以及try...catch...
内的异步方法所产生的异常(例如ajax请求、定时器),都是不会被捕获的!看代码
这段代码中,setTimeout
的回调函数抛出一个错误,并不会在catch
中捕获,会导致程序直接报错崩掉。
这说明在js
中try...catch...
并不是说写上一个就可以高枕无忧。尤其是在异步处理的场景下。
那这个问题是怎么来的呢?
我从网上扒了个动图,可以比较形象的解释这个问题。图中演示了foo
,bar
,tmp
,baz
四个函数的执行过程。同步函数的执行在调用栈中转瞬即逝,异步处理需要借助libuv
。比如这个setTimeout
这个Web API,它独立于主线程中的libuv
中别的线程负责执行。执行结束吼,会将对应回调函数放到等待队列中,当调用栈空闲吼会从等待队列中取出回调函数执行
const foo = ()=>console.log('Start!')
const bar = ()=>setTimeout(()=>console.log('Timeout!'), 0)
const tmp = ()=>Promise.resolve('Promise!').then(res=>console.log(res))
const baz = ()=>console.log('End!')
foo();
bar();
tmp();
baz();
不能捕获的原因
为了讲清楚不能被捕获的原因,我改一下代码,模拟异步过程发生了异常。大家可以把执行逻辑再套回刚才的动图逻辑再看一下,(后面有机会学习怎么做动图哈哈哈)
const bar = ()=> {
try{
setTimeout(()=>{
throw new Error()
}, 500)
}catch(e){
// catch error.. don't work
}
}
当setTimeout
的回调在Queue
排队等待执行的时候,Call Stack
中的bar
就已经执行完了,bar
的销毁顺便也终止了try...catch...
的捕获域。当主进程开始执行throw new Error()
的时候,相当于外层是没有任何捕获机制的,该异常会直接抛出给V8进行处理
回调函数无法捕获?
因为大部分遇到无法catch
的情况,都发生在回调函数,就认为回调函数不能catch
,这个结论是对的吗?
只能说不一定,且看这个例子
// 定义一个 fn,参数是函数。
const fn = (cb: () => void) => {
cb();
};
function main() {
try {
// 传入 callback,fn 执行会调用,并抛出错误。
fn(() => {
throw new Error('123');
})
} catch(e) {
console.log('error');
}
}
main();
结果当然是可以catch
的。因为callback
执行的时候,跟main
还在同一次事件循环中,即一个eventloop tick
。所以上下文没有变化,错误是可以catch
的。 根本原因还是同步代码,并没有遇到异步任务。
如何捕获?
简单来说就是哪里抛异常就在哪里捕获
const bar = ()=> {
setTimeout(()=>{
try{
throw new Error()
}catch(e){
// catch error.. don't work
}
}, 500)
}
那这样写代码一点都不会快乐了,要出处小心,时候留意以防哪里没有考虑到异常的场景。
基于Promise的解决方案
所谓Promise
,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise
是一个对象,从它可以获取异步操作的消息。Promise
提供统一的 API,各种异步操作都可以用同样的方法进行处理。
本质上,这个就是一个状态管理机,同时又提供resolve
和reject
两个开关。resolve
负责将状态机的状态调整成Fulfilled
,reject
将状态处理成Rejected
。
对于Promise
来说是如何处理异常的?我们不妨通过改造前面的代码来试试
code1
function bar(){
new Promise((resolve, reject)=>{
setTimeout(()=>{
// 通过throw抛出异常
throw new Error('err')
}, 500)
})
}
function exec(){
try{
bar().then(res=>{
console.log('res', res)
})
}catch(err){
console.log('err has been caught in try-catch block')
}
}
在这个过程中,尝试抛出全局异常Uncaught Error
,然而try...catch...
并没有捕获到。造成这个问题的原因还是在于异常抛出的时候,exec
已经从执行栈中出栈了,此外,在Promise
规范里有说明,在异步执行的过程中,通过throw
抛出的异常是无法捕获的,异步异常必须通过reject
捕获
code2
function bar(){
return new Promise((resolve, reject)=>{
setTimeout(()=>{
reject('err')
}, 500)
})
}
function exec(){
try{
bar().then(res=>{
console.log('res', res)
})
}catch(err){
console.log('err has been caught in try-catch block')
}
}
这次通过reject
抛出异常,但是try...catch...
同样还是没有捕获到异常。原因是reject
需要配合Promise.prototype.catch
一起使用
code3
function bar(){
return new Promise((resolve, reject)=>{
setTimeout(()=>{
reject('err')
}, 500)
})
}
function exec(){
try{
bar().then(res=>{
console.log('res', res)
}).catch(err=>{
// Promise.prototype.catch捕获异常
console.log('err has been caught in promise catch')
})
}catch(err){
console.log('err has been caught in try-catch block')
}
}
这次,异常成功地通过Promise.prototype.catch
捕获到了,现在我们完全可以确定,在Promise
中,异常的捕获跟try...catch...
没有什么关系。
code4
至此我们已然通过try...catch...
捕获异常的测试,那如果采用async...await...
的方式呢?
function bar(){
return new Promise((resolve, reject)=>{
setTimeout(()=>{
reject('err')
}, 500)
})
}
async function exec(){
// trycatch 捕获异常
try{
await bar()
}catch(err){
console.log("err has been caught in try-catch block")
}
}
惊讶的发现,通过这样的方式,我们终于通过try...catch...
捕捉到了异常!对于code3和code4来说,我们的差异在于采用了async...await...
,而这,到底是什么原理来实现的呢?至此,问题的根源我们已经模拟出来了,接下来是剖析
小结
Promise
必须为以下三种状态之一:等待态Pending
、执行态Fulfilled
和拒绝态Rejected
。一旦Promise
被resolve
或reject
,不能再迁移至其他任何状态(即状态immutable
。
基本过程:
- 初始化
Promise
状态pending
- 立即执行
Promise
中传入的fn
函数,将Promise
内部resolve
、reject
函数作为参数传递给fn
,按事件机制时机处理 - 执行
then(..)
注册回调处理数组(then
方法可被同一个promise
调用多次) -
Promise
里的关键是要保证,then
方法传入的参数onFulfilled
和onRejected
,必须在then
方法被调用的那一轮事件循环之后的新执行栈中执行。
对于Promise
来说,本质上也是基于回调的,只要是基于回调,那就同样无法摆脱try...catch...
不能捕获异步异常的事实。不过在Promise
规范中有一套自己的异常处理逻辑,尽管这并不能打破时空上的隔离,但由于其将异步的异常逻辑封装在回调逻辑中,当Promise
的状态发生改变时,将错误或异常以回调的形式呈现出来
虽然Promise
的出现很大程度改变了编程的习惯,不过嘛,这个机制还是有问题的,毕竟其运行的过程非常依赖内部状态的控制,我们知道Promise
的状态控制是非常依赖resolve
和reject
,这就意味着,我们必须很清楚明白异常会出现在哪里,然后异常出现的地方需要通过reject
的方式将Promise
的状态调整成Rejected
,也就说,我们需要很明确代码要在什么地方执行reject
异常本无形,它的出现不一定可控,在工程实践的过程中又是大肠包小肠,层层套娃,Promise
可以处理我们已经明确的异常,那么那些不明确的又需要怎么处理呢?为了从本质上处理这个问题,async...await...
由此而生
async&await今生
啰啰嗦嗦说了这么多,铺垫了async...await...
的诞生背景——为了解决异常跨越时空的问题,这部分则是解释async...await...
实现的原理,是的,就是那股风的来源,风起之处——Generator
Generator
Generator
函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
协程
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做协程coroutine
,意思是多个线程互相协作,完成异步任务。
对于协程来说其有点像函数,又有点像线程。它的运行流程大致如下
- 协程A开始执行。
- 协程A执行到一半,进入暂停,任务挂起,执行权转移到协程B。
- (一段时间后)协程B交还执行权。
- 协程A恢复执行。
上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。
举例来说,读取文件的协程写法如下。
function* asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}
上面代码的函数asyncJob
是一个协程,它的奥妙就在其中的yield
命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield
命令是异步两个阶段的分界线。
协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它如果去除yield
命令,这种写法非常跟同步操作相比,不要说相似,简直一模一样。
协程的 Generator 函数实现
Generator
函数是协程在ES6
的实现,最大特点就是可以交出函数的执行权,即暂停执行。
整个Generator
函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield
语句注明。Generator
函数的执行方法如下。
function* gen(x) {
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
上面代码中,调用Generator
函数,会返回一个内部指针(即遍历器)g。这是Generator
函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next
方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield
语句,上例是执行到x + 2
为止。
换言之,next
方法的作用是分阶段执行Generator
函数。每次调用next
方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield
语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator
函数是否执行完毕,即是否还有下一个阶段。
异常捕获
Generator
函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:
- 函数体内外的数据交换
- 错误处理机制。
注意观察代码中的两个next
的不同
function* gen(x){
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }
next
返回值的value
属性,是Generator
函数向外输出数据
next
方法还可以接受参数,向Generator
函数体内输入数据。
上面代码中,第一个next方法的value属性,返回表达式x + 2
的值3。第二个next
方法带有参数2,这个参数可以传入Generator
函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。因此,这一步的value属性,返回的就是2(变量y的值)。
Generator
函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
g.next();
g.throw('出错了');
// 出错了
上面代码的最后一行,Generator
函数体外,使用指针对象的throw
方法抛出的错误,可以被函数体内的try...catch
代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
异步任务的封装
下面看看如何使用Generator
函数,执行一个真实的异步任务。
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}
上面代码中,Generator
函数封装了一个异步操作,该操作先读取一个远程接口,然后从JSON
格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield
命令。
执行这段代码的方法如下。
var g = gen();
var result = g.next();
result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});
上面代码中,首先执行Generator
函数,获取遍历器对象,然后使用next
方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个Promise
对象,因此要用then
方法调用下一个next
方法。
可以看到,虽然Generator
函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。
co模块
co 模块是著名程序员TJ Holowaychuk
于 2013 年 6 月发布的一个小工具,用于Generator
函数的自动执行。
var gen = function* () {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
co
模块可以让你不用编写Generator
函数的执行器。
var co = require('co');
co(gen);
上面代码中,Generator
函数只要传入co函数,就会自动执行。
co
函数返回一个Promise
对象,因此可以用then
方法添加回调函数。
co(gen).then(function (){
console.log('Generator 函数执行完成');
});
上面代码中,等到Generator
函数执行结束,就会输出一行提示。
异步实现
先回答了异步实现的前置条件——基于协程,之后我们再来看看异步的关键词async
。
ES2017
标准引入了async
函数,使得异步操作变得更加方便。
async
函数是什么?一句话,它就是Generator
函数的语法糖。
前文有一个Generator
函数,依次读取两个文件。
const fs = require('fs');
const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
};
const gen = function* () {
const f1 = yield readFile('/etc/fstab');
const f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
上面代码的函数gen
可以写成async
函数,就是下面这样。
const asyncReadFile = async function () {
const f1 = await readFile('/etc/fstab');
const f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
一比较就会发现,async
函数就是将Generator
函数的星号(*)
替换成async
,将yield
替换成await
,仅此而已。
async
函数对Generator
函数的改进,体现在以下四点。
- 内置执行器。
asyncReadFile();
-
Generator
函数的执行必须靠执行器,所以才有了co
模块,而async
函数自带执行器。也就是说,async
函数的执行,与普通函数一模一样,只要一行。
上面的代码调用了asyncReadFile
函数,然后它就会自动执行,输出最后结果。这完全不像Generator
函数,需要调用next
方法,或者用co
模块,才能真正执行,得到最后结果。
- 更好的语义。
-
async
和await
,比起星号和yield
,语义更清楚了。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。
- 更广的适用性。
-
co
模块约定,yield
命令后面只能是Thunk
函数或Promise
对象,而async函数的await命令后面,可以是Promise
对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
- 返回值是
Promise
。
-
async
函数的返回值是Promise
对象,这比Generator
函数的返回值是Iterator
对象方便多了。你可以用then
方法指定下一步的操作。
进一步说,async
函数完全可以看作多个异步操作,包装成的一个Promise
对象,而await
命令就是内部then
命令的语法糖。
async实现原理
本质上是将Generator
函数和自动执行器,包装在一个函数里。
async function fn(args) {
// ...
}
// 等同于
function fn(args) {
return spawn(function* () {
// ...
});
}
所有的async
函数都可以写成上面的第二种形式,其中的spawn
函数就是自动执行器。
下面给出spawn
函数的实现,基本就是前文自动执行器的翻版。
function spawn(genF) {
return new Promise(function(resolve, reject) {
const gen = genF();
function step(nextF) {
let next;
try {
next = nextF();
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
});
}
await分析
根据语法规格,await
命令只能出现在async
函数内部,否则都会报错。
// 报错
const data = await fetch('https://api.github.com');
上面代码中,await
命令独立使用,没有放在async
函数里面,就会报错。
目前,有一个语法提案,允许在模块的顶层独立使用await
命令,使得上面那行代码不会报错了。这个提案的目的,是借用await
解决模块异步加载的问题。
// awaiting.js
let output;
async function main() {
const dynamic = await import(someMission);
const data = await fetch(url);
output = someProcess(dynamic.default, data);
}
main();
export { output };
上面代码中,awaiting.js
除了输出output
,还默认输出一个Promise
对象(async 函数立即执行后,返回一个Promise
对象),从这个对象判断异步操作是否结束。
下面是加载这个模块的新的写法。
// usage.js
import promise, { output } from "./awaiting.js";
function outputPlusValue(value) { return output + value }
promise.then(() => {
console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100), 1000);
});
上面代码中,将awaiting.js
对象的输出,放在promise.then()
里面,这样就能保证异步操作完成以后,才去读取output
。
这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块。一旦你忘了要用Promise
加载,只使用正常的加载方法,依赖这个模块的代码就可能出错。而且,如果上面的usage.js
又有对外的输出,等于这个依赖链的所有模块都要使用Promise
加载。
顶层的await
命令,就是为了解决这个问题。它保证只有异步操作完成,模块才会输出值。
// awaiting.js
const dynamic = import(someMission);
const data = fetch(url);
export const output = someProcess((await dynamic).default, await data);
上面代码中,两个异步操作在输出的时候,都加上了await
命令。只有等到异步操作完成,这个模块才会输出值。
加载这个模块的写法如下。
// usage.js
import { output } from "./awaiting.js";
function outputPlusValue(value) { return output + value }
console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100), 1000);
上面代码的写法,与普通的模块加载完全一样。也就是说,模块的使用者完全不用关心,依赖模块的内部有没有异步操作,正常加载即可。
这时,模块的加载会等待依赖模块(上例是awaiting.js
)的异步操作完成,才执行后面的代码,有点像暂停在那里。所以,它总是会得到正确的output
,不会因为加载时机的不同,而得到不一样的值。
小结
协程的引入具备了挂起自己和被重新唤醒的能力。可以想象一下,协程在被中断吼,是需要某种机制来保存当前执行的上下文。在空间上,协程初始化创建的时候为其分配的栈一定的栈空间,用来保存执行过程中的一些关键信息,当函数被唤醒后,通过栈内保存的信息恢复”案发现场”。
总结
至此,前面code4中的案例就解释通了,await
的时候exec
函数被挂起,等bar
函数中的异步操作执行结束后,exec
函数被恢复。此时恢复的还有try...catch...
。这个时候可以发现Promise
的状态已经通过reject
触发,由于没有Promise.prototype.catch
,所以这个时候Promise
会把异常向外抛出,正好被try...catch...
捕捉到,这个时候,确实如前文所猜测,在async...await...
中try...catch...
就是守株待兔,并且最后还真的等到了!
Sync invoke an async function and await its returned awaitable object.
参考资料
- https://www.w3cschool.cn/escript6/escript6-q9rw37fc.html
- https://www.bookstack.cn/read/es6-3rd/spilt.3.docs-generator-async.md
- https://juejin.cn/post/6844903830409183239
- https://juejin.cn/post/6844904063570542599
- https://segmentfault.com/a/1190000009478377
文章版权声明
1 原创文章作者:3721,如若转载,请注明出处: https://www.52hwl.com/36230.html
2 温馨提示:软件侵权请联系469472785#qq.com(三天内删除相关链接)资源失效请留言反馈
3 下载提示:如遇蓝奏云无法访问,请修改lanzous(把s修改成x)
4 免责声明:本站为个人博客,所有软件信息均来自网络 修改版软件,加群广告提示为修改者自留,非本站信息,注意鉴别