generator函数与async

提出问题

为了解决多线程带来的各种问题(例如耗费上下文切换的资源,竞态条件等),Node.js等平台使用了异步IO的方式,主工作现场只使用单线程来处理工作。然而单线程异步IO带来了一个严重问题—回调地狱(callback hell)。为此,出现了Promise等技术来解决书写回调地狱的麻烦问题。

比如以前这样的代码:

1
2
3
4
5
6
7
8
9
a(function(){
b(function(){
c(function(){
d(function(){

})
})
})
})

现在可以写成:

1
a().then(b).then(c).then(d).

这样,a、b、c、d四项异步任务就会依次发生。使用Promise除了链式写法,还可以:

1
2
3
4
var aok = a()
var bok = aok.then(b)
var cok = bok.then(c)
var dok = cok.then(d)

技术的发展总是无止境的,开发方式开始越来越往把 程序员当傻子 的方向发展,于是现在,有一种叫做 Generator 的技术,可以直接将异步任务书写为同步的代码,这必然彻底解决了回调地狱的问题;其封装后的语法糖 async、await 彻底简化了异步代码编写困难的问题。而Generator的理论实际上是一种 协程 技术的应用,协程技术本来的目标是可以实现一种并发编程操作。

回顾概念

线程与进程

进程(Process)是系统资源分配和调度的单元。一个运行着的程序就对应了一个进程。一个进程包括了运行中的程序和程序所使用到的内存和系统资源。如果是单核CPU的话,在同一时间内,CPU上有且只有一个进程在运行。但是,单核CPU也能实现多任务同时运行,比如你边听网易云音乐的每日推荐歌曲,边在网易有道云笔记上写博文。这算开了两个进程(多进程),那运行的机制就是一会儿播放一下歌,一会儿响应一下你的打字,但由于CPU切换的速度很快,你根本感觉不到,以至于你认为这两个进程是在同时运行的。进程之间是资源隔离的。

线程(Thread)是进程下的执行者,一个进程至少会开启一个线程(主线程),也可以开启多个线程。比如网易云音乐一边播放音频,一边显示歌词。多进程的运行其实也就是通过进程中的线程来执行的。一个进程下的线程是共享资源的,所以多线程比多进程实现起来要简单而且要消耗更少的资源。当多个线程同时操作同一个资源的时候,就出现资源争抢的问题,所以这种并行编程一旦涉及到访问同一个资源就要小心进行编程上的控制,这又是另外一个大的问题了这里不再赘述。

chrome浏览器之所以占内存大,就是因为他采用了新开tap是新开进程的方式。每个进程都是独立的资源,所以比较耗内存。

并行与并发

并行(Parallelism)是指程序的运行状态,在同一个时间内有几件事情并行在处理。由于一个线程在同一时间只能处理一件事情,所以并行需要多个线程在同一时间执行多件事情。

而并发(Concurrency)是指程序的设计结构,在同一时间内多件事情能被交替地处理。重点是,在某个时间内只有一件事情在执行。比如单核CPU能实现多线程多任务运行的过程就是并发的。比如总体上来看,服务器上有限的线程来处理成千上万的Request请求,总体看也是并发的(不可能是并行处理几千万个请求)。

所以,并发应该是在描述一种事务请求资源的状态。而并行是在说做任务的方法。比如你开了5个进程或线程来进行数据分片计算,这应该叫并行处理任务。 你的5个线程来响应外网一千万个请求,这些请求对你的服务器或者CPU来说是并发的。 如果你的服务器是单核,但开启了100个线程,那么这些线程对你的服务器CPU来说,也是叫并发的;而如果是四核CPU开了4个线程同时处理任务,这个就可以叫做并行了。

浏览器单线程机制与事件循环的原理

由于本文要讨论的问题,跟异步、线程有很大关系,不由会思考浏览器中的单线程机制和事件循环,请参考我的另外一篇博文:

协程

协程就是在一个线程里像多线程一样,执行多个任务。

官方概念:协程(Coroutine)是一种轻量级的用户态线程。简单来说,进程(Process), 线程(Thread)的调度是由操作系统负责,线程的睡眠、等待、唤醒的时机是由操作系统控制,开发者无法精确的控制它们。使用协程,开发者可以自行控制程序切换的时机,可以在一个函数执行到一半的时候中断执行,让出CPU,在需要的时候再回到中断点继续执行。因为切换的时机是由开发者来决定的,就可以结合业务的需求来实现一些高级的特性。更多资料

