Loading

你不知道的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
  1. 生成器函数通过在函数名前面加*来定义
  2. 调用生成器函数并没有直接执行这个函数,而是返回一个对象,这个对象有next方法
  3. 调用next方法启动生成器函数
  4. 生成器函数执行过程中遇到yield会主动让出控制权。
  5. 同第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
  1. var it = foo(10)用于获取一个生成器的迭代器。
  2. it.next()启动生成器,它执行到yield时会暂停,让出CPU控制权
  3. 这个yield在一个算式中充当占位符,当下一个it.next从这里开始启动生成器的时候,需要给next中传递一个值,这个值会替换掉这个yield
  4. 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中传值的特权,因为nextfor-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.nextvalue是空,就不继续向下延伸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。

因为生成器也是函数,所以没有理由不赋予它可以在任意位置被复用的能力,生成器委托可以让我们实现生成器中逻辑的抽取和复用。

参考

posted @ 2021-07-30 13:22  yudoge  阅读(277)  评论(0编辑  收藏  举报