Skip to main content

async/await异步模型是否优于stackful coroutine模型?

· 18 min read

问题

async/await异步模型是否优于stackful coroutine模型?

这里说的coroutine是stackful的,可以对比js的async/await和fibjs,还有c#的async/await对比go的goroutine。

async/await的优点是不需要为每个coroutine分配单独的栈内存,只需要一个闭包保存状态,避免了内存浪费;在调度上也更加灵活,可以让用户手动调度;另外在实现上也不需要改动虚拟机,只要在语言层面加上generator的支持就可以实现。那么是不是可以认为async/await是一个更好的异步方案呢?

回答

乔捷

原文 乔捷 自答一波,好多答案里似乎都搞错了概念,导致了一些无谓的争议。这里谈一下我的理解。

首先coroutine 是个很宽泛的概念,async/await

也属于coroutine的一种。但是问题是拿async/await和stackful coroutine比较。所谓stackful是指每个coroutine有独立的运行栈,比如每个goroutine会分配一个4k的内存来做为运行栈,切换goroutine的时候运行栈也会切换。stackful的好处在于这种coroutine是完整的,coroutine可以嵌套、循环。

与stackful对应的是stackless coroutine,比如generator,continuation ,这类coroutine不需要分配单独的栈空间,coroutine状态保存在闭包里,但缺点是功能比较弱,不能被嵌套调用,也没办法和异步函数

配合使用进行控制流的调度,所以基本上没办法跟stackful coroutine做比较。

但是async/await的出现,实现了基于stackless coroutine的完整coroutine。在特性上已经非常接近stackful coroutine了,不但可以嵌套使用也可以支持try catch。所以是不是可以认为async/await是一个更好的方案?

最后有个匿名用户死活在哪里纠结并发需要多线程,这里我统一做个回复。很多人是从多核时代入行的,看到的异步框架都是使用了线程池,所以想当然的认为并发必须依赖多线程去处理,更有人连并发和并行的概念都搞混,认为单核CPU就不能并发了。实际上并发这个概念在没有多核CPU甚至没有线程的年代(早期的Linux是没有线程的)就有了。并发一般特指IO,IO是独立于CPU的设备,IO设备通常远远慢于CPU,所以我们引入了并发的概念,让CPU可以一次性发起多个IO操作而不用等待IO设备做完一个操作再做令一个。怎么实现呢?原理就是非阻塞操作+事件通知,在核心态非阻塞操作对应的是读写端口和DMA,而事件通知则有专门的术语叫中断响应。过程有2种,一种是IO设备发起中断告诉CPU现在可以进行IO操作,然后CPU进行相应的操作,还有一种是CPU先发起IO操作,然后IO设备完成处理后发起中断告诉CPU操作完成。在核心态是不存在多线程这种概念的,一切都是异步的事件驱动(中断响应),线程是核心给用户态提供的高层概念,线程本身也依赖中断来进行调度。早期的用户态IO并发处理是用poll(select)模型去轮询IO状态,然后发起相应的IO操作,称之为事件响应式的异步模型,这种方式并不容易使用,所以又发展出了阻塞式IO操作,让逻辑挂起并等待IO完成,为了让阻塞式IO能够并发就必须依赖多线程或者多进程模型来实现。但是线程的开销是非常大的,当遇到大规模并发的时候多线程模型就无法胜任了。所以大规模并发时我们又退回去使用事件响应,epoll在本质上还是poll模型,只是在算法上优化了实现,此时我们只用单线程就可以处理上万的并发请求了。直到多核CPU的出现,我们发现只用一个线程是无法发挥多核CPU的威力的,所以再次引入线程池来分摊IO操作的CPU消耗,甚至CPU的中断响应也可以由多个核来分摊执行,此时的线程数量是大致等于CPU的核心数而远小于并发IO数的(这时CPU能处理百万级的并发),线程的引入完全是为了负载均衡而跟并发没有关系。所以不管是用select/epoll/iocp在逻辑层都绕不开基于事件响应的异步操作,面对异步逻辑本身的复杂性,我们才引入了async/await以及coroutine来降低复杂性。

朱元

原文 朱元

我们要先想清楚:问题是什么?

当代码遇到一个“暂时不能完成”的流程时(例如建立一个tcp链接,可能需要5ms才能建立),他不想阻塞在这里睡眠,想暂时离开现场yield

去干点别的事情(例如看看另外一个已经建立的链接是否可以收包了)。问题是:离开现场后,当你回来的时候,上下文还像你走的时候吗?

跳转离开,在任何语言里都有2种最基本的方法:1)从当前函数返回; 2)调用一个新的函数。 前者会把上下文中的局部变量和函数参数全部摧毁,除非他返回前把这些变量找个别的地方保存起来;后者则能保护住整个上下文的内存(除了函数返回之后会摧毁一些我们高级语言所看不见的寄存器),而且跳转回来也是常规方法:函数返回。

async/await和有栈协程的区别就在于,在这里分别选用了这2种方法:

前者(async/await)在函数返回前把那些变量临时保存在堆的某个地方,然后把存放地址传回去,当你想返回现场的时候,把这些变量恢复,并跳转回离开时候那个语句;持有指针语义的c/c++语言则略麻烦:因为可能这些局部变量中有谁持有另一个局部变量的地址,这样“值语义”的恢复就会把他们变成野指针,所以需要在进入函数时所有的局部变量和函数参数都在堆上分配,这样就不会有谁持有离开时栈上下文的指针了,换句话说,对c/c++来说,这是一种无栈协程(有些自己写的无栈协程库提供你在堆上面分配局部变量的接口,或者强迫你在进入这个函数前把要用到的所有局部变量在堆上面分配好内存),其它语言只要没有值语义或变量天生不放栈上就没这个概念。如果使用闭包语法返回现场,可以只需要恢复闭包中捕获的变量;对于c++,在离开现场时不能提前析构掉那些没有被捕获的变量(否则析构顺序未必是构造顺序的反序,其实这个c++规则真是没必要)。所以从C++的观点来说,这是一种彻头彻尾的“假”函数返回(有垃圾回收器的语言倒是有可能走到async之后的语句后,回收前面已经不用的临时变量)。

后者(有栈协程)在离开前只需要把函数调用中可能被破坏的callee-saved 寄存器给保存在当前栈就完事了(别的协程和当前协程栈是完全隔离的,不会破坏自己堆栈),跳转回来的时候把在栈中保存的寄存器

都恢复了并跳转回离开时候那个语句就行了。

综上:前者(尤其是c、c++)需要编译器的特殊支持,对使用了async/await语义的函数中的局部变量的分配,恢复进行些特殊的处理;后者则只需要写写汇编就搞定了(一般需要给 进入协程入口函数,协程间切换,协程函数入口函数返回后回收协程资源并切换去另一个协程 这3个地方写点汇编,也有的协程库把这3种情况都统一起来处理)。

谁优谁劣呢?

语法友好度:衡量这个玩意儿的标准,莫过于“逻辑聚合性”:逻辑相关的代码能否写在相近的代码处。例如 redis/nginx中处处可见这种上下文被分割的代码,因为任何一个“暂时不能完成“的场景都会把场景前后代码逻辑写在完全不同的两个函数里。。对于async/await 或无栈协程语义,c/c++在没有闭包之前的,还需要达夫设备跳转回离开现场的那行代码,有了闭包之后,上下文之间就只被return ( [xxx](){ 分开了,代码可以认为基本没有被分割( C# 新版js, VC和clang实验性的resumable function连这点分开都没有了);不过依然远远比不上有栈协程,因为他语法完全是常规的函数调用/函数返回,使用hook之类的手法甚至可以把已有的阻塞代码自动yield加无阻塞化(参见libco, libgo)。可以认为在这一项:前者在得到现代化编译器辅助后,和后者相近但依然有差距且容易对一些常识产生挑战;后者语法非常适合传统编程逻辑。

时间/空间效率:async/await 语义执行的是传统的函数调用函数返回流程,没有对栈指针进行手工修改操作,cpu对return stack buffer的跳转预测优化继续有效;有栈协程需要在创建时根据协程代码执行的最坏情况提前分配好协程函数的栈,这往往都分配的过大而缺乏空间效率,而且在协程间切换的时候手工切换栈,从而破坏了return stack buffer跳转预测,协程切换后函数的每一次返回都意味着一次跳转预测失效,所以流程越复杂有栈协程的切换开销越大(非对称调度的有栈协程会降低一些这方面的开销,boost新版有栈协程彻底抛弃了对称协程)。对于async/await 语义实践的无栈协程,如果允许提前析构不被捕获的C++变量,或者你返回前手工销毁或者你用的是带垃圾回收器的语言,空间效率会更佳。 可以认为在这一项:前者远胜后者,而且后者会随着你业务复杂度加深以及cpu流水线的变长(还好奔4过后的架构不怎么涨了)而不断变差。笔者写的yuanzhubi/call_in_stack yuanzhubi/local_hook, 以及一个没有开源的jump assembler(把有栈协程切换后的代码输出的汇编语句中的ret指令全部换成pop+jmp指令再编译,避开return stack buffer 预测失败)都是来优化有栈协程在时间/空间的表现的 。

调度:其实2者都是允许用户自己去管理调度事宜的,不过前者必须返回由调度函数选择下一个无栈协程的切入,后者允许”深度优先调度“:即当某个协程发现有“暂时不能完成“的场景时自己可以根据当前场景选择一个逻辑相关的协程进行切入,提升内存访问局部性,不过这对使用者的要求和业务侵入度非常高。。整体而言的话,可以认为在这一项:前者和后者大致持平,前者是集中式管理而后者是分布式管理

,后者可以挖掘的潜力更高但对使用者要求很高且未必能适应业务的变更。

结论:性能上,前者有一定时间优势但不是精雕细琢的多用途公共开源组件完全可以忽略,而空间上前者超越后者很多;易用度上,前者正在快速演进 慢慢的追上后者(c#这样的async/await鼻祖已经完全不存在这个问题);和已有组件的可结合度上,后者始终保持优势(不管已有组件是源码还是二进制)。孰优孰劣,如何侧重,如何选择(如果你们有选择的机会的话),,也许 纯属你头儿的口味问题吧 哈哈哈。

看到很多同学提状态机,其实这种理解没有什么问题,而是人和编译器的观点有所不同:人会抽象出很多状态,在痛苦这些状态如何在各种上下文跳转中传递和保存(状态机); 编译器则在痛苦怪异的上下文跳转中,局部变量的保存和恢复(无栈协程)。 前者会自行决定某些局部变量是“真的局部”变量,后续无需恢复了;后者会把他们全盘考虑下来,把所有的量都要在各个状态间传递和保存(当然有的语言可以智能些,按需传递)。从本质来说,如果是由编译器来玩状态机实现的async/await和无栈协程的,概念上没有什么区别。 人才说状态,机器只说变量,内存这些。