JS 生成器 vs. Lua 协程

1,808 阅读9分钟

“协程(coroutine)”于我而言还是比较新的概念,Lua 也是刚接触不久。不过碰巧这段时间我又在看 ES6 生成器的文章:

然后很自然地发现两者其实是相似的东西。整理、对比的过程,肯定也会加深自己的理解,所以尽管是初学,还是贸然一试了。

JS 生成器

先来看一个简单的例子:

function *g() {
  yield 1
  yield 2
  yield 3
}

通过 function* 声明了一个生成器函数,这是 ES6/ES2015 引入的新的函数类型,也是下面主要分析的对象。

可以看到,函数体内还有新的 yield 关键字。yield 这里在函数执行时会产生“中断”,而中断时函数的执行环境(变量等)会被保存下来,然后在某个时刻,可以返回中断的位置继续执行函数,同时执行环境被还原。这是我理解的生成器函数的主要特性。

也就是说,和一般的函数不同,生成器函数的执行过程可能是这样的:

  • 开始执行
  • 暂停
  • 继续
  • 暂停
  • 继续
  • ...
  • 执行结束

而且暂停和继续之间,其他的代码可以获得控制权进行执行,并且决定在什么时候继续生成器函数的执行,甚至可以永远不继续执行生成器函数。

我们来运行一个完整的示例,看下生成器函数的用法:

var o = g()
console.log(o.next()) // {value: 1, done: false}
console.log(o.next()) // {value: 2, done: false}
console.log(o.next()) // {value: 3, done: false}
console.log(o.next()) // {value: undefined, done: true}

与普通函数不同,调用生成器函数并不是真正执行了函数,而是返回了一个“生成器对象”。从这一点上来看,生成器函数有点类似“构造函数”的感觉,每次调用返回一个新的对象。

当然,这个新的对象也是比较特殊的,它是一个迭代器对象,遵循“iterator”协议。从迭代的角度,也可以称之为“迭代器对象”。

iterator 协议也是 ES6 新增的,它要求支持该协议(或者说接口吧,虽然 JS 中并没有接口)的对象提供一个 next() 方法,每次调用时可以返回一个结果对象。这个迭代结果对象包含 valuedone 两个属性。在较新版本的 Chrome 控制台执行下示例代码,可以看到,value 是返回的值,done 表示迭代是否执行完成。

看到这里,貌似生成器的确和其名字一样,可以生成一些值,只不过这些值是断断续续地返回的,需要其他的代码主动去获取。基于这些,我们可以做一个能够持续不断返回数据的生成器函数:

function *num() {
  var i = 0
  while (true) {
    yield i++
  }
}

有点暴力,不过的确可行:

var n = num()
console.log(n.next()) // {value: 0, done: false}
console.log(n.next()) // {value: 1, done: false}
console.log(n.next()) // {value: 2, done: false}

可以一直调用 n.next() 下去,和前面的 *g() 不同,这个生成器函数貌似不会主动结束。

ES6 还引入了 for ... of 语句,专门用于迭代,例如,可以这样:

for (var i of g()) {
  console.log(i)
}

for (var n of num()) {
  if (n < 10) {
    console.log(n)
  } else {
    break
  }
}

这个也很容易理解,不多说了。

不过,这还不是有关生成器的全部,如果仅仅是这样,那和后面要介绍的 Lua 的协程的区别就太大了,比人家的能力差得太多。

生成器还支持在生成器函数内外进行数据传递,来看一个例子:

function *query(name) {
  var age = yield getAgeByName(name)
  console.log('name: `' + name + '` age: ' + age)
}

我们把生成器函数 *foo() 作为普通函数来看待,它封装了一段的逻辑。在运行时,外部传入一个名字(name),通过调用 getAgeByName() 来获得名字对应的年龄,然后打印出来。

我们假设 getAgeByName() 是这样的:

function getAgeByName(name) {
  var people = [{
    name: 'luobo',
    age: 18
  }, {
    name: 'tang',
    age: 20
  }]

  var person = people.find(p => p.name === name)
  return person.age
}

由于生成器函数与普通函数的执行过程不同,我们定义一个执行生成器函数的函数:

function run(g, arg) {
  var o = g(arg)

  next()

  function next() {
    var result = o.next(arg)
    arg = result.value
    if (!result.done) {
      next()
    }
  }
}

函数 run() 用于生成器函数 g,可以传入一个初始参数 arg,之后每次调用生成器对象的 next() 时,会将上一次调用的返回值传入。

yield 暂停生成器函数执行时,会将一个值返回到生成器外部,而外部程序在调用生成器对象的 next() 方法时可以传入一个参数,这个参数的值会作为 yield 表达式的值使用,然后继续执行生成器函数。

下面我们来实际执行一下上面的 *foo()

run(query, 'luobo') // name: `luobo` age: 18
run(query, 'tang') // name: `tang` age: 20

好像没什么了不起,而且本来很简单的过程,使用了生成器函数好像还有点复杂了。

还是上面的例子,如果我们改变下 getAgeByName() 函数的实现:

