likes
comments
collection
share

深入理解协程关于协程曾有人说: 协程具有同步的编程方式又具有异步的性能;接下来我们具体看看为什么这么说 关于异步: 什么

作者站长头像
站长
· 阅读数 56

关于协程曾有人说: 协程具有同步的编程方式又具有异步的性能;接下来我们具体看看为什么这么说

关于异步:

我对此的总结是:由多层回调函数导致的代码执行流程混乱,就是回调地狱

上述示例代码其实还没体现出问题的严重性,因为它在每层将任务异步出去后 主流程就没有任务了;按理来说在每一层将异步任务放出去,接着执行主流程任务,主流程和异步任务交织执行会导致所谓的“心智负担”。

如何解决回调地狱呢?我认为思路可以分为两种:

  • 仍然基于异步 + 回调的思路,实现更多补救措施
  • 使用同步代替异步,也就是使用协程

对于第一种思路也有两个层面的做法

  • 在语言层面封装好更易用的API,或者是用起来更像同步调用的API
    1. 比如说Java的Future 和 CompletableFuture,通过拿到异步任务的句柄,就能主动的查看任务是否完成/根据结果执行下一步操作。
    2. 比如说JS的promise 和 async/await,也是拿到代表异步任务的promise对象,由此在主流程设置同步操作。
    3. 响应式编程:(或者叫事件驱动) 当代码中一个异步任务完成后,主流程应该怎么感知到呢?最常用的方式是在主流程中轮询检查某个结果是否产生(也就是变量是否为null),也有一种方法是让异步任务主动通知主流程;响应式编程就是基于第二种思路的演进,它专门指程序在设计时就很关注“接收响应,产生行为”这种模式,也就是我们说的Reactive;(多路复用IO模型中的Reactor模型也基于此);

      夹带私货:刚刚意识到,实习时项目组的项目架构就是源于此,或者是Skynet框架本身就是源于此 —— 游戏项目中将各个服务间通信通过响应式编程实现,是对游戏世界的一种合理抽象。

  • 在编码层面遵守规则
    1. 保持代码浅层
    2. 模块化
    3. 处理每一个错误

    具体都在官网有说明:回调地狱 (callbackhell.com)

对于第二种思路,就是本文要说明的主题 —— 协程:

关于协程

什么是协程:

最核心的点是:一段程序或函数能够“轻松地”被挂起,稍后能够在挂起点恢复执行;本质上是程序控制权的流转机制。

由于早期各个语言对协程没有统一的定义,导致语言层面的实现都稍有不同,有的语言设计为有栈协程,有的认为无栈协程更好;有的设计成对称式协程,有的只提供非对称协程的API...但是在各家对协程定义的交集里,就是 挂起和恢复的能力

协程和线程的区别:

  1. 线程一旦开始执行就不会暂停,直到任务结束,而协程拥有yield的能力

    存疑:线程不也有wait,sleep,notify等能力吗?

  2. 线程的调度是由OS负责,是抢占式的,而协程的调度是由程序自己定义的,往往被设计成协作式
  3. 线程切换需要由OS保存调用栈等上下文,成本高,协程的切换全部在用户态实现
  4. 多线程操作下需要锁来解决抢占式调度产生的顺序不确定的问题,但协程的调度顺序人为设定,并且是串行的,不需要加锁
  5. ...拒绝八股!

协程的分类:

前面说过由于各种语言前期对协程的定义没有统一,于是产生了很多种实现方式,lua之父将这些实现进行了分类

  1. 有栈协程 和 无栈协程
  2. 对称协程 和 非对称协程
  • 有栈协程 & 无栈协程

    此处的有栈/无栈并不是指协程运行时是否需要调用栈,因为当函数调用时肯定都需要使用栈;区别在于:协程挂起时如何实现保存上下文 —— 有栈协程会分配有一段空间,挂起时将上下文信息就保存在属于自己的空间中,无栈协程则是通过其他方式曲线救国。

    • 有栈协程:

      代表语言:go & Lua,但是go routine对栈空间进行了动态扩缩容,最小只有4KB;

      • 优点:
        1. 由于每个协程具有一段内存空间,挂起时完全可以将上下文信息拷贝到内存中来;所以有栈协程在代码的任何位置都可以被挂起,无论是表层方法还是深层嵌套的方法
      • 缺点: 2. 占用空间 代表:Lua
      function counter(max)
         local i = 0
         while i < max do
             i = i + 1
             coroutine.yield(i) -- 每次yield一个数字
         end
      end
      
      -- 创建协程
      local co = coroutine.create(counter)
      
      -- 定义一个函数来运行协程
      function runCoroutine()
         local co = coroutine.create(counter) -- 创建协程
         while true do
             local status, value = coroutine.resume(co) -- 启动并恢复协程
             if not status then
                 print("Coroutine finished or error:", value)
                 break
             end
             print(value)
         end
      end
      
      -- 运行协程
      runCoroutine()
      

      可以看到,对于异步任务counter,其内部除了具有代表挂起的yield关键字外,函数本身和正常函数没有区别,同时使用协程和使用线程的方式也很相似,区别仅仅在于保存上下文的动作由用户态完成而非操作系统完成。

      我在刚接触lua这门语言时,直接把协程当作线程用,用这种方式完成了第一版的向僵尸开炮;但是经主管点拨,才知道这其中的不妥之处 —— 当时项目基于Skynet框架,Skynet的每个服务都有一个协程池(协程池就是指 在创建好协程后,协程中的任务是while(true)的接收传入任务),服务上通过EventLoop处理两类任务,一个是来自其他服务的消息(当前服务要做好响应式编程),另一个是自身触发的加入到任务队列的任务;问题就出在,假如一个服务fork过多的协程,协程池中用于响应外部消息的协程数量减少,自身提供服务的能力就降低了,并且我fork出来的协程经常做一些while(true)的操作,阴差阳错通过每轮结束后sleep操作才实现yield让出...汗颜了

      代表:go routine // todo 等到系统学习完go后在做补充

    • 无栈协程:

      代表语言,python,js,kotlin,c++

      • 缺点: 1.由于没有自由使用该的空间保存上下文,所以无栈协程只能通过其他方式实现上下文的保存,但这些方式都不如有栈协程灵活,尤其体现在无法在程序任意位置挂起

      • 优点: 1.每个协程没有必要的空间占用 2.由于挂起和恢复时不需要将空间中的上下文切换,所以会节约点时间 我暂时学到python和JS两种无栈协程的实现,这两种还有所不同 代表:python

      python的做法很有意思,它通过将上下文信息保存在协程的句柄generator中,协程恢复时在generator里找到执行的位置和变量信息;这本质上是对有栈协程里调用栈的轻量级模仿;

      def simple_generator():
          print("Generator starts")
          x = yield
          print(f"Generator received: {x}")
      
      # 主函数,用于驱动生成器(协程)
      def main():
          gen = simple_generator()  # 创建生成器对象
          next(gen)  # 启动生成器,直到遇到第一个 yield 表达式
          gen.send(42)  # 发送值给生成器,这将从上次挂起的地方恢复执行
      
      if __name__ == "__main__":
          main()
      

      缺点:由于generator只能记录当前方法的执行进度,所以无法支持在多级调用中进行挂起,例如如下代码

      def simple_generator():
          print("Generator starts")
          x = do_yield()
          print(f"Generator received: {x}")
      def do_yield():
          print("内部函数停止")
          x = yield
          return x
      # 主函数,用于驱动生成器(协程)
      def main():
          gen = simple_generator()  # 创建生成器对象
          next(gen)  # 启动生成器,直到遇到第一个 yield 表达式
          gen.send(42)  # 发送值给生成器,这将从上次挂起的地方恢复执行
      
      if __name__ == "__main__":
          main()
      

      在do_yield函数中执行x = yield时,当前函数会返回generator代表对当前函数执行过程的记录,但是无法表示对外层的记录

      对此,python还提供了yield from关键字,用来实现将挂起委托给子函数,本质上是通过多个协程的传递,实现了一个函数任意深度的挂起。(就是父函数先挂起,将调度权交给子函数,子函数执行完后再返回给父函数); 代表:JS

      JS中,通过状态机实现协程,首先由编译器识别出会调用挂起的方法,然后将其转化成状态机代码,简单来说就是将原本代码按照yield前后分成多段,并添加一个属性state,根据state的不同属性决定走那段。并将原来的局部变量抽出来作为全局变量。

      深入理解协程关于协程曾有人说: 协程具有同步的编程方式又具有异步的性能;接下来我们具体看看为什么这么说 关于异步: 什么

转载自:https://juejin.cn/post/7408574834060148763
评论
请登录