在一个cpu时,多线程也无法真正的并行执行任务;因此协程这种跳跃式执行不同位置代码的方式表面上的效果是有些类似多线程的。只不过 多线程是内核提供的功能,线程切换实际上要涉及内核态切换,还是消耗性能;而协程唤做用户态线程(协程),用户态线程就是程序自己控制状态切换,进程不用陷入内核态,开发者可以按照程序的特性来选择更适合的调度算法,协程属于语言级别的调度算法实现。

所以协程我是这样理解的: 协程在一个线程里做出线程那样并行的效果,其实本质也无法并行,毕竟只在一个线程内。但从表面上看你的代码可以随时跳来跳去执行, 好像并不是传统的顺序执行。最大的特点是: 一个函数竟然可以执行到某个地方保存现场,然后程序就跑到另外一个地方去执行了。 等到合适的时机,再跳回来恢复现场并执行。 但从表面看,我们可以认为这是一种对一个线程的并发行为。更多资料。关于协程, 是一个比较大的课题。可以参考一些文章,例如 并发之痛 Thread,Goroutine,Actor

协程配合多线程和异步

我们知道多线程和异步写的代码不好管理,比如异步代码里太多回调。而如果协程来封装多线程的操作,可以更好的管理多线程;用协程来封装异步,也可以更好的书写异步代码。 在JavaScript中,一般用协程来管理异步IO的调用和回调,从而编写出同步样式的代码. 这正是本文要讲的内容。

Generator

终于来到本文的重点 Generator 了。generator 是一种协程的具体语言实现,其实generator还不能是完全的协程,他只实现了让一个函数可以暂停,也称之为非对称协程(semi-coroutine). 所以说Generator应该是半协程这个概念的语言实现。

generator函数是一个状态机,内部有自己的状态以及可以变更其状态. 通过 yield 表达式,可以定义generator函数暂停的位置。

1
2
3
4
5
function* f() {
yield 1
yield 2
return 3
}

要想再次让函数执行到下一个yield表达式的位置,必须调用遍历器对象的next方法,使得其内部指针移向下一个状态。

1
2
var g = f();
g.next()

也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行.

哇,仿佛,我们的js函数具有了状态记忆的功能。(这体现在yield表达式)。不过,为了更容易理解,我们可以用 迭代器 的思路来学习 Generator。比如,我们可以认为执行 f 函数后,就返回一个迭代器(实际上确实是一个迭代器),迭代器内包含了函数的每一个步骤,通过这个迭代器可以去执行函数的每一个步骤。

next的返回值

执行next方法是有返回值的。next方法返回一个对象,它的value属性就是当前yield表达式中yield后面跟着的那个值(右值),done属性的值若是false,则表示遍历还没有结束。如上文的例子,我们不断调用g.next()

1
2
3
4
5
var g = f();
var a = g.next()
var b = g.next()
var c = g.next()
var d = g.next()

其结果 a,b,c,d 分别是 {value: 1, done: true}, {value: 2, done: true}, {value: 3, done: true}, {value: undefined, done: true}

也就是说,当函数运行到return或者函数末尾时,Generator函数就已经运行完毕,此时会把return的东西当做value返回最后一次且done属性为true。 以后再执行 next 方法返回对象的 value 属性为undefined,done属性为true。再以后,无论多少次调用next方法,返回的都是这个值。

从另一个角度看Generator,其实发现它的结果就是: 生成了一系列的值,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。

yield的返回值

generator函数内的yield表达式是没有返回值的,所以函数内部的这种赋值语句,将会永远拿到的是undefined

1
var reset = yield i;

然而,它虽然没有返回给左侧,但会返回给外部调用next函数的左侧。另外,可以通过调用generator生成的函数,来向已经运行的generator函数中注入值。传入的值会被赋值给yield左侧的reset。所以,也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数的行为。比如:

1
2
3
4
5
6
7
8
9
10
11
12
function* f() {
for(var i = 0; true; i++) {
var reset = yield i;
if(reset) { i = -1; }
}
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

generator函数里面写了一个无限循环的函数,每次调用next都会让 i 加1,并让循环进入下一个迭代中,并暂停在下一个yield那里。 由于reset是false,所以 i=-1 永远不会执行,循环还会继续。

而通过外部 next调用 时传入true,就让yield有了返回值,从而 var reset = yield i; 这一句里的reset就不是undefined了,而是true,所以可以让这次循环的迭代时将i置为 -1

Generator总结

总结一下,生成器不是线程,在支持线程的语言中,多段代码可以同时运行,通常导致竞态条件和非确定性,不过同时也带来不错的性能。生成器则完全不同。当生成器运行时,它和调用者处于同一线程中,拥有确定的连续执行顺序,永不并发。调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

Generator与异步操作

其实了解了Generator之后,会有一个很困惑的问题,就是这玩意怎么执行,通过不断调用next来执行函数中的一部分代码,有啥子作用呢? 这跟我们自己去运行多个函数来完成一个任务也差不多啊?

其实Generator的类似“断点”执行函数的特性,非常适合用来封装那些异步操作的API。

generator 本质上并不能将异步代码做成同步的,仅仅能够控制代码的执行顺序,要实现异步代码执行起来像是同步的,需要类似co这样的库。co的根本目的:将上一个 yield 函数的回调返回值作为下一个 next 函数的入参传递,从而将一个Generator自己运行起来,封装后就实现了让 异步代码看起来像同步的

使用co这个库,就可以将Promise和Generator结合起来,改成 同步的代码 。瞅一下:

1
2
3
4
co(function* getData() {
var res = yield this.$http.get('http://www.baidu.com')
this.value = res
})

需要确保yield后面是个 Promise (或 thunk 函数)。

使用 Generator 也方便我们去捕获异步的异常。我们知道,promise 的异常需要使用 p.catch 这样的语法来捕获。而使用了 Generator,完全可以使用 try…catch 捕捉异步的异常。

1
2
3
4
5
6
7
8
9
co(function* getData() {
try {
var res = yield this.$http.get('')
this.value = res
}
catch(err) {
console.log('error')
}
})

对于co来说,他实现为一个Promise的返回。因此,如果你的业务代码中不去try…catch,则co会触发Promise reject。你可以在co函数之后捕获这个错误。

对于Promise的reject异常,如果你没用自己catch,则只能在全局catch,在Node.js中可以通过监听 unhandleRejection 来处理:

1
2
3
4
5
6
7
8
9
10
process.on('unhandledRejection', function (err) {
console.error(err.stack);
});

const co = require('co')

co(function* getData() {
var res = yield this.$http.get('')
this.value = res
})

注意了: 大家说熟知的uncaughtException,以及浏览器中的 window.onerror 都是无法捕获Promise的reject异常的。

co封装Generator的原理

简单的代码模拟如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 模拟一个异步回调函数1 (yibu1其实就是一个Thunk函数)
function yibu1(cb) {
setTimeout(function () {
cb("1");
}, 300);
}
// 模拟一个异步回调函数2
function yibu2(cb) {
setTimeout(function () {
cb("2");
}, 300);
}

function * doit() {
var a = yield yibu1;
console.log(a);
var b = yield yibu2;
console.log(b);
return "over";
}
var gen = doit();
function mynext(result) {
var step = gen.next(result); // 异步结果会传入generator 从而赋值给yield的左边
if (!step.done) {
step.value(mynext); // mynext作为回调传入yibu1 会在300毫秒后被调用。这就实现了递归
} else {
console.log( step.value);
}
}
mynext(); // 首次触发mynext调用

其核心就是通过mynext函数重复递归调用,实现异步任务完成后就再次触发generator,并把结果传入generator(同时发起下一个异步操作)

async,await

async,await是 ES7 对generator使用方法的包装,使其使用起来更简洁(尤其是用在generator做异步IO操作的时候)。使用async来修改上面的代码,可以这样写:

1
2
3
4
async function getData() {
var res = await this.$http.get('')
this.value = res
}

async,await就是generator+自动执行器的语法糖。

更多参考,可以查看阮一峰老师的ES6标准教程(在线有开源版本),其中详细讲解了async,await语法糖的来源。值得有时间时进行详细解读,大概从中可以明白,async是对generator封装后的语法糖,实现了之前用co库才能做到的generator的自动run执行器。

async可以认为就是一个包含了很多异步操作的函数(一个generator函数)。而await你就可以看做后面是个promise异步任务(相当于generator里的yield),而这个语法糖让后面异步promise的结果会自动赋值给前面的变量。

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。

await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try…catch代码块中

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}

asyncPrint('hello world', 50); // async函数执行时已经不需要co等模块,直接执行
console.log('我先执行')

通过例子可以看出来,虽async实现了同步代替异步,但仅仅是语法层面的。JavaScript单线程本质没变,异步下面一行代码必须等到其他同步代码执行完,才会打印。因此,即使学习了async,但思维本质上我们要跟以前Promise等异步的理解方式保持一致的哦。

async函数返回一个promise,其实也类似于co函数执行后返回一个promise。因此,我们可以用then方法来指定异步完成后的回调。

另外一点是,多个 await 语句实际上是串行执行的,有时候我们需要并行执行两个异步操作。这个时候可以这样来拼装2个异步操作:

1
2
3
4
5
6
7
8
// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

Refer

深入浅出ES6(三):生成器 Generators
async 函数
nodejs co 本质学习 及演进代码