JavaScript中的协程

本篇是《尝试用通俗的方式解释协程》的续集,上一篇梳理了一遍协程的概念,现在我们用 JavaScript 为例更深入的了解协程。

协程长啥样

直接上代码看看 JavaScript 中协程是怎样的:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
function* idMaker() {
let index = 0;
while (true) yield index++;
}

let gen = idMaker(); // "Generator { }"

console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
// ...

没错,就是一个生成器,生成器本身是一个函数,也就是说在 JavaScript 中协程是由一个生成器函数实现的。

协程如何切换

协程本身是个函数,协程之间的切换本质是函数执行权的转移。

生成器函数的yield关键字有可以交出函数的执行权,挂起自身,然后 JavaScript 引擎,去执行这个函数后面的语句,在上面这个例子中,第 8 行调用gen.next()开始执行生成器函数的内容,第一次while循环里yield交出了执行权,JavaScript 引擎转而执行第 9 行,再次调用gen.next(),这时候 JavaScript 接着上次挂起的地方执行,不会重新执行let index = 0语句,然后执行函数内的语句,对于这个例子,使用yieldnext()方法就能不断的交出和恢复函数的执行权,怎么样,是不是有点感觉了?站在一个线程的角度看,线程的切换就是这样不断让 CPU 暂停和继续对自己执行。

上面这个例子是把生成器函数的执行权交给普通函数(你也可以把非协程看做是一个协程整体),也可以在一个协程中调用另一个协程,实现协程之间的切换,比如这个例子:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function* anotherGenerator(i) {
yield i + 1;
yield i + 2;
yield i + 3;
}

function* generator(i) {
yield i;
yield* anotherGenerator(i); // 移交执行权
yield i + 10;
}

var gen = generator(10);

console.log(gen.next().value); // 10
console.log(gen.next().value); // 11
console.log(gen.next().value); // 12
console.log(gen.next().value); // 13
console.log(gen.next().value); // 20

第 9 行使用yield*将执行权交给另一个生成器函数,接下来要等到这个生成器函数anotherGenertor()执行完毕执行权才会回到generator函数。这和普通函数表现一致,都是后进先出,如果感兴趣可以去看看 JavaScript 事件循环机制(Event Loop),本篇就不再多说了。

协程如何实现异步

异步

搞清楚什么是同步,什么是异步,问题才能很好的讨论下去。先说同步:

JavaScript
1
2
3
4
5
6
7
res = function fetchFun() {
// 请求资源
// res = ....
return res;
};

console.log(res);

计算机按照程序顺序执行代码,比如这几行代码一定第三行先给res赋值,然后才是打印res,而非同步(即异步)可以先执行后面的console.log(res),然后再给res赋值。

为什么需要这么做呢,当一个请求需要耗费大量的时间,程序执行一直停留在这一行,就会引发阻塞,最容易受影响的是eventListener,事件监听没了,在请求数据的时候点击事件都是无效的。

所以实现异步的关键就是把会阻塞线程函数的执行权交出去,让这个函数等待恢复执行,等待的时间内请求(或者其他异步任务)也该执行完了,这时候再来继续执行这个函数。通过前面对协程的运行方式的讲解我们很容易就能想到用协程来解决这个问题,利用yield挂起这个阻塞线程函数,然后继续执行后面的语句,等这个函数不再阻塞了,再回到这个函数继续执行。那么问题来了,应该什么时候继续执行这个挂起的函数呢?你可能想到大概估计一下阻塞时间,设定时间再回来执行,这个方案。。。有点牵强。

Promise

这时候 Promise 就派上用场了,Promise 本质是一个状态机,用于表示一个异步操作的最终完成 (或失败), 及其结果值。它有三个状态:

  • pending: 初始状态,既不是成功,也不是失败状态。
  • fulfilled: 意味着操作成功完成。
  • rejected: 意味着操作失败。

最终 Promise 会有两种状态,一种成功,一种失败,当 pending 变化的时候,Promise 对象会根据最终的状态调用不同的处理函数。

根据 Promise 的特点,他是一个状态机,在yield之后可以用 Promise 来表示异步任务是否执行完毕(是否是 pending 状态),并且还能够自动判别异步任务成功与否(fulfilled 还是 rejected)并执行处理函数。如此看来用协程+Promise 可以完美实现异步。

好的,让我们来根据上面的理论实现一下吧:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 模拟阻塞事件
function resolveAfter2Seconds(val) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(val);
}, 2000);
});
}
// 实现生成器
function* coroutineFunc(val) {
yield resolveAfter2Seconds(val);
}

let doIt = coroutineFunc("OK");
let value = doIt.next().value;
// value是Promise对象
value.then((res) => {
console.log(res);
});
// 模拟后面被阻塞的语句
for (let i = 0; i < 10; i++) {
console.log(i);
}

这段代码的输出顺序是 0=>1=>2=>…=>9,两秒之后输出’OK’,从输出顺序来看我们已经实现了异步。其执行过程和之前说的一样,挂起会阻塞运行的函数,继续执行后面的语句,等待 Promise 改变状态并自动执行处理函数。

使用 Generator、Promise 组合和直接使用 Promise 的区别

实际上下面这段代码运行顺序的结果和上面一模一样:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
function resolveAfter2Seconds(val) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(val);
}, 2000);
});
}

resolveAfter2Seconds("OK").then((res) => {
console.log(res);
});

为什么要使用上面那种复杂的写法呢?为了简化问题,便于理解,我已经简化了代码,在前一个例子中,生成器函数内,11 行以后完全可以写更多的代码,这些代码一定是在异步获取到数据之后才执行的。如果直接使用 Promise 需要把这些代码放在 then 代码块里边才能保证在异步获取到值之后执行,那么当有多个异步事件的时候问题就来了——可怕的嵌套!

Async、Await

ECMAscript2017 中提供了更高级的协程控制语法,其被看做是对 Generator 和 Promise 组合的封装,使异步函数看起来更像同步函数,减轻开发者的痛苦。上面的例子改写:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
function resolveAfter2Seconds(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}

async function f1() {
var x = await resolveAfter2Seconds(10);
console.log(x); // 10
}

可以看出 Async、Await 实现了 Generator 的自动迭代,不需要手动使用next()方法来继续执行。正因为 Async、Await 是对 Generator 和 Promise 组合的封装,所以 Async 和 Await 基本上就只能用来实现异步和并发了,而不具有协程的其他作用。

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×