你不知道的JavaScript——异步编程(下)生成器
前面两篇笔记中介绍了JS引擎的运行模式,它是以任务为中心,一个任务就是一个函数,一个任务一旦执行,没有人能够打破这个执行,这也保证了JS中不会发生类似Java等抢占式多线程编程语言的竞态问题。
生成器打破了这个完整性,它允许一个函数在执行过程中主动暂停,保存状态,将执行控制权让给其它函数。生成器在很多语言中都有出现,如Python、Kotlin等,而由生成器演变出来的并发编程模型叫“协程”,这种变成模型允许程序员自行控制何时让出CPU时间,掌握了主动权。
我们先来介绍下生成器是啥。
初探生成器
var x = 0;
function *foo(){
yield;
console.log(x);
}
function bar(){
x++;
}
var it = foo();
it.next();
bar();
it.next(); // 1
- 生成器函数通过在函数名前面加
*
来定义 - 调用生成器函数并没有直接执行这个函数,而是返回一个对象,这个对象有
next
方法 - 调用
next
方法启动生成器函数 - 生成器函数执行过程中遇到
yield
会主动让出控制权。 - 同第4点,yield后调用
next
方法会从暂停位置启动生成器函数,恢复函数内部之前的状态
输入和输出
参数
生成器函数虽不是不同函数,但仍是个函数,它依然可以有参数和返回值。
var x = 0;
function *foo(prefix){
yield;
console.log(prefix+": "+x);
}
function bar(){
x++;
}
var it = foo("x is");
it.next();
bar();
it.next(); // x is: 1
返回值
通过刚刚的例子,我们发现调用生成器方法返回了一个对象,这个对象叫迭代器,我们后面会介绍,那么生成器方法的返回值会被返回到哪里呢?
前面的例子中也能发现,调用next
才会实际启动生成器,实际上生成器函数中的return
语句返回给最后一个it.next
,如果没有返回语句也会有一个隐式的return undefined
。
// ...
function *foo(prefix){
yield;
console.log(prefix+": "+x);
return 'OK';
}
// ...
var it = foo("x is");
it.next();
bar();
var status = it.next();
console.log(status);
输出结果
x is: 1
{ value: 'OK', done: true }
可以看到生成器的next
返回的不是一个单独的值,而是一个对象,我们可以通过value
来获取这个值,done
属性代表的是生成器是否已经执行完成。
与生成器交互
function *foo(x){
var a = x + (yield);
return a;
}
var it = foo(10);
it.next();
var result = it.next(1).value;
console.log(result); // 11
var it = foo(10)
用于获取一个生成器的迭代器。it.next()
启动生成器,它执行到yield
时会暂停,让出CPU控制权- 这个
yield
在一个算式中充当占位符,当下一个it.next
从这里开始启动生成器的时候,需要给next
中传递一个值,这个值会替换掉这个yield
。 it.next(1).value
,像3中说的,给yield
传递一个值,并启动挂起的生成器,这时的算式就是x+1=10+1=11
。并且程序中再无其他yield
语句,此次next调用会一直运行到结束,我们可以使用.value
来获取生成器的返回值。
yield
除了可以在最后一步返回一个值之外,它也可以像return
语句一样,在每次暂停时返回一个值。
function *foo(x){
var a = x + (yield 'HAHA');
return a;
}
var it = foo(10);
var bar = it.next().value;
console.log(bar); // HAHA
var result = it.next(1).value;
console.log(result); // 11
顺序问题
生成器next
方法和yield
操作是不匹配的,并且传入值和传出值也是不匹配的,这很容易把人搞晕,我现在还有点晕。
还是参考这段代码:
function *foo(x){
var a = x + (yield 'HAHA');
return a;
}
var it = foo(10);
var bar = it.next().value;
console.log(bar); // HAHA
var result = it.next(1).value;
console.log(result); // 11
第一个注意到的就是yield
的个数和外部next
的个数不匹配,next总是多一个,为啥呢?这就是六棵树中间有多少个空隙的问题,next
调用就是树,yield
就是中间的空隙,六棵树之间显然只有五个空隙。因为每执行一次next
都会走到一个yield
上并挂起,当经历了几次调用后走到了最后一个yield
时(例子代码中是一次),程序仍是挂起状态,需要额外的一次next
让它走到结尾。
再有就是next
的参数与返回值,先看参数。
因为yield
作为占位符时,它所占位的值要等到下次next
调用把挂起状态恢复时才被需要,因为那个时候它才被执行。所以第一个next
调用总是没有参数的。
而yield
的返回值总被返回给走到该次暂停的那个next
调用,也就是说,第一个yield
的返回值被返回给第一个next
调用,因为是它发起了next
并走到了第一个yield
,程序被挂起。
前面说到,next
总是比yield
多一个,那么最后一个next
的返回值是什么?正好对应显式或隐式return
语句的返回值。
对照着上面的代码理解这些话,多看几遍,应该就明白了。
不明白?我再解释一遍这些代码:
function *foo(x){
var a = x + (yield 'HAHA');
return a;
}
var it = foo(10); // 创建生成器的迭代器对象
var bar = it.next().value; // 启动生成器并走到第一个yield处,接收它的返回值
console.log(bar); // HAHA
var result = it.next(1).value; // 从第一个yield中恢复,并且它需要一个值来计算当前的表达式,所以传入一个值。程序继续从这个`yield`后执行,遇到返回语句,生成器结束执行,它的返回值被返回给该次next调用
console.log(result); // 11
生成器、迭代器、iterable
前面一直说什么迭代器迭代器的,那玩意是啥。
其实它和其他语言中的迭代器并无差别,就是一个对象,它包含一个next
方法用来遍历这个对象中的某种序列。而生成器把一系列通过yield
让出cpu,暂时暂停运行的过程看作一个序列,也就是说,JS为了让我们方便的使用生成器的功能,它选择了让生成器返回一个迭代器来帮助我们,让我们使用简单的迭代器API来操作生成器。
我们来编写一个简单的迭代器,在这之前我先使用传统的闭包来编写一个斐波那契数列的例子
function fib(){
var p_prev = 0, prev = 1;
return function(){
var ret = p_prev + prev;
p_prev = prev;
prev = ret;
return ret;
}
}
var gen_fib = fib();
console.log(gen_fib()); // 1
console.log(gen_fib()); // 2
console.log(gen_fib()); // 3
console.log(gen_fib()); // 5
console.log(gen_fib()); // 8
实际上这是从第二个数字开始的斐波那契数列,我们使用闭包来保存前一个和前两个数,并且返回一个函数,每次调用一个函数都会生成斐波那契数列的下一位数。
现在我们使用迭代器来改写这个例子。
function fib(){
var p_prev = 0, prev = 1;
return {
next: function(){
var ret = p_prev + prev;
p_prev = prev;
prev = ret;
return {done: false,value: ret};
}
}
}
var gen_fib = fib();
console.log(gen_fib.next());
console.log(gen_fib.next());
console.log(gen_fib.next());
console.log(gen_fib.next());
console.log(gen_fib.next());
很简单,只需要返回的时候返回一个对象并且遵守迭代器规范,提供一个next
方法用于获取下一个值即可,注意这里的返回值也是迭代器规定的格式,同时在下面的调用中,我们修改成了gen_fib.next()
。
它的好处就是你可以和任何接受迭代器的API合作了。
下面我们介绍一下Iterable
,我们可以把它翻译成“可迭代的”,一个对象,如果有一个名为[Symbol.iterator]
的方法,这个方法返回一个迭代器,就认为它是Iterable
的。
注意它和迭代器之间的关系,迭代器只用于提供一套公有的API来遍历某种序列,而Iterable
则代表了一个对象是具有迭代器的。
Iterable
可以和for-of
循环交互,我们下面将我们的fib
的返回值修改成一个Iterable
。
function fib(){
var p_prev = 0, prev = 1;
return {
[Symbol.iterator]: function(){return this;},
next: function(){
var ret = p_prev + prev;
p_prev = prev;
prev = ret;
return {done: false,value: ret};
}
}
}
这个代码值得考究,这里fib
返回的对象既是一个迭代器也是一个Iterable
,所以它的[Symbol.iterabor]
方法直接简单的返回了自身,因为自己就是一个迭代器。
现在这个函数可以和for-of
循环交互。
for(var i of fib()){
if(i>500) break; // 避免死循环
console.log(i);
}
回过头来,生成器是什么呢?
生成器是一个生成器......
八嘎,能不能好好说了!
生成器确实是一个生成器,只是你调用它的时候,它返回一个迭代器,同时,就像我们上面写的,这个迭代器还是一个Iterable
,它里面也是简单的做了类似return this
的操作,所以我们也可以使用for-of
来操作生成器。
但使用for-of
,就意味着你放弃了所有向next
中传值的特权,因为next
是for-of
自动帮你调用的,并且他会自动读取返回对象当中的value
来赋给你的循环变量,并且会自动检测里面的done
,如果为true
则跳出循环。
okok,咱再把上面的fib写成生成器形式:
function *fib(){
var p_prev = 0, prev = 1;
while(true){
var ret = p_prev + prev;
p_prev = prev;
prev = ret;
yield ret;
}
}
for(var i of fib()){
if(i>500) break;
console.log(i);
嘶,代码一下子清爽了不少,而且我们也能够发现,yield
机制让我们可以放心大胆的使用while true
而不担心会卡死,因为yield
会在等待时让出CPU资源,而不是像传统的JS函数,执行过程中没人能够插进来执行。
不过还有个问题,这个for-of
使用break
跳出后,生成器岂不是永久的挂起了?其实,只要for-of
意外结束(break,return,异常等),都会向迭代器发送一个信号,生成器的迭代器接受了这个信号就会自行结束。
当for-of
意外结束时,会调用迭代器的return
方法通知迭代器可以清理资源了,这不是一个迭代器必须实现的方法,如果你需要清理资源你就实现,显然生成器返回的迭代器是实现了此方法的。
如果你想在自己的生成器中执行一些善后工作的话,你可以使用try-finally
,无论是生成器正常的完成工作还是外部显式要求生成器停止执行,finally
子句都会被执行。
function *fib(){
try{
var p_prev = 0, prev = 1;
while(true){
var ret = p_prev + prev;
p_prev = prev;
prev = ret;
yield ret;
}
}finally{
console.log("Over");
}
}
也可以在for-of
循环中使用it.return
来代替break
。
var it = fib();
for(var i of it){
if(i>500) it.return();
else console.log(i);
}
it.return
把生成器的迭代器的状态设置为已完成,所以在下一次循环中自动会跳出,注意这里添加了一个else
子句,因为it.return
本身没有跳出循环的功能,这是一点细微的差别。
异步生成器
这么半天终于到了激动人心的时候了,就是生成器和异步任务结合。
// 用于延时返回一个随机整数
function delayRandom(bound,time){
setTimeout(function(){
try{
// 判断如果不是整数
if(bound != Math.floor(bound)) throw 'Not An Integer';
var num = parseInt(Math.random()*bound);
it.next(num);
}catch(err){
it.throw(err);
}
},time);
}
function *main(){
try{
var rndX = yield delayRandom(100,1000);
var rndY = yield delayRandom(10,200);
console.log(rndX,rndY,rndX + rndY);
var rndZ = yield delayRandom('asdf',1000);
console.log(rndZ);
}catch(e){
console.log("found an error: ",e);
}
}
var it = main();
it.next();
上面的代码使用生成器代替回调执行异步操作,使用yield
等待一个异步操作,异步操作中又使用next
来发送执行结果,使用throw
来上报异常。
这使得我们的main
函数中有了串行的语序,但实际上它并不像串行执行那样独占CPU资源,等待异步任务时,其它的任务照常执行。
这像不像,async和await。
我们要重点来看这行代码了:
var rndX = yield delayRandom(100,1000);
首先,delayRandom
方法并没有返回值,所以it.next
操作是拿不到什么value数据的,而且就算有返回值,它拿到的也是自己的方法返回的值,不会拿到我们作用域中的数据。其次,it.next
操作传递的值会被替换到yield
这里,这就完成了类似同步代码的异步调用。
it.throw
用于通知生成器发生了一个异常,这里面的异常会抛到生成器函数中去,我们可以直接在生成其中使用trycatch
来捕获。
同步风格的代码比异步风格的回调更加符合我们的思维模式,想想之前用于解决回调地狱的Promise
链,不也是将异步行为串成一串嘛,这正是我们大脑惯常的思维方式。
生成器中抛出的异常同样会被外部try-catch
安全的捕获,这解决了回调中的try-catch
失效问题。
function *main(){
throw 'Oops!!';
console.log('HELLO'); // 永远不会到达这里
}
var it = main();
try{
it.next();
}catch(err){
console.log('异常到了外面'+err); // 异常到了外面Oops!!
}
生成器内部抛出的异常成功被外部捕获!并且,甚至可以这样玩:
function *main(){
var a = yield 3;
console.log(a); // 永远不会到达这里
}
var it = main();
try{
it.throw('Oops!!');
}catch(err){
console.log('异常到了外面'+err); // 异常到了外面Oops!!
}
看上面的执行过程,我们直接使用it.throw
向生成器中抛出了一个异常,这个异常在生成器中发生,但生成器没有捕获异常,所以异常被外面的try-catch
捕获。
生成器+Promise
生成器解决了异步编程的顺序问题,Promise解决了传统回调模式中的信任危机问题,如何将二者组合?
事实上有很多库可以直接组合二者,但我们还是自己来看看,为了了解二者是如何协作的。
还是之前的随机数的例子:
首先我们修改delayRandom
中的代码,让它不直接操控生成器,而是通过返回一个Promise
,
function delayRandom(bound,time){
return new Promise(function(resolve,reject){
setTimeout(function(){
try{
if(bound != Math.floor(bound)) throw 'Not A Number';
var num = parseInt(Math.random()*bound);
resolve(num);
}catch(err){
reject(err);
}
},time);
});
}
注意,这里开始delayRandom
具有了返回值,也就是说我们的it.next
调用会接收到一个Promise
,并且,delayRandom
不再主动调用it.next
,而是我们拿到Promise
后自己去调用it.next
。
然后修改外部代码:
var it = main();
it.next().value.then(
function fulfilled(num){
return it.next(num).value
},
function rejected(err){
it.throw(err);
}
).then(
function fulfilled(num){
return it.next(num).value
},
function rejected(err){
it.throw(err);
}
).then(
function fulfilled(num){
it.next(num)
},
function rejected(err){
it.throw(err);
}
);
这个代码显然不好看,但是我们先解释下其中的细节,因为it.next.value
返回的是一个Promise,在delayRandom
中,如果成功生成一个随机数,fulfilled
会被调用,如果发生了异常,rejected
会被调用。我们在fulfilled
中调用了it.next
并把delayRandom
中生成的随机数传入,用于替换到生成器中的yield
占位,并且我们会把it.next.value
传入,因为*main
中还会调用delayRandom
,还会返回一个Promise,所以我们这样将Promise链延续下去。到第四个then
的时候,Promise链无需继续向下延续了,因为*main
该结束了。
首先,这代码中有很多可复用的代码,因为几乎每个then
中的代码都一样,其次是这条Promise
链是完全针对现在*main
中的代码设计的,如果*main
中的代码修改了,这条Promise
链就不适用了,再有就是错误处理,it.throw
确实能停止生成器的运行,但是Promise
链是写死的,这个链还会继续执行下去,虽然目前的代码中造不成什么影响,但是很可能在生产环境中影响会很大。
我们修改这个代码:
function nextStep(num){
var p = it.next(num).value;
if(p) p.then(nextStep,throwErr);
}
function throwErr(err){
it.throw(err);
}
nextStep(undefined);
提供一个方法nextStep
用于向下执行,也就是调用it.next
,这里做了一个检测,如果it.next
的value
是空,就不继续向下延伸Promise链,这包含一个基本的假设,就是*main
中的所有yield
只可能返回Promise。然后提供一个throwErr
方法用于向生成器中上报异常。最后,我们主动调用一下nextStep
用于启动生成器。
虽然目前还有一些问题和限制,但是我们已经取得了一些成功。而且*main
中的代码完全没有修改,它不知道发生了什么,但是我们已经完全把回调模式修改成了Promise模式。
编写支持Generator的Promise Runner
我们很需要一个公共的库函数来帮我们做上面接收Promise决议结果,调用生成器步骤的事,毕竟这种代码不应该是我们在每个项目中都重新写一次的。
下面提供一个公共的方法,它允许你直接像上面一样组合生成器和Promise而不用自己做那些糟心事。
// Generator Runner
function run(gen) {
var args = [].slice.call(arguments, 1);
var it = gen.apply(this, args);
return Promise.resolve().then(function handleNext(val) {
var next = it.next(val);
return (function handleResult(next) {
if (next.done) {
return next.value;
} else {
return Promise.resolve(next.value).then(handleNext, function handleError(err) {
return Promise.resolve(it.throw(err)).then(handleResult);
});
}
})(next);
});
}
然后你只需要这样简单的调用它即可。
run( main );
我们解释一下其中的代码,首先,下面两行不用多说,只是调用生成器函数,拿到一个迭代器。
var args = [].slice.call(arguments, 1);
var it = gen.apply(this, args);
然后下面,返回了一个总的Promise,外部可以通过这个Promise来获取生成器函数执行的最终结果。剩下的代码,和我们之前写的大同小异,就是递归调用,不断进行处理,直到生成器执行完成。
return Promise.resolve().then(function handleNext(val) {
// 下一步
var next = it.next(val);
return (function handleResult(next) {
// 如果没有下一步了
if (next.done) {
return next.value; // 直接返回生成器最终的返回值
} else {
// 如果还有下一步,通过`Promise.resolve`来转换响应`yield`语句的返回值,即使它所调用的只是同步函数,也会被转换成Promise,保证整个调用链的一致性。然后就是递归调用handleNext
return Promise.resolve(next.value).then(handleNext, function handleError(err) {
// 如果出错,通过it.throw上报,并且重新调用handleResult,去返回生成器最终的返回值
return Promise.resolve(it.throw(err)).then(handleResult);
});
}
})(next);
});
async和await
在后期的ES6和未来的ES7中,async和await已经成了一种使用同步方式进行异步编程的规范,它的原理其实就和上面差不多,使用async和await来编写
使用async和await,就可以告别生成器语法,并且它可以自动支持Promise。虽然大部分语言都使用生成器作为一个异步编程的方案,但是生成器的本意并不是进行异步编程,所以语义上它不是很清晰。async和await解决了这个问题。
async function main(){
try{
var rndX = await delayRandom(100,1000);
var rndY = await delayRandom(10,200);
console.log(rndX,rndY,rndX + rndY);
var rndZ = await delayRandom(10,1000);
console.log(rndZ);
}catch(e){
console.log("found an error: ",e);
}
}
main();
找回并行执行的能力
使用yield确实可以让我们以同步的顺序组织异步代码了,但是现在它们只能串行了,这会损失效率,很多时候其中的一些任务是完全可以并行执行的。
比如如下的代码,这里和之前有些改动,注意。
这段代码的作用是取两个随机数,一个会在1000毫秒后返回,一个则是2000毫秒。然后将它们的得数相加,然后取第三个随机数,它在1000毫秒后返回,最后把第一个和第二个相加的结果乘以第三个随机数,作为最终结果。
它的运行时间大概是:1000+2000+1000=4000ms
function *main(){
try{
var rndX = yield delayRandom(100,1000);
var rndY = yield delayRandom(10,2000);
var xAndY = rndX + rndY;
var rndZ = yield delayRandom(200,1000) * xAndY;
console.log(rndZ);
}catch(e){
console.log("found an error: ",e);
}
}
问题在于,第一个任务和第二个任务之间完全没有依赖关系,它们可以并行执行,如果并行执行,任务的运行时间将会缩短到3000ms左右。
如下是一个办法,它先不让步,先让两个异步方法执行起来,然后再进行阻塞,这样两个异步方法是并行执行的。得到xAndY
的时间总是这两个异步任务中耗时时间最长的那个任务的时间。
function *main(){
try{
var pX = delayRandom(100,1000);
var pY = delayRandom(10,2000);
var xAndY = (yield pX) + (yield pY);
var rndZ = (yield delayRandom(200,1000)) * xAndY;
console.log(rndZ);
}catch(e){
console.log("found an error: ",e);
}
}
如下又是一个办法,使用Promise.all
function *main(){
try{
var results = yield Promise.all([delayRandom(100,1000),delayRandom(10,2000)]);
var xAndY = results[0] + results[1];
var rndZ = (yield delayRandom(200,1000)) * xAndY;
console.log(rndZ);
}catch(e){
console.log("found an error: ",e);
}
}
隐藏Promise
我们使用生成器进行异步开发的目的就是尽量隐藏异步代码的细节,让它看起来更像同步,但是现在我们却在里面写了Promise。多明显的异步痕迹啊。
就是说,我们的生成器方法中不应该有任何异步风格的代码。我个人的想法就是,一个项目,或者一个模块,或者一个函数,要么你用纯异步的风格来写,要么你就用纯同步的风格来写,不要两种风格混合。
所以我们应该隐藏生成器方法中的Promise。
function fetchXY(){
return Promise.all([delayRandom(100,1000),delayRandom(10,2000)]);
}
function *main(){
// ...
var results = yield fetchXY();
var xAndY = results[0] + results[1];
// ...
}
同时,如果你在开发库函数,应该尽量开发出方便调用者隐藏调用细节的并发处理函数。
生成器委托
function *bar(){
yield 1;
yield 2;
yield 3;
}
function *main(){
// ...
var results = yield fetchXY();
yield *bar();
// ...
}
使用yield *生成器
,可以将当前迭代器委托给新的迭代器,也就是说,你可以使用main产生的迭代器去操控bar。当bar中的所有yield
都被消耗之后,迭代器会继续转回来控制main。
因为生成器也是函数,所以没有理由不赋予它可以在任意位置被复用的能力,生成器委托可以让我们实现生成器中逻辑的抽取和复用。