3.215. 说说对协程的看法
今天有人给我发了一个用协程优化线程的方案,我已经看过很多次这样的东西了,每次我对这个概念都很晕,我觉得它没穿衣服,但别人都说看见了衣服。所以我要当一下那个幼稚的小孩,来扒一下协程的皮。请各位读者来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依赖本来就存在,你一开始就不应该把它们分到不同的上下文中调度,这不就成了发明一个问题自己打自己了?
3.215.1. 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); //根据上下文进行处理,并更新上下文状态
这有两个观察:
前者看起来确实更直接,但考虑一下加上异常处理,比如v1之后收到了v3,你要处理所有这些行为,你还会觉得方便吗?
这种情况使用coroutine确实不影响效率
暂时来说,我的结论是协程确实提供了更丰富的表达能力,但认为它可以优化线程调度,那更多是线程本身没有设计好,它本身也没有把压力分解到多个线程去的作用。如果让我选,我还是会直接用队列和ctx池搞定的。
3.215.2. 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上的建议,也是觉得应该用状态机解决的。我承认协程是语法糖,但我不觉得这个语法糖有多甜。我上面的例子都没有做异常处理,如果加上异常处理,这个过程不做状态设计,我觉得很不可靠。
3.215.3. 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()的切换就可以是函数一级的切换,变成了队列调度。
这个效率主要体现在语法糖上,并没有比使用上下文和状态机更高效,但它却是起到让代码更清晰的效果。
3.215.4. 20200614补充(补充4)
这可能是最后一个补充了,我总结一下最后我对协程的认识。
首先,不同的人对协程有不同的认识,不同的编程语言实现明显也给了协程这个概念不同的语义,综合讨论中大部分人的意见,我对协程的最终总结是这样的:
协程是一种进程内进行低成本调度的机制。使用者通过调用协程库的函数在协程间进行主动调度,从而实现把一个线性的同步调用的代码进行简化的目的。
协程的调度成本比函数调用差,但比线程调度高,函数的调用成本可以这样理解::
insts1
A.a()
insts2
函数调用相当于在本执行序列中,插入另一个执行上下文,按ABI协议,函数内部必须保存使用过的持久寄存器(Saved Register),对比直接A.a()的逻辑在原来的位置上展开,这多了部分成本。另一方面,函数可以任意使用临时寄存器(emporaries),所以,跨越A.a()的时候,临时寄存器必须重新初始化,这也产生部分成本。
但如果变成协程,以上序列将变成这样::
insts1
cr_sched()
insts2
在cr_sched()内部,我们必须首先保证这个序列的上下文可以恢复,这时我们仍可以不保存临时寄存器(因为这个上下文本来就不保证临时寄存器没有发生变化),但我们需要保存所有的持久寄存器(无论协程中是否使用它了),这个提高了成本。同时,我们需要在协程列表中找到一个可以调度的协程,并把它投入执行。
这个过程的成本比函数调用高,但比线程调度成本低,因为至少它不需要保存临时寄存器。此外,很多线程库调度还有内核切换的成本在其中。
这样一个机制,带来的最大好处是优化(注意,不是简化)的表达。对于类似这样的状态机模式:
它可以表达为一个简单的线性逻辑::
try:
wait_io1() //S1
handle_io1()
wait_io2() //S2
handle_io2()
...
wait_ion() //Sn
...
Execept:
fallback(); //S1 or exit
这比较容易“看”,但如果状态机变得复杂,这个设计并不能带来优势。
3.215.5. 关于架构的一些扩展讨论
最后我们从架构设计的角度来解释一下这个讨论。这个讨论是一个比较典型的架构讨论。如果把我和各位参与讨论的读者看作是一个设计团队,我们要研究一个问题,就需要把所有人的知识和经验展示出来。我们不能指望我们到设计的时候才去深入学习某种知识(就算要,也是在决定策略以后的事情)。
我经常在这个专栏中谈“守弱”,比如:
背后支撑这些观点的案例主要就是本文中说的这种情形。第一个组织这个逻辑的人,代表我们整组人其中一个经验,这个经验显然并不“强”。但如果每个人都自重身份,不肯展示这个“弱”,或者你自己怕露怯,非要反复学习,没有百分比把握前不肯组织这个逻辑,这个事情就会一直都没有进展。
所以,守弱不是让别人,不是展示出你被人欺负的样子,展示出你被人欺负的样子,就已经是守强了,因为别人承认你“被欺负”,就已经认为你是对的了。真正的守弱是真的用你自己的“无能”,展示团队的“无能”,从而修正这些“无能”,所以这个团队才变强的。
这种情况几乎天天都发生在架构设计中,很多概念,理念,我们说起来好像都知道它什么意思,听到第一个名字,我们就可以谈得热火朝天,然后很容易就落到这个名字上:“你懂XXX的真正含义吗?”,“你懂个屁的XXX”,“你对XXX一无所知”……这些讨论和XXX这件事毫无关系。
架构师要抱朴见素,就要把XXX这个名字拆开,让它变成:如果我们这样认为这个名字的概念,那么我们的这个设计将会变成xxxxxx这个样子,这其中的收益是xxxxxxxx,这是大家的认知吗?不是?那你说说这个逻辑链哪里不对?应该如何调整?
这样慢慢,我们就能达成这个知识水平的最优解,这不见得最终“终极”答案了,但它是我们需要操作前可以得到的最好答案了。