仓库源文

.. Kenneth Lee 版权所有 2020

:Authors: Kenneth Lee :Version: 1.0

说说对协程的看法


今天有人给我发了一个用协程优化线程的方案,我已经看过很多次这样的东西了,每次我 对这个概念都很晕,我觉得它没穿衣服,但别人都说看见了衣服。所以我要当一下那个幼 稚的小孩,来扒一下协程的皮。请各位读者来K我,看看是我在这个问题上太小孩,还是有 人根本就穿了一件假衣服。

我2004年负责给我们的网络设备研究下一代操作系统和中间件平台,但一时很难立即开发 完成那么多的基础设施,所以,我们从调度器开始做。我们先做了一个基于其他线程之上 的调度器,这个调度器可以承载在我们要用的几个平台的线程库之上,比如Windows, VxWorks和Linux。原理很简单,就是不进入中断调度,所有调度都是这个内部线程之内的 互相调度,只要调用线程库的函数(比如锁,Yield,Wait, Join等等),就产生调度。 这个过程中可以发生OS本身的线程的调度,但我们的线程库看不见。

这个线程库给了我们研究不少问题的机会,比如我们用它研究了不同IO模型的调度效率和 用gdb Server调试多线程程序等。但最终我们正式推荐给产品的时候还是选择了用Linux, 而且也不在它上面再架一层我们的线程,而是提供了一个用于调度的中间件。

因为我们实际上发现,线程调度的成本,其实主要在于这个“不可以预知性”,优势也是这 个“不可预知性”。如果我有一件事要干,这件事分三步a, b, c。我可以写三个模块A,B, C,我们主业务线程只需要顺序调用A.a, B.b, C.c,这个事情就结了,这个成本非常受控 ,因为当我们从A.a切换到B.b,需要的成本仅仅是按照ABI,保存非易失变量,而不用管临 时变量,这样,ABC都不失自由度,同时也不需要保存额外的信息。但如果我们认为abc是 三个线程,那么,在a到b的切换中,我就需要把a用到的所有CPU状态全部保存下来,这是 多余的,因为很多时候b根本就不需要这个保存。如果我们没有在a执行的过程中,打断它 的需要,创建多个线程根本就是在增加成本。

如果a,b,c之间有依赖关系,抢占并没有意义,把load分开也没有任何意义。从一开始, a,b,c就不应该在不同的线程中。如果我们有很多的abc序列,我们要平衡调度它,那么我 们只要给这个业务需要的数据建立一组上下文,然后用不同核上的线程根据我们自己可控 的调度需要,从上下文中取出对应的abc序列,基于这个上下文执行abc其中一步就可以了 ,我们根本没有在a中间打断去执行b这么个需要。我们只有把abc的压力分布到各个CPU上 的压力。这种情况下,我有贴着硬件一层的调度就可以了,我为什么需要额外的调度?加 一层调度就多一个不可控的要素。

所以我们最终的设计是提供一组基于队列的中间件调度器,而不是额外提供一个线程库。 简单说,在每个线程或者线程池内部,我们都可以设计一个队列,每个队列是一个abc的上 下文,线程执行的时候我从里面根据我的调度策略取出一个成员,然后调用不同的函数即 可,这个过程都是函数调用,根本没有基于线程“调度”的需要。

很多人所谓用协程去优化线程,模型常常是这样的:把abc每个根据模块各分配一个或者多 个线程,然后在a,b,c之间进行消息发送,互相激活对方执行。然后再把abc改成协程,然 后说:速度果然提高了。

但你的a,b,c依赖本来就存在,你一开始就不应该把它们分到不同的上下文中调度,这不就 成了发明一个问题自己打自己了?

2020-6-9 18:00更新讨论(补充1)

讨论中很多读者提出了不同意见,我尝试总结一下:不少有实际经验的人(我个人是没有 的:))认为,协程提供了一个简单的多路IO复用的编程接口。其面对的情形可能是这样 的:

我有n个会话,都通过一个IO接口上来,如果有协程我可以这样来做:::

    create_coroutine(n, consumer {
      v1 = Yield(1)        //等待第1个数据
      v2 = Yield(2)        //等待第2个数据,下同
      v3 = Yield(3)
      v4 = Yield(4)
      handle(v1, v2, v3, v4) //处理数据
    })
    while(True):
      data = io() //从IO获得数据
      give_data(data, get_data_vi(data), get_data_coroutine_i(data)) //匹配协程和vi,解锁协程步骤

如果我没有理解错,那我觉得让我干我会这样干:::

    ctxs = create_ctx_pool()             //创建一个上下文池子
    while(True):
      data = io()                        //从IO上获得数据
      ctx = ctxs.data_to_ctx(data)       //从对IO数据上匹配对应的上下文,如果是初始化信息,就分配一个新的上下文
      ctx.update_state(data);            //根据上下文进行处理,并更新上下文状态

这有两个观察:

  1. 前者看起来确实更直接,但考虑一下加上异常处理,比如v1之后收到了v3,你要处理所 有这些行为,你还会觉得方便吗?

  2. 这种情况使用coroutine确实不影响效率

暂时来说,我的结论是协程确实提供了更丰富的表达能力,但认为它可以优化线程调度, 那更多是线程本身没有设计好,它本身也没有把压力分解到多个线程去的作用。如果让我 选,我还是会直接用队列和ctx池搞定的。

2020年6月10日更新讨论(补充2)

针对补充1的例子,有人提出在比如Nodejs这样的环境中,常常会造成Callback Hell,其 原理如下:::

    foreach session:
      v1 = io()
      if v1.is_good:
        v2 = io()
        if v2.is_good:
           v3 = io()
           if v3.is_good:
              v4 = io()
              if v4.is_good:
                handle(v1, v2, v3, v4)

在Nodejs中,这个行为常常使用一层层的回调函数去处理下一轮状态上的行为,比如这样 (还是用Python伪码表示):::

    foreach session:
      io(lambda v1: {
        if v1.is_good:
          io(lambda v2: {
            if v2.is_good:
               io(lambda v3: {
                  if v3.is_good:
                     io(lambda v4: {
                        if v4.is_good:
                           handle(v1, v2, v3, v4)
                     })
                 })
            })
       })

这叫回调地狱,但我看了一下比如stackoverflow上的建议,也是觉得应该用状态机解决的 。我承认协程是语法糖,但我不觉得这个语法糖有多甜。我上面的例子都没有做异常处理 ,如果加上异常处理,这个过程不做状态设计,我觉得很不可靠。

20200611补充(补充3)

有人提出这个概念:

    | 协程只能在io密集型业务当中发挥其威力,
    | 当遇到异步io时调度器就将当前协程上下文保存起来,
    | 待下次io回来时再将协程上下文切换回来继续执行,
    | 这样就能将异步非阻塞io同步化处理,代码非常简单易懂。
    | 同时不会受限于单线程同步io无法并发、
    | 多线程异步io锁以及线程切换代码难写等问题。
    | 协程本质就是异步非阻塞io,对于计算密集型业务,
    | 协程是没法调度的,它的调度切换点只能是io。

我来推演一下如果这样看是协程可以带来的优势:如果我们认为协程库有自己的IO接口, 当协程调用这种约定的IO接口可以调度到其他协程去执行,那么,我们可以这样组织上面 的IO访问程序:::

    ... 假定我们用协程封装socket库,调用cr_sock对象的函数的时候,都用协程库
        来调度。下面这个程序在每次读到一个新的Socket连接的时候,创建一个协
        程进行响应

    def cr_procedure(sock):
      try:
        d1 = sock.recv();
        d2 = sock.recv();
        d3 = sock.recv();
        d4 = sock.recv();
      except e:
        log_err(e)

    def __main__():
      while(True):
        cr_sock = main_socket.accept()
        cr_create(cr_sock, cr_procedure)

首先,这个语法糖的效果确实很明显;第二,这个处理是有收益的:如果我用协程库的另 一个线程来做socket的统一polling,收到以后送到协程的一个队列中,那么,在那个协程 的线程中,sock.recv()的切换就可以是函数一级的切换,变成了队列调度。

这个效率主要体现在语法糖上,并没有比使用上下文和状态机更高效,但它却是起到让代 码更清晰的效果。

20200614补充(补充4)

这可能是最后一个补充了,我总结一下最后我对协程的认识。

首先,不同的人对协程有不同的认识,不同的编程语言实现明显也给了协程这个概念不同 的语义,综合讨论中大部分人的意见,我对协程的最终总结是这样的:

    | 协程是一种进程内进行低成本调度的机制。
    | 使用者通过调用协程库的函数在协程间进行主动调度,
    | 从而实现把一个线性的同步调用的代码进行简化的目的。

协程的调度成本比函数调用差,但比线程调度高,函数的调用成本可以这样理解:::

    insts1
    A.a()
    insts2

函数调用相当于在本执行序列中,插入另一个执行上下文,按ABI协议,函数内部必须保存 使用过的持久寄存器(Saved Register),对比直接A.a()的逻辑在原来的位置上展开,这 多了部分成本。另一方面,函数可以任意使用临时寄存器(emporaries),所以,跨越 A.a()的时候,临时寄存器必须重新初始化,这也产生部分成本。

但如果变成协程,以上序列将变成这样:::

    insts1
    cr_sched()
    insts2

在cr_sched()内部,我们必须首先保证这个序列的上下文可以恢复,这时我们仍可以不保 存临时寄存器(因为这个上下文本来就不保证临时寄存器没有发生变化),但我们需要保 存所有的持久寄存器(无论协程中是否使用它了),这个提高了成本。同时,我们需要在 协程列表中找到一个可以调度的协程,并把它投入执行。

这个过程的成本比函数调用高,但比线程调度成本低,因为至少它不需要保存临时寄存器 。此外,很多线程库调度还有内核切换的成本在其中。

这样一个机制,带来的最大好处是优化(注意,不是简化)的表达。对于类似这样的状态 机模式:

    .. figure:: _static/协程状态机.jpg

它可以表达为一个简单的线性逻辑:::

    try:
      wait_io1()    //S1
      handle_io1()
      wait_io2()    //S2
      handle_io2()
      ...
      wait_ion()    //Sn
      ...
    Execept:
      fallback();   //S1 or exit

这比较容易“看”,但如果状态机变得复杂,这个设计并不能带来优势。

关于架构的一些扩展讨论

最后我们从架构设计的角度来解释一下这个讨论。这个讨论是一个比较典型的架构讨论。 如果把我和各位参与讨论的读者看作是一个设计团队,我们要研究一个问题,就需要把所 有人的知识和经验展示出来。我们不能指望我们到设计的时候才去深入学习某种知识(就 算要,也是在决定策略以后的事情)。

我经常在这个专栏中谈“守弱”,比如:

    :doc:`弱者道之用——谈技术工作中的守弱问题`

    :doc:`再谈“守弱”`

背后支撑这些观点的案例主要就是本文中说的这种情形。第一个组织这个逻辑的人,代表 我们整组人其中一个经验,这个经验显然并不“强”。但如果每个人都自重身份,不肯展示 这个“弱”,或者你自己怕露怯,非要反复学习,没有百分比把握前不肯组织这个逻辑,这 个事情就会一直都没有进展。

所以,守弱不是让别人,不是展示出你被人欺负的样子,展示出你被人欺负的样子,就已 经是守强了,因为别人承认你“被欺负”,就已经认为你是对的了。真正的守弱是真的用你 自己的“无能”,展示团队的“无能”,从而修正这些“无能”,所以这个团队才变强的。

这种情况几乎天天都发生在架构设计中,很多概念,理念,我们说起来好像都知道它什么 意思,听到第一个名字,我们就可以谈得热火朝天,然后很容易就落到这个名字上:“你懂 XXX的真正含义吗?”,“你懂个屁的XXX”,“你对XXX一无所知”……这些讨论和XXX这件事毫无 关系。

架构师要抱朴见素,就要把XXX这个名字拆开,让它变成:如果我们这样认为这个名字的概 念,那么我们的这个设计将会变成xxxxxx这个样子,这其中的收益是xxxxxxxx,这是大家 的认知吗?不是?那你说说这个逻辑链哪里不对?应该如何调整?

这样慢慢,我们就能达成这个知识水平的最优解,这不见得最终“终极”答案了,但它是我 们需要操作前可以得到的最好答案了。