Iterator-Generator协议与async、await实现原理推导
我们都知道,Promise解决了回调地狱的问题,但是如果遇到复杂的业务,代码里面会包含大量的 then 函数,使得代码依然不是太容易阅读。
基于这个原因,ES7 引入了 async/await
,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰,而且还支持 try-catch
来捕获异常,非常符合人的线性思维。
所以,要研究一下如何实现 async/await
。总的来说,async
是Generator函数的语法糖,并对Generator函数进行了改进。
Generator函数简介
Generator 函数是一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,可以依次遍历 Generator 函数内部的每一个状态,但是只有调用next
方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield
表达式就是暂停标志。
有这样一段代码:
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}
var hw = helloWorldGenerator();
调用及运行结果:
hw.next()// { value: 'hello', done: false }
hw.next()// { value: 'world', done: false }
hw.next()// { value: 'ending', done: true }
hw.next()// { value: undefined, done: true }
由结果可以看出,Generator
函数被调用时并不会执行,只有当调用next
方法、内部指针指向该语句时才会执行,即函数可以暂停,也可以恢复执行。每次调用遍历器对象的next
方法,就会返回一个有着value
和done
两个属性的对象。value
属性表示当前的内部状态的值,是yield
表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
Generator函数暂停恢复执行原理
要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。
一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收回执行权的时候,再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。
协程是一种比线程更加轻量级的存在。普通线程是抢先式的,会争夺cpu资源,而协程是合作的,可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。它的运行流程大致如下:
- 协程A开始执行
- 协程A执行到某个阶段,进入暂停,执行权转移到协程B
- 协程B执行完成或暂停,将执行权交还A
- 协程A恢复执行
协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield
命令,简直一模一样。
执行器
通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器,co
模块就是一个著名的执行器。
Generator
是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点:
回调函数。将异步操作包装成 Thunk
函数,在回调函数里面交回执行权。
Promise
对象。将异步操作包装成 Promise
对象,用then
方法交回执行权。
一个基于 Promise
对象的简单自动执行器:
function run(gen){
var g = gen();
function next(data){
var result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data){
next(data);
});
}
next();
}
我们使用时,可以这样使用即可,
function* foo() {
let response1 = yield fetch('https://xxx') //返回promise对象
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://xxx') //返回promise对象
console.log('response2')
console.log(response2)
}
run(foo);
上面代码中,只要 Generator 函数还没执行到最后一步,next
函数就调用自身,以此实现自动执行。通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。
async/await
ES7 中引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。可以说async 是Generator函数的语法糖,并对Generator函数进行了改进。
前文中的代码,用async
实现是这样:
const foo = async () => {
let response1 = await fetch('https://xxx')
console.log('response1')
console.log(response1)
let response2 = await fetch('https://xxx')
console.log('response2')
console.log(response2)
}
一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。
async函数对 Generator 函数的改进,体现在以下四点:
- 内置执行器。Generator 函数的执行必须依靠执行器,而 async 函数自带执行器,无需手动执行 next() 方法。
- 更好的语义。async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
- 更广的适用性。co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
- 返回值是 Promise。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,可以直接使用 then() 方法进行调用。 这里的重点是自带了执行器,相当于把我们要额外做的(写执行器/依赖co模块)都封装了在内部。比如:
async function fn(args) {
// ...
}
等同于:
function fn(args) {
return spawn(function* () {
// ...
});
}
function spawn(genF) { //spawn函数就是自动执行器,跟简单版的思路是一样的,多了Promise和容错处理
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); });
});
}
async/await执行顺序
要正确理解 async/await
的执行顺序,核心在于掌握它与事件循环(Event Loop),特别是微任务(Microtask)队列的交互方式。我们不再需要将情况分为两种,而是通过一套统一的、清晰的规则来分析。
核心规则:
- 当一个
async
函数被调用时,它内部的代码会同步执行,直到遇到第一个await
表达式。 await
右侧的表达式会立即执行。 如果该表达式返回的不是一个 Promise,await
会将其转换为一个立即 resolved 的 Promise。await
关键字会暂停async
函数的执行,并让出主线程。async
函数中,await
之后的代码会被封装成一个微任务,并被添加到微任务队列(Microtask Queue)中。这个微任务只会在await
等待的 Promisesettle
(即 resolved 或 rejected) 之后,才会被真正添加到队列里。async
函数暂停后,调用它的外部代码(主线程)会继续执行,直到同步代码全部执行完毕。- 当主线程的同步任务执行完成后,事件循环会检查微任务队列,并按照**先进先出(FIFO)**的原则,依次执行队列中的所有微任务。
下面我们用这套规则来分析几个例子。
示例一:await
一个同步函数
console.log('script start');
async function async1() {
await async2(); // 关键点 1
console.log('async1 end'); // 关键点 2
}
async function async2() {
console.log('async2 end');
}
async1();
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
})
.then(function() {
console.log('promise1'); // 关键点 3
})
.then(function() {
console.log('promise2');
});
console.log('script end');
执行结果:
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
分步解析:
- 同步代码: 输出
script start
。 - 调用
async1()
:async1
函数开始同步执行。 - 执行
await async2()
:await
右侧的async2()
立即同步执行,输出async2 end
。async2
函数隐式返回一个 resolved 的 Promise。await
暂停async1
的执行。由于其等待的 Promise 已是 resolved 状态,await
之后的代码console.log('async1 end')
被立即添加到微任务队列。- 微任务队列:
[async1 end]
- 继续主线程:
async1
暂停,代码继续向下执行。 setTimeout
: 将其回调注册为一个宏任务(Macrotask)。new Promise
:- Promise 的构造函数立即同步执行,输出
Promise
。 resolve()
被调用,Promise 状态变为 fulfilled。- 第一个
.then()
的回调console.log('promise1')
被添加到微任务队列。 - 微任务队列:
[async1 end, promise1]
- Promise 的构造函数立即同步执行,输出
- 同步代码: 输出
script end
。主线程同步代码执行完毕。 - 处理微任务:
- 执行队列中的第一个任务:输出
async1 end
。 - 执行队列中的第二个任务:输出
promise1
。这个.then
执行完毕后,将其返回的 Promise 的.then
(即promise2
的回调) 添加到微任务队列。 - 微任务队列:
[promise2]
- 执行队列中的下一个任务:输出
promise2
。
- 执行队列中的第一个任务:输出
- 处理宏任务: 微任务队列已空。事件循环开始处理下一个宏任务,即
setTimeout
的回调,输出setTimeout
。
示例二:await
一个返回 Promise 的异步函数
console.log('script start')
async function async1() {
await async2(); // 关键点 1
console.log('async1 end'); // 关键点 3
}
async function async2() {
console.log('async2 end');
return Promise.resolve().then(()=>{
console.log('async2 end1'); // 关键点 2
});
}
async1();
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
})
.then(function() {
console.log('promise1'); // 关键点 4
})
.then(function() {
console.log('promise2');
});
console.log('script end');
执行结果:
script start
async2 end
Promise
script end
async2 end1
promise1
async1 end
promise2
setTimeout
分步解析:
- 同步代码: 输出
script start
。 - 调用
async1()
:async1
函数开始同步执行。 - 执行
await async2()
:async2()
立即同步执行,输出async2 end
。async2
返回一个pending
状态的 Promise。Promise.resolve().then()
会将console.log('async2 end1')
注册为第一个微任务。- 微任务队列:
[async2 end1]
await
暂停async1
的执行,等待async2
返回的 Promise 完成。此时async1 end
不会进入队列,因为它在等待。
- 继续主线程:
async1
暂停,代码继续向下执行。 setTimeout
: 注册一个宏任务。new Promise
: 同步执行,输出Promise
。.then()
将console.log('promise1')
注册为第二个微任务。- 微任务队列:
[async2 end1, promise1]
- 同步代码: 输出
script end
。主线程同步代码执行完毕。 - 处理微任务:
- 执行队列中的第一个任务:输出
async2 end1
。- 这个任务执行完毕后,
await
所等待的 Promise 变为 resolved 状态。 - 因此,
async1
的后续代码console.log('async1 end')
现在被添加到微任务队列的末尾。 - 微任务队列:
[promise1, async1 end]
- 这个任务执行完毕后,
- 执行队列中的下一个任务:输出
promise1
。这个.then
的回调console.log('promise2')
被添加到微任务队列的末尾。 - 微任务队列:
[async1 end, promise2]
- 执行队列中的下一个任务:输出
async1 end
。 - 执行队列中的下一个任务:输出
promise2
。
- 执行队列中的第一个任务:输出
- 处理宏任务: 微任务队列已空,执行
setTimeout
的回调,输出setTimeout
。
参考资料
- 极客时间《浏览器工作原理与实践》
- 阮一峰《es6入门》
Promise资料
- 《这一次,彻底理解Promise原理》