10081677wc / blog

78 stars 6 forks source link

关于协程和 ES6 中的 Generator #30

Open 10081677wc opened 6 years ago

10081677wc commented 6 years ago

关于协程和 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,结果可能是:

1
2
x
y
3
z

但是在 A 中是没有调用 B 的,所以协程的调用比函数调用理解起来要难一些。看起来 A、B 的执行有点像多线程,但协程的特点在于是一个线程执行,和多线程比协程最大的优势就是协程极高的执行效率,因为子程序切换不是线程切换,而是由程序自身控制,因此没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显;第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态即可,所以执行效率比多线程高很多。

Wiki 中的定义: Coroutine

协程是一种程序组件,是由子例程(过程、函数、例程、方法、子程序)的概念泛化而来的,子例程只有一个入口点且只返回一次,而协程允许多个入口点,可以在指定位置挂起和恢复执行。

解释协程时最常见的就是生产消费者模式:

var q := new queue

coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume

coroutine 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 queue

generator 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 "我们下次再见!";
}

这段代码看起来很像一个函数,我们称之为生成器函数,它与普通函数有很多共同点,但是二者有如下区别:

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 方法的运行逻辑如下:

  1. 遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值
  2. 下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式
  3. 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值
  4. 如果该函数没有 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 诞生以前,异步编程的方法大概有下面四种:

  1. 回调函数
  2. 事件监听
  3. 发布/订阅
  4. 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 对象也可以做到这一点。