关于Generator 与协程

120 阅读7分钟

在早期的 JavaScript 中,常见的异步编程方式是使用回调函数来处理异步操作的结果。如果有多个异步操作依赖于前一个操作的结果,就会出现嵌套的回调函数。这种嵌套的结构会导致代码层级过深,造成可读性差、难以调试和扩展的问题,形成了回调地狱。

如图所示:

image.png

为了解决回调地狱的问题出现了Promise 使⽤Promise能很好地解决回调地狱的问题,但是这种⽅式充满了Promise的then()⽅法 如果处理流程⽐较复杂的话,那么整段代码将充斥着then,语义化不明显,代码不能很好地表⽰执⾏流程。

image.png

要是能像同步代码那样去写javascript就好了 那样代码的语义化 可读性都能得到完善 大幅度简化编写和维护成本

基于这个问题 ES6 引入了 generator/yield 概念 我们来了解一下 ⽣成器(Generator)是如何⼯作的以及了解Generator的底层实现机制⸺协程

首先 我们先了解一下什么是 生成器函数

生成器函数是一个带*的函数 他是可以暂停和恢复执行的

看一段代码:

function* genDemo() {
  console.log("开始执⾏第⼀段")
  const a = yield 'generator 1'
  console.log("开始执⾏第⼆段", a)
  yield 'generator 2'
  console.log("开始执⾏第三段")
  yield 'generator 3'
  console.log("执⾏结束")
  return 'generator 4'
}
let gen = genDemo()
let result = gen.next()
console.log(result)


setTimeout(()=>{
  console.log('----------- 2s 后 ------------')
  result =  gen.next(123)
  console.log(result)
  while(!result.done){
    result =  gen.next()
    console.log(result)
  }

},2000)

当执行 genDemo() 时会返回 迭代器 对象 函数内部代码并没有执行 当你执行gen.next() 时 内部代码开始执行 此时控制台输出 '开始执⾏第⼀段' 遇到 yield 后内部代码停止执行 并将 yield 后面的内容作为value值 同时标记done为false 返回 所以 第一执行完 gen.next()后 得到 结果result = { value: 'generator 2', done: false }

image.png

genDemo内部将不再执行直到再次执行 gen.next()

我们两秒钟后再次执行 gen.next(123) 并传入参数123 此时函数内部会从之前yield 位置恢复执行 并将 next(123)的参数123 当成结果 给 const a 赋值 并继续向下执行 直到遇见下一个yield停止运行并返回yield 后面的值 然后等待下一次 gen.next() 就这样重复 直到 遇见 return 此时会返回 return 后的值 并将 done 标记为true 则 整个genDemo函数执行完成

上面代码的执行结果:

image.png

注意一点第一次执行 gen.next(111) 里面有参数是无效的 还有就是 如果遇到return时不管后面是否还有逻辑都会结束 再次执行gen.next()返回的则为{value:undefined,done:true} 如果genDemo 中没有return语句 函数结尾会默认return undefined

试验一次 得到的结果 如下:

function* genDemo() {
  console.log("开始执⾏第⼀段")
  const a = yield 'generator 1'
  console.log("开始执⾏第⼆段", a)
  if(a > 10){
    return '提前结束了'
  }
  yield 'generator 2'
  console.log("开始执⾏第三段")
  yield 'generator 3'
  console.log("执⾏结束")
  return 'generator 4'
}
let gen = genDemo()
let result = gen.next(111)
console.log(result)


setTimeout(()=>{
  console.log('----------- 2s 后 ------------')
  result =  gen.next(123)
  console.log(result)
  while(!result.done){
    result =  gen.next()
    console.log(result)
  }
  result =  gen.next()
  console.log(result)

},2000)

image.png

关于上面说到的 函数的暂停和恢复执行 你一定会好奇其中的原理接下来我们就简单介绍一下javascript 引擎V8 是如何实现函数的暂停和恢复的

要搞懂函数为何能暂停和恢复 那你⾸先要了解协程的概念。协程是⼀种⽐线程更加轻量级的存在 你可以把协程看成是跑在线程上的任务,⼀个线程上可以存在多个协程,但是在线程上同时只能执⾏⼀个协程,⽐ 如当前执⾏的是A协程,要启动B协程,那么A协程就需要将主线程的控制权交给B协程,这就体现在A协程 暂停执⾏,B协程恢复执⾏;同样,也可以从B协程中启动A协程。通常 如果从A协程启动B协程我们就把A协程称为B协程的父协程