function getAgeByName(name) {
  return fetch('/person?name=' + name).then(res => res.json().age)
}

现在根据名字查找年龄的过程是异步的了,需要向服务器获取数据。如果是通过普通函数实现 *query() 的逻辑,那我们需要修改函数的实现,因为同步获取数据和异步是不同的编程方式,通常需要改用回调函数。

不过生成器本身的执行就是“异步”的,而且生成器支持数据传递。所以,借助这个特性,我们其实可以不必修改 *query() 的逻辑,而是在 run() 上做一下处理:

function run(g, arg) {
  var o = g(arg)

  next()

  function next() {
    var result = o.next(arg)
    arg = result.value
    if (!result.done) {
      // 返回值可能是 Promise 对象
      if (arg && typeof arg.then === 'function') {
        arg.then(val => {
          arg = val
          next()
        })
      } else {
        next()
      }
    }
  }
}

将对异步状态的处理拆分到了与逻辑无关的控制函数 run() 中,而具体的逻辑部分(*query())不需要修改代码。

看到这里,是不是有点意思了?当然,或许你早就知道这些了。

稍微总结一下 JS 生成器吧。

JS 生成器除了作为“生成器”来使用,还可以作为一种改善代码编写方式的技术,它可以使得我们能够写出类似“同步”执行的异步代码,这样的代码毕竟更易读和维护。

有关生成器的特性,其实还有很多,本文前面列出的四篇文章中有比较全面的介绍,这里不再赘述。

下面,我们来看 Lua 中的协程。

Lua 协程

先看一个例子:

co = coroutine.create(function ()
    coroutine.yield(1)
    coroutine.yield(2)
    coroutine.yield(3)
  end)

print(coroutine.resume(co)) --> true    1
print(coroutine.resume(co)) --> true    2
print(coroutine.resume(co)) --> true    3
print(coroutine.resume(co)) --> true
print(coroutine.resume(co)) --> false   cannot resume dead coroutine

这个例子和 JS 生成器一节的第一个例子类似,不过有一些区别:

  • Lua 的协程通过 coroutine 库来创建,coroutine.create() 接收的是普通函数,而 JS 则是新增了生成器函数这一新的函数类型
  • Lua 的协程对应的是 thread 类型的值,JS 的生成器函数调用后返回的是迭代器对象
  • Lua 的协程通过 coroutine.resume(co) 的模式来执行,JS 则是利用迭代器接口的 next() 方法来执行
  • Lua 的协程内部通过 coroutine.yield() 来产生中断,JS 则是通过 yield 关键字
  • Lua 的协程中断返回的是一组值,第一个值表示是否执行成功,后续的值为传递的数据,JS 函数只能返回一个值,所以是通过对象来传递状态和返回值的
  • Lua 的协程结束执行后再次调用,会产生异常,JS 不会

Lua 的 for ... in 也可以对协程进行迭代,与 JS 类似:

co = coroutine.wrap(function ()
    coroutine.yield(1)
    coroutine.yield(2)
    coroutine.yield(3)
  end)

for i in co do
  print(i)
end

不过区别在于,由于 Lua 的协程对应的是 thread 类型的值,而并非是迭代器,所以这里通过 coroutine.wrap() 将协程包装为迭代器返回。

Lua 的协程也支持进行数据传递,所以在 JS 部分介绍的所谓“同步”的异步模式,在 Lua 中也是可以实现的。

不过还是可以看出,Lua 的协程的确是“协程”,并不是为了实现“生成器”而设计,只不过“顺便”能够支持作为生成器来使用而已。而 JS 生成器则是作为生成器设计,生成器函数调用后返回的就是一个迭代器,而非像 Lua 那样的一个特殊类型的值(thread)。不过也可以利用 JS 生成器内部的“pause-resume”机制实现一些编程技巧,这就有点协程的味道了。

更深入的分析 Lua 的协程和 JS 生成器的区别,需要对 Lua 的协程有更深入的理解,不过我还没有这样的能力。所以,抱歉,目前只能在表面上做些文章。

总结

协程(coroutine)是一个重要的编程技术,在许多编程语言中都有体现。

Lua 中的协程,据相关文档介绍,是具有较完整的相关特性的实现,而且在 Lua 编程中有广泛的应用,是重要的技术。

JS 的生成器,在 ES6 中才引入,虽然叫做生成器,不过的确有些“协程”的特性,这也是为什么可以基于这些特性构建新的异步编程解决方案,因为生成器函数的执行具有“暂停-继续”的特性。显然协程这一重要的编程技术被引入到了 JS 中。

从更高的层面来讲,协程和多线程是两种解决“多任务”编程的技术。多线程使得同一时刻可以有多个线程在执行,不过需要在多个线程间协调资源,因为多个线程的执行进度是“不可控”的。而协程则避免了多线程的问题,同一时刻实质上只有一个“线程”在执行,所以不会存在资源“抢占”的问题。

不过在 JS 领域,貌似不存在技术选择的困难,因为 JS 目前还是“单线程”的,所以引入协程也是很自然的选择吧。

如有错漏,欢迎指正。

感谢阅读!

更多参考资料: