[学习]-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切换的速度很快,你根本感觉不到,以至于你认为这两个进程是在同时运行的。进程之间是资源隔离的。

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

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

并行与并发

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

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

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

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

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

协程

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

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

咦,怎么可能? 首先,要明白,其实在一个CPU多线程的时候,也是无法真正的任务并行的。这个同时执行多个任务其实是交替进行的,只因为每个任务获得时间片很短,你无法觉察而已。其实在一个cpu时,他实现的效果跟多线程是一样的,但多线程是内核提供的功能,线程切换实际上要涉及内核态切换,还是消耗性能。
线程切换的时候,进程需要为了管理而切换到内核态,状态转换的消耗有点严重。为此又产生了一个概念,唤做用户态线程(协程)。用户态线程就是程序自己控制状态切换,进程不用陷入内核态,开发者可以按照程序的特性来选择更适合的调度算法,协程属于语言级别的调度算法实现。

所以协程我是这样理解的: 协程在一个线程里做出线程那样并行的效果,其实本质就是交替执行(在单核CPU上他跟多线程一样,而且比多线程节省性能)。但从表面上看你的代码可以随时跳来跳去执行, 好像并不是传统的顺序执行。最大的特点是: 一个函数竟然可以执行到某个地方保存现场,然后程序就跑到另外一个地方去执行了。 等到合适的时机,再跳回来执行。 但从表面看,实际上可以认为是一个线程里实现了多个任务并发执行。
https://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000/001432090171191d05dae6e129940518d1d6cf6eeaaa969000

关于协程, 是一个比较大的课题。可以参考一些文章,例如 并发之痛 Thread,Goroutine,Actor

协程配合多线程和异步

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

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表达式)

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;

然而,可以通过调用generator生成的函数,来向已经运行的generator函数中注入值。传入的值会被赋值给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置为初始值。 可以发现调用 g.next(true) 返回的value是0,所以可以认为 yield的返回值不是yield后面跟着的变量的实时值,而是Generator函数内执行暂停时的yield后面变量的值。

不是线程

值得特别一提的是,生成器不是线程,在支持线程的语言中,多段代码可以同时运行,通常导致竞态条件和非确定性,不过同时也带来不错的性能。生成器则完全不同。当生成器运行时,它和调用者处于同一线程中,拥有确定的连续执行顺序,永不并发。

Generator总结

总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

Generator与Promise会产生反应

其实了解了Generator之后,会有一个很困惑的问题,就是这玩意怎么执行,通过不断调用next来执行函数中的一部分代码,有啥子作用呢?
其实Generator的类似“断点”执行函数的特性,非常适合用来封装那些异步操作的API。

generator 本质上并不能将异步代码做成同步的,仅仅能够控制代码的执行顺序,要实现异步代码执行起来像是同步的,需要类似co和thunkify的库。

co的根本目的:将上一个 yield 函数的回调返回值作为下一个 next 函数的入参传递,从而将一个Generator自己运行起来,封装后就实现了代码看起来像是同步的。要实现同步编写代码,需要在Generator里面使用了Promise来封装异步代码

异步代码我们一般这样写:

1
2
3
4
5
6
function getData() {
var self = this
this.$http.get('').then(function (res) {
self.value = res
})
}

上面用了Promise,promise有个特性是你可以返回无限的promise,然后一直then,then,then下去. 这算是改善了一种写法. 不过重点不在于此,使用co这个库,就可以将Promise和Generator结合起来,改成同步的代码呢。瞅一下CO的代码可以这样写:

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

需要确保yield后面是个Promise,我们知道,promise有两种状态,一种是成功,一种是拒绝。成功当然你就可以直接拿到res; 但假如失败如何监听呢? 也很简单,只需要使用同步代码时常用的异常捕捉:

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

这顺便也解决了异步代码中异常捕捉难的问题。 你如果不去捕捉的话,你这个错误会消失,对,由于co实际上在包装Promise,所以你没有try… catch.. 就相当于Promise没有写catch。如果一个Promise不catch的话肯定错误就直接扔到全局了,所以你只能在全局看到报错。

如果你想在全局捕捉错误堆栈,在浏览器里有window.error, 在Node.js中可以在代码中加入以下代码:

1
2
3
4
5
process.on('unhandledRejection', function (err) {
console.error(err.stack);
});
process.on(`uncaughtException`, console.error);

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
// 模拟一个异步回调函数
function yibu1(cb) {
setTimeout(function () {
cb("1");
}, 300);
}
// 模拟一个异步回调函数
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 next(result) {
var step = gen.next(result);
if (!step.done) {
step.value(next);
} else {
console.log( step.value);
}
}
next();

async,await

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

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
async function getData() {
var res = await this.$http.get('')
this.value = res
}
```
async,await就是generator+自动执行器的语法糖。
更多参考,可以查看阮一峰老师的ES6标准教程(在线有开源版本),其中详细讲解了async,await语法糖的来源。值得有时间时进行详细解读,大概从中可以明白,async是对generator封装后的语法糖,实现了之前用co库才能做到的generator的自动run执行器。
async可以认为就是一个包含了很多异步操作的函数。而await你就可以看做后面是个promise,而这个语法糖让后面异步promise的结果会自动赋值给前面的变量。
如上这样理解, 就能够比较轻易的大概知道async,await是做什么的以及怎么用了。 但是我们要知道js是单线程的,执行一个async函数本质上我们要知道其实是自动去执行一个generator函数生成的函数进度指针,其实他执行时内部给你设置好了执行next()的时机,但我们要知道当这个async函数run起来,且第一个异步任务执行的时候,js线程执行流就往下走了。 js中异步和单线程这些概念的本质没有变,因此第二个await任务的触发依然是本质靠事件(或回调或promise的then)来驱动的,所以第二个await的执行必然要等到js线程空闲时才能得到执行。
例子:
``` javascript
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);

Refer

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