最近尝试在搬砖专用语言Java上实现异步。原因及过程不再详述。但是这个过程并没有白费,所以我借此机会回顾了各种异步编程的实现。本文将涉及回调、Promise、reactive、async/await、用户态线程等异步编程的实现。如果您熟悉其中的一两个,您应该也能够快速理解其他的。为什么需要异步?操作系统可以看作是一个虚拟机(VM),进程生活在操作系统创建的虚拟世界中。该进程不需要知道它有多少核和多少内存。只要进程不要求太多,操作系统就会假装有无限的可用资源。基于这种思想,线程(Thread)的数量不受硬件的限制:你的程序可以只有一个线程,也可以有成百上千。操作系统会静默调度,让许多线程共享有限的CPU时间片。这个调度过程对线程是完全透明的。那么,操作系统如何在没有线程感知的情况下进行调度呢?答案是上下文切换(ContextSwitch)。简单的说,操作系统使用软中断机制从任意位置中断程序,然后保存所有当前寄存器——包括最重要的指令寄存器PC和栈顶指针SP,以及一些线程控制信息(TCB),整个进程将产生几微秒的开销。然而,作为一个合格的程序员,你一定听说过线程是昂贵的:线程的上下文切换成本很高,占用宝贵的CPU时间;每个线程都会占用一些(至少一页)内存。这两个原因驱使我们尽量避免创建过多的线程,而异步编程的目的就是消除IO等待阻塞——大多数时候,这是创建一堆线程甚至引入线程池的罪魁祸首。很多人知道Continuation回调函数,但是知道Continuation的人不多。Continuation有时被含糊地翻译为“计算延续”,我们就直接使用这个词吧。中间中断了一个计算过程,剩下的部分用一个对象来表示,就是Continuation。操作系统挂起一个线程时保存的现场数据,也可以看成是一个Continuation。有了它,我们就可以在刚才的断点之后的这个点继续执行了。中断计算过程听起来很重要!其实每时每刻都在发生——假设在函数f()的中间调用了g(),当g()运行完毕后,需要回到f()刚刚调用g()的地方继续执行。这个过程太自然了,以至于所有的编程语言(汇编除外)都将它隐藏起来,让你在编程中感觉不到调用栈的存在。操作系统使用昂贵的软中断机制来保存和恢复堆栈。那么有没有其他的方式来实现Continuation呢?最简单的想法是将所有可用信息打包到一个函数对象中,在调用g()时将其传入,并约定:一旦g()完成,就用结果调用这个Continuation。这种编程模式称为Continuation-passingstyle(CPS):将调用者f()未执行的部分包装成一个函数对象cont,一起传递给被调用者g();正常运行g()函数体;当g()完成时,它会回调以返回其结果,从而继续执行f()中的剩余代码。拿维基百科上的定义来巩固一下:以continuation-passing风格编写的函数需要一个额外的参数:一个显式的“continuation”,即一个参数的函数。当CPS函数计算出它的结果值时,它通过调用以这个值作为参数的延续函数来“返回”它。CPS风格的函数有一个额外的参数:显式延续,特别是只有一个参数的函数。当CPS函数计算完返回值后,它“返回”的方式就是用返回值调用Continuation。你应该发现了,这是回调函数,我只是改了个名字而已。异步的简单实现:Callback只有一个回调函数,其实没什么用。对于纯计算的工作,CallStack已经很不错了,何必费时费力地用回调来做Continuation呢?你是对的,但前提是你没有IO。我们知道IO通常比CPU慢几个数量级。在BIO中,一个线程只能在发起IO后暂停,然后等待IO完成才被操作系统唤醒。varinput=recv_from_socket()//Blockatsyscallrecv()varresult=calculator.calculate(input)send_to_socket(result)//Blockatsyscallsend()在异步IO中,当进程发起一个IO操作时,它也会被输入Callback(即Continuation),极大地解放了生产力——现场无需等待,马上返回做其他事情。一旦IO成功,AIO的EventLoop就会调用刚才设置的回调函数来完成剩下的工作。这种模式有时也称为即发即弃。recv_from_socket((input)->{varresult=calculator.calculate(input)send_to_socket(result)//ignoreresult})就这么简单,通过我们自己的Continuation,线程不再被IO阻塞,可以自由运行完整的CPU。一个语法糖:Promise的回调函数哪里都好,就是不好用,太丑了。第一个问题是可读性大大降低。由于我们绕过操作系统,自己做Continuation,所以所有的函数调用都必须传入一个lambda表达式。你的代码看起来要起飞了,缩进不停地向右移动。不(“回调地狱”)。第二个问题是处理各种细节很麻烦。例如,考虑到异常处理,似乎传递一个延续是不够的。最好传递一个回调来进行异常处理。Promise是对异步调用结果的封装,在Java中称为CompletableFuture(JDK8)或ListenableFuture(Guava)。Promise有两个意思:第一个意思是:我现在还没有真正的结果,但是我保证以后会得到这个结果。这很容易理解。异步任务迟早会完成。如果调用者很笨,他也可以使用Promise.get()来强制获取结果。对了,当前线程阻塞,异步变成同步。第二个意思是:如果你(来电者)有什么吩咐,尽管告诉我。这很有趣。也就是说,回调函数不再传递给g(),而是g()返回的Promise。比如我们之前的代码是用Promise写的,看起来顺眼多了。varpromise_input=recv_from_socket()promise_input.then((input)->{varresult=calculator.calculate(input)send_to_socket(result)//ignoreresult})Promise提高了Callback的可读性,让异常处理更优雅一点A一点点,但毕竟是语法糖。反应式编程反应式(Reactive)起源于函数式编程中的一种模式。随着微软推出ReactiveX项目并一步步壮大,它被移植到各种语言和平台。Reactive最初广泛应用于GUI编程,很快凭借异步调用的高性能在服务端后端领域开花结果。Reactive可以看作是对Promise的极大增强。相对于Promise,Reactive引入了Flow的概念。ReactiveX中的事件流流出一个Observable对象。该对象可以是按钮或RestfulAPI。简而言之,它可以被外界触发。与Promises不同,事件可能会被触发多次,因此处理代码会被多次调用。一旦允许多次调用,从数据流的角度来看,该模型实际上是Push而不是Pull。那么问题来了,如果调用频率高到我们的处理速度跟不上怎么办?因此,RX框架引入了Backpressure机制来进行流量控制。最简单的流控方式是:一旦缓冲区满了,就丢弃后续的事件。ReactiveX框架还有一个好处就是内置了很多好用的算子,比如:merge(流合并)、debounce(开关除颤)等,方便业务开发。下面是RxJava的一个例子:CPS改造:Coroutine和async/await无论是reactive还是Promise,归根结底还是没有摆脱手工构造continuation:开发者需要将业务逻辑写成回调函数。基本可以处理线性逻辑,但是如果逻辑比较复杂呢?(例如,考虑包含循环的情况)C#、JavaScript和Python等一些语言提供了async/await关键字。和Reactive一样,这也是来自微软的C#语言。在这些语言中,你会感受到前所未有的舒畅感:异步编程终于摆脱了回调函数!唯一要做的就是在异步函数调用中加入await,编译器会自动将其转换为协程而不是昂贵的线程。神奇的背后是CPS改造。CPS转换将一个普通函数转换为CPS函数,即Continuation也可以作为调用参数。函数不仅可以从头开始运行,也可以根据Continuation的指令在某个点(比如调用IO的地方)继续运行。示例可以在我的下一篇文章中找到。由于代码太长,这里就不贴了。可以看到,函数不再是一个函数,而是一个状态机。每次调用,或者调用其他异步函数,状态机都会做一些计算和状态轮换。约定的Continuation在哪里?它是对象本身(this)。CPS转换实现非常复杂,尤其是在考虑try-catch之后。不过没关系,复杂的在编译器,用户只要学会两个关键字就可以了。这个特性很优雅,比起Java没用的CompletableFuture不知道高了多少。(更新:没那么没用。)JVM上还有一个实现:electronicarts/ea-async,原理和C#的async/await类似,在编译时修改Bytecode,实现CPS转换。终极方案:在用户态线程中使用async/await,代码就简单多了,和同步代码基本一样。是否可以使异步代码与同步代码完全相同?听起来像是免费的午餐,但它是可以做到的!用户态线程的代表是Golang。java训练JVM上也有一些实现,比如Quasar,但是因为缺少JDBC、Spring等外围生态(占据了大部分IO操作),所以基本没用。用户态线程完全放弃了操作系统提供的线程机制,换句话说,不使用这个VM的虚拟化机制。比如硬件有8个核心,那么创建8个系统线程,然后调度N个用户线程运行在这8个系统线程上。N个用户线程的调度是在用户进程中实现的。由于一切都在进程内部,因此切换成本远低于操作系统的ContextSwitch。另一方面,所有可能阻塞系统级线程的东西,比如sleep()、recv()等,用户态线程一定不能碰,否则一旦阻塞,就会阻塞其中一个8个系统线程。GoRuntime接管了所有此类系统调用,并使用统一的事件循环进行轮询和调度。另外,由于用户态线程非常轻量级,我们根本不需要使用线程池。如果我们需要开启一个线程,我们可以直接创建它。比如Java中的WebServer几乎肯定有一个线程池,而Go可以为每个请求开辟一个goroutine来处理。并发编程从未如此美好!综上所述,在上述方案中,Promise和Reactive本质上都是回调函数,但框架的存在在一定程度上减轻了开发者的精神负担。async/await和用户模式线程的解决方案更加优雅和彻底。前者在编译时通过CPS转换帮助用户创建CPS风格的函数调用;后者绕过操作系统,重新实现了一套线程机制。所有的调度工作都由Runtime接管。不知道是不是因为历史包袱太重。Java语言本身提供的异步编程支持弱得可怜。甚至CompletableFuture也是在Java8中才引入的。结果是许多库没有异步支持。虽然Quasar在没有语言层面支持的情况下引入了CPS转换,但由于缺乏周边生态的支持,实际上很难在项目中使用。
