关于协程和 ES6 中的 Generator
什么是协程?
进程和线程
众所周知,进程和线程都是一个时间段的描述,是CPU工作时间段的描述,不过是颗粒大小不同,进程是 CPU 资源分配的最小单位,线程是 CPU 调度的最小单位。
其实协程(微线程,纤程,Coroutine)的概念很早就提出来了,可以认为是比线程更小的执行单元,但直到最近几年才在某些语言中得到广泛应用。
那么什么是协程呢?
子程序,或者称为函数,在所有语言中都是层级调用的,比如 A 调用 B,B 在执行过程中又调用 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕,显然子程序调用是通过栈实现的,一个线程就是执行一个子程序,子程序调用总是一个入口,一次返回,调用顺序是明确的;而协程的调用和子程序不同,协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
我们用一个简单的例子来说明,比如现有程序 A 和 B:
def A(): print '1' print '2' print '3'def B(): print 'x' print 'y' print 'z'
假设由协程执行,在执行 A 的过程中,可以随时中断,去执行 B,B 也可能在执行过程中中断再去执行 A,结果可能是:
12xy3z
但是在 A 中是没有调用 B 的,所以协程的调用比函数调用理解起来要难一些。看起来 A、B 的执行有点像多线程,但协程的特点在于是一个线程执行,和多线程比协程最大的优势就是协程极高的执行效率,因为子程序切换不是线程切换,而是由程序自身控制,因此没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显;第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态即可,所以执行效率比多线程高很多。
协程是一种程序组件,是由子例程(过程、函数、例程、方法、子程序)的概念泛化而来的,子例程只有一个入口点且只返回一次,而协程允许多个入口点,可以在指定位置挂起和恢复执行。
- 协程的本地数据在后续调用中始终保持
- 协程在控制离开时暂停执行,当控制再次进入时只能从离开的位置继续执行
解释协程时最常见的就是生产消费者模式:
var q := new queuecoroutine produce loop while q is not full create some new items add the items to q yield to consumecoroutine consume loop while q is not empty remove some items from q use the items yield to produce
这个例子中容易让人产生疑惑的一点就是 yield 的使用,它与我们通常所见的 yield 指令不同,因为我们常见的 yield 指令大都是基于生成器(Generator)这一概念的。
var q := new queuegenerator produce loop while q is not full create some new items add the items to q yield consume generator consume loop while q is not empty remove some items from q use the items yield produce subroutine dispatcher var d := new dictionary(generator → iterator) d[produce] := start produce d[consume] := start consume var current := produce loop current := next d[current]
这是基于生成器实现的协程,我们看这里的 produce 与 consume 过程完全符合协程的概念,不难发现根据定义生成器本身就是协程。
“子程序就是协程的一种特例。” —— Donald Knuth
什么是 Generator?
在本文我们使用 ES6 中的 Generators 特性来介绍生成器,它是 ES6 提供的一种异步编程解决方案,语法上首先可以把它理解成是一个状态机,封装多个内部状态,执行 Generator 函数会返回一个遍历器对象,也就是说 Generator 函数除状态机外,还是一个遍历器对象生成函数,返回的遍历器对象可以依次遍历 Generator 函数内部的每一个状态,先看一个简单的例子:
function* quips(name) { yield "你好 " + name + "!"; yield "希望你能喜欢这篇介绍ES6的译文"; if (name.startsWith("X")) { yield "你的名字 " + name + " 首字母是X,这很酷!"; } yield "我们下次再见!";}
这段代码看起来很像一个函数,我们称之为生成器函数,它与普通函数有很多共同点,但是二者有如下区别:
- 普通函数使用 function 声明,而生成器函数使用 function* 声明
- 在生成器函数内部,有一种类似 return 的语法即关键字 yield,二者的区别是普通函数只可以 return 一次,而生成器函数可以 yield 多次,在生成器函数的执行过程中,遇到 yield 表达式立即暂停,并且后续可恢复执行状态
Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号,不同的是调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象
当调用 quips() 生成器函数时发生什么?
> var iter = quips("jorendorff"); [object Generator]> iter.next() { value: "你好 jorendorff!", done: false }> iter.next() { value: "希望你能喜欢这篇介绍ES6的译文", done: false }> iter.next() { value: "我们下次再见!", done: false }> iter.next() { value: undefined, done: true }
每当生成器执行 yield 语句时,生成器的堆栈结构(本地变量、参数、临时值、生成器内部当前的执行位置 etc.)被移出堆栈,然而生成器对象保留对这个堆栈结构的引用(备份),所以稍后调用 .next() 可以重新激活堆栈结构并且继续执行。当生成器运行时,它和调用者处于同一线程中,拥有确定的连续执行顺序,永不并发。
遍历器对象的 next 方法的运行逻辑如下:
- 遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值
- 下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式
- 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值
- 如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined
生成器是迭代器!
迭代器是 ES6 中独立的内建类,同时也是语言的一个扩展点,通过实现 [Symbol.iterator]() 和 .next() 两个方法就可以创建自定义迭代器。
// 应该弹出三次 "ding"for (var value of range(0, 3)) { alert("Ding! at floor #" + value);}
我们可以使用生成器实现上面循环中的 range 方法:
function* range(start, stop) { for (var i = start; i < stop; i++) yield i;}
生成器是迭代器,所有的生成器都有内建 .next() 和 [Symbol.iterator]() 方法的实现,我们只需要编写循环部分的行为即可。
for...of 循环可以自动遍历 Generator 函数时生成的 Iterator 对象,且此时不再需要调用 next 方法。
function* foo() { yield 1; yield 2; yield 3; yield 4; yield 5; return 6;}for (let v of foo()) { console.log(v);}// 1 2 3 4 5
上面代码使用 for...of 循环,依次显示 5 个 yield 表达式的值。这里需要注意,一旦 next 方法的返回对象的 done 属性为 true,for...of 循环就会中止,且不包含该返回对象,所以上面代码的 return 语句返回的6,不包括在 for...of 循环之中。
下面是一个利用 Generator 函数和 for...of 循环,实现斐波那契数列的例子:
function* fibonacci() { let [prev, curr] = [0, 1]; for (;;) { [prev, curr] = [curr, prev + curr]; yield curr; }}for (let n of fibonacci()) { if (n > 1000) break; console.log(n);}
除了 for...of 循环以外,扩展运算符(...)、解构赋值和 Array.from 方法内部调用的,都是遍历器接口,这意味着它们都可以将 Generator 函数返回的 Iterator 对象作为参数。
使用 Generator 实现生产消费者模式
function producer(c) { c.next(); let n = 0; while (n < 5) { n++; console.log(`[PRODUCER] Producing ${n}`); const { value: r } = c.next(n); console.log(`[PRODUCER] Consumer return: ${r}`); } c.return();}function* consumer() { let r = ''; while (true) { const n = yield r; if (!n) return; console.log(`[CONSUMER] Consuming ${n}`); r = '200 OK'; }}const c = consumer();producer(c);
[PRODUCER] Producing 1[CONSUMER] Consuming 1[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 2[CONSUMER] Consuming 2[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 3[CONSUMER] Consuming 3[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 4[CONSUMER] Consuming 4[PRODUCER] Consumer return: 200 OK[PRODUCER] Producing 5[CONSUMER] Consuming 5[PRODUCER] Consumer return: 200 OK[Finished in 0.1s]
异步流程控制
ES6 诞生以前,异步编程的方法大概有下面四种:
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
想必大家都经历过同样的问题,在异步流程控制中会使用大量的回调函数,甚至出现多个回调函数嵌套导致的情况,代码不是纵向发展而是横向发展,很快就会乱成一团无法管理,因为多个异步操作形成强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改,这种情况就是我们常说的"回调函数地狱"。
Promise 对象就是为了解决这个问题而提出的,它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。然而,Promise 的最大问题就是代码冗余,原来的任务被 Promise 包装一下,不管什么操作一眼看去都是一堆 then,使得原来的语义变得很不清楚。
那么,有没有更好的写法呢?
哈哈这里有些明知故问,答案当然就是 Generator!Generator 函数是协程在 ES6 的实现,整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器,Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因,除此之外,它还有两个特性使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。
function* gen(x){ var y = yield x + 2; return y;}var g = gen(1);g.next() // { value: 3, done: false }g.next(2) // { value: 2, done: true }
上面代码中,第一个 next 方法的 value 属性,返回表达式 x + 2 的值3,第二个 next 方法带有参数2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量 y 接收,因此这一步的 value 属性返回的就是2(也就是变量 y 的值)。
function* gen(x){ try { var y = yield x + 2; } catch (e){ console.log(e); } return y;}var g = gen(1);g.next();g.throw('出错了');// 出错了
上面代码的最后一行,Generator 函数体外,使用指针对象的 throw 方法抛出的错误,可以被函数体内的 try...catch 代码块捕获,这意味着出错的代码与处理错误的代码实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
Generator 函数的自动流程管理
Thunk 函数
函数的"传值调用"和“传名调用”一直以来都各有优劣(比如传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失),本文不多赘述,在这里需要提到的是:编译器的“传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体,这个临时函数就叫做 Thunk 函数。
function f(m) { return m * 2;}f(x + 5);// 等同于var thunk = function () { return x + 5;};function f(thunk) { return thunk() * 2;}
任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 JavaScript 语言 Thunk 函数转换器:
// ES5 版本var Thunk = function(fn){ return function (){ var args = Array.prototype.slice.call(arguments); return function (callback){ args.push(callback); return fn.apply(this, args); } };};// ES6 版本const Thunk = function(fn) { return function (...args) { return function (callback) { return fn.call(this, ...args, callback); } };};
你可能会问, Thunk 函数有什么用?回答是以前确实没什么用,但是 ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。
首先 Generator 函数本身是可以自动执行的:
function* gen() { // ...}var g = gen();var res = g.next();while(!res.done){ console.log(res.value); res = g.next();}
但是,这并不适合异步操作,如果必须保证前一步执行完,才能执行后一步,上面的自动执行就不可行,这时 Thunk 函数就能派上用处,以读取文件为例,下面的 Generator 函数封装了两个异步操作:
var fs = require('fs');var thunkify = require('thunkify');var readFileThunk = thunkify(fs.readFile);var gen = function* (){ var r1 = yield readFileThunk('/etc/fstab'); console.log(r1.toString()); var r2 = yield readFileThunk('/etc/shells'); console.log(r2.toString());};
上面代码中,yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数,这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数,为了便于理解,我们先看如何手动执行上面这个 Generator 函数:
var g = gen();var r1 = g.next();r1.value(function (err, data) { if (err) throw err; var r2 = g.next(data); r2.value(function (err, data) { if (err) throw err; g.next(data); });});
仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性,这使得我们可以用递归来自动完成这个过程,下面就是一个基于 Thunk 函数的 Generator 执行器:
function run(fn) { var gen = fn(); function next(err, data) { var result = gen.next(data); if (result.done) return; result.value(next); } next();}function* g() { // ...}run(g);
Thunk 函数并不是 Generator 函数自动执行的唯一方案,因为自动执行的关键是,必须有一种机制自动控制 Generator 函数的流程,接收和交还程序的执行权,回调函数可以做到这一点,Promise 对象也可以做到这一点。