我们再来看一段代码 来分析协程的执行流程

function* genDemo() {
  console.log("开始执⾏第⼀段")
  yield 'generator 2'
  console.log("开始执⾏第⼆段")
  yield 'generator 2'
  console.log("开始执⾏第三段")
  yield 'generator 2'
  console.log("执⾏结束")
  return 'generator 2'
}
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

协程执⾏流程图

image.png

通过上述代码得知 如果这个线程只有一个常规任务也可理解这个任务就是一个协程 而通过调用一个generator函数 如上述的genDemo 当他被genDemo()执行时就会创建一个协程 外面执行的js任务就是 gen协程的父协程

父协程通过调用gen.next 切换 gen协程 而gen协程 通过 yield 交出线程控制权给父协程 因为父子协程都有自己的调用栈 V8是如何切换他们的调用栈呢?

要回答上面的问题要搞清楚两点

第⼀点:gen协程和⽗协程是在主线程上交互执⾏的,并不是并发执⾏的,它们之前的切换是通过yield和 gen.next来配合完成的

第⼆点:当在gen协程中调⽤了yield⽅法时,JavaScript引擎会保存gen协程当前的调⽤栈信息,并恢复⽗协程的调⽤栈信息。同样,当在⽗协程中执⾏gen.next时,JavaScript引擎会保存⽗协程的调⽤栈信息,并恢复gen协程的调⽤栈信息。(需要了解CPU调用栈)

到这⾥相信你已经弄清楚了协程是怎么⼯作的,其实在JavaScript中 generator就是协程的⼀种实现⽅式

弄清楚 generator和协程概念后 我们知道要想执行 generator函数需要在合适的时机去调用 next方法直到函数执行完因为遇到异步操作需要等到异步完成才能继续调用next 这就又出现了难题 该如何去调用next呢?如果能自动去调用那岂不是爽歪歪?

我们再实现一个方法 他能接收一个 iterator 迭代器 (genDemo()的返回结果 gen 就是一个迭代器对象) 然后去自动执行 next 方法 代码如下:

function co(it){
  return new Promise((resolve,reject)=>{
    const next = (v)=>{
      const {value, done} = it.next(v)
      if(!done){
        // 无论是同步还是都转换整promise 并在.then 后 继续递归调用next
        Promise.resolve(value).then(next, reject) 
      }else{
        resolve(value)
      }
    }
    next()
  })
}

co 函数接收一个迭代器(it) 返回一个Promise对象 在promise executor函数中定义next方法 并执行 在next方法中调用it.next()

  • 如果返回结果中done的值为false 则等待Promise.resolve(value).then 后递归调用 next
  • 如果结果中done的值为true 则调用promise实例的resolve 方法 把next() 返回的value参数作为 返回

之前的代码就可写成:

let time = Date.now()
function* genDemo() {
  console.log("开始执⾏第⼀段")
  yield new Promise((resolve)=>{
    setTimeout( resolve,1000)
  })
  console.log("开始执⾏第⼆段")
  yield new Promise((resolve)=>{
    setTimeout( resolve,1000)
  })
  console.log("开始执⾏第三段")
  yield new Promise((resolve)=>{
    setTimeout( resolve,1000)
  })
  console.log("执⾏结束")
  return 'generator 2'
}

co(genDemo()).then(()=>{
  console.log( `----------- ${(Date.now() - time)} ms后 -----------`)
  console.log(' co + generator 执行结束')
})

image.png

genDemo函数中每到一个yield 都等待1s 最后3s后整体执行结束

在ES7中引⼊了async/await,这种⽅式能够彻底告别执⾏器和⽣成器,实现更加直观简洁的代码。其实async/await技术背后的秘密就是Promise和⽣成器应⽤,往低层说就是微任务和协程应⽤

你可以理解为 async/await 是 co + genertor + yield 的语法糖

async函数返回的结果是 promise 对象 所以我们可以直接调用它的then 方法 不再需要 co库去调用使用起来更方便了 赶紧用起来 今天就写到这了 学无止境 再接再厉啊 兄嘚!!!