做者:Eric Fu\
连接:https://ericfu.me/several-way...html
近期尝试在搬砖专用语言 Java 上实现异步,原由和过程就再也不详述了,总而言之,心中一万头草泥马奔过。但这个过程也没有白白浪费,趁机回顾了一下各类异步编程的实现。java
这篇文章会涉及到回调、Promise、反应式、async/await、用户态线程等异步编程的实现方案。若是你熟悉它们中的一两种,那应该也能很快理解其余几个。react
操做系统能够看做是个虚拟机(VM),进程生活在操做系统创造的虚拟世界里。进程不用知道到底有多少 core 多少内存,只要进程不要索取的太过度,操做系统就伪装有无限多的资源可用。程序员
基于这个思想,线程(Thread)的个数并不受硬件限制:你的程序能够只有一个线程、也能够有成百上千个。操做系统会默默作好调度,让诸多线程共享有限的 CPU 时间片。这个调度的过程对线程是彻底透明的。面试
那么,操做系统是怎样作到在线程无感知的状况下调度呢?答案是上下文切换(Context Switch),简单来讲,操做系统利用软中断机制,把程序从任意位置打断,而后保存当前全部寄存器——包括最重要的指令寄存器 PC 和栈顶指针 SP,还有一些线程控制信息(TCB),整个过程会产生数个微秒的 overhead。spring
然而做为一位合格的程序员,你必定也据说过,线程是昂贵的:编程
这两个缘由驱使咱们尽量避免建立太多的线程,而异步编程的目的就是消除 IO wait 阻塞——绝大多数时候,这是咱们建立一堆线程、甚至引入线程池的罪魁祸首。后端
回调函数知道的人不少,但了解 Continuation 的人很少。Continuation 有时被晦涩地翻译成“计算续体”,我们仍是直接用单词好了。promise
把一个计算过程在中间打断,剩下的部分用一个对象表示,这就是 Continuation。操做系统暂停一个线程时保存的那些现场数据,也能够看做一个 Continuation。有了它,咱们就能在这个点接着刚刚的断点继续执行。服务器
打断一个计算过程听起来很厉害吧!实际上它每时每刻都在发生——假设函数 f()
中间调用了 g()
,那 g()
运行完成时,要返回到 f()
刚刚调用 g()
的地方接着执行。这个过程再天然不过了,以致于全部编程语言(汇编除外)都把它掩藏起来,让你在编程中感受不到调用栈的存在。
操做系统用昂贵的软中断机制实现了栈的保存和恢复。那有没有别的方式实现 Continuation 呢?最朴素的想法就是,把全部用获得的信息包成一个函数对象,在调用 g()
的时候一块儿传进去,并约定:一旦 g()
完成,就拿着结果去调用这个 Continuation。
这种编程模式被称为 Continuation-passing style(CPS):
f()
还未执行的部分包成一个函数对象 cont
,一同传给被调用者 g()
;g()
函数体;g()
完成后,连同它的结果一块儿回调 cont
,从而继续执行 f()
里剩余的代码。再拿 Wikipedia 上的定义巩固一下:
A function written in continuation-passing style takes an extra argument: an explicit "continuation", i.e. a function of one argument. When the CPS function has computed its result value, it "returns" it by calling the continuation function with this value as the argument.
CPS 风格的函数带一个额外的参数:一个显式的 Continuation,具体来讲就是个仅有一个参数的函数。当 CPS 函数计算完返回值时,它“返回”的方式就是拿着返回值调用那个 Continuation。
你应该已经发现了,这也就是回调函数,我只是换了个名字而已。
光有回调函数其实并无卵用。对于纯粹的计算工做,Call Stack 就很好,为什么要费时费力用回调来作 Continuation 呢?你说的对,但仅限于没有 IO 的状况。咱们知道 IO 一般要比 CPU 慢上好几个数量级,在 BIO 中,线程发起 IO 以后只能暂停,而后等待 IO 完成再由操做系统唤醒。
var input = recv_from_socket() // Block at syscall recv() var result = calculator.calculate(input) send_to_socket(result) // Block at syscall send()
而异步 IO 中,进程发起 IO 操做时也会一并输入回调(也就是 Continuation),这大大解放了生产力——现场无需等待,能够当即返回去作其余事情。一旦 IO 成功后,AIO 的 Event Loop 会调用刚刚设置的回调函数,把剩下的工做完成。这种模式有时也被称为 Fire and Forget。
recv_from_socket((input) -> { var result = calculator.calculate(input) send_to_socket(result) // ignore result })
就这么简单,经过咱们本身实现的 Continuation,线程再也不受 IO 阻塞,能够自由自在地跑满 CPU。
回调函数哪里都好,就是不大好用,以及太丑了。
第一个问题是可读性大大降低,因为咱们绕开操做系统自制 Continuation,全部函数调用都要传入一个 lambda 表达式,你的代码看起来就像要起飞同样,缩进止不住地往右挪(the "Callback Hell")。
第二个问题是各类细节处理起来很麻烦,好比,考虑下异常处理,看来传一个 Continuation 还不够,最好再传个异常处理的 callback。
Promise 是对异步调用结果的一个封装,在 Java 中它叫做 CompletableFuture (JDK8) 或者 ListenableFuture (Guava)。Promise 有两层含义:
第一层含义是:我如今还不是真正的结果,可是承诺之后会拿到这个结果。这很容易理解,异步的任务早晚会完成,调用者若是比较蠢萌,他也能够用 Promise.get()
强行要拿到结果,顺便阻塞了当前线程,异步变成了同步。
第二层含义是:若是你(调用者)有什么吩咐,就告诉我好了。这就有趣了,换句话说,回调函数再也不是传给 g()
,而是 g()
返回的 Promise,好比以前那段代码,咱们用 Promise 来书写,看起来顺眼了很多。
var promise_input = recv_from_socket() promise_input.then((input) -> { var result = calculator.calculate(input) send_to_socket(result) // ignore result })
Promise 改善了 Callback 的可读性,也让异常处理稍稍优雅了些,但终究是颗语法糖。
反应式(Reactive)最先源于函数式编程中的一种模式,随着微软发起 ReactiveX 项目并一步步壮大,被移植到各类语言和平台上。Reactive 最初在 GUI 编程中有普遍的应用,因为异步调用的高性能,很快也在服务器后端领域遍地开花。
Reactive 能够看做是对 Promise 的极大加强,相比 Promise,反应式引入了流(Flow)的概念。ReactiveX 中的事件流从一个 Observable 对象流出,这个对象能够是一个按钮,也能够是 Restful API,总之,它能被外界触发。与 Promise 不一样的是,事件可能被触发屡次,因此处理代码也会被屡次调用。
一旦容许调用屡次,从数据流动的角度看,事实上模型已是 Push 而非 Pull。那么问题来了,若是调用频率很是高,以致于咱们处理速度跟不上了怎么办?因此 RX 框架又引入了 Backpressure 机制来进行流控,最简单的流控方式就是:一旦 buffer 满,就丢弃掉以后的事件。
ReactiveX 框架的另外一个优势是内置了不少好用的算子,好比:merge
(Flow 合并),debounce
(开关除颤)等等,方便了业务开发。下面是一个 RxJava 的例子:
不管是反应式仍是 Promise,说到底仍然没有摆脱手工构造 Continuation:开发者要把业务逻辑写成回调函数。对于线性的逻辑基本能够应付自如,可是若是逻辑复杂一点呢?(好比,考虑下包含循环的状况)
有些语言例如 C#,JavaScript 和 Python 提供了 async/await
关键字。与 Reactive 同样,这一样出自微软 C# 语言。在这些语言中,你会感到史无前例的爽感:异步编程终于摆脱了回调函数!惟一要作的只是在异步函数调用时加上 await
,编译器就会自动把它转化为协程(Coroutine),而非昂贵的线程。
魔法的背后是 CPS 变换,CPS 变换把普通函数转换成一个 CPS 的函数,即 Continuation 也能做为一个调用参数。函数不只能从头运行,还能根据 Continuation 的指示继续某个点(好比调用 IO 的地方)运行。
能够看到,函数已经再也不是一个函数了,而是变成一个状态机。每次 call 它、或者它 call 其余异步函数时,状态机都会作一些计算和状态轮转。说好的 Continuation 在哪呢?就是对象本身(this
)啊。
CPS 变换实现很是复杂,尤为是考虑到 try-catch 以后。可是不要紧,复杂性都在编译器里,用户只要学两个关键词便可。这个特性很是优雅,比 Java 那个废柴的 CompletableFuture
不知道高到哪去了
JVM 上也有一个实现:electronicarts/ea-async,原理和 C# 的 async/await 相似,在编译期修改 Bytecode 实现 CPS 变换。
有了 async/await
,代码已经简洁不少了,基本上和同步代码无异。是否有可能让异步代码和同步代码彻底同样呢?听起来就像免费午饭,可是的确能够作到!
用户态线程的表明是 Golang。JVM 上也有些实现,好比 Quasar,不过由于 JDBC、Spring 这些周边生态(它们占据了大部分 IO 操做)的缺失基本没有什么用。
用户态线程是把操做系统提供的线程机制彻底抛弃,换句话说,不去用这个 VM 的虚拟化机制。好比硬件有 8 个核心,那就建立 8 个系统线程,而后把 N 个用户线程调度到这 8 个系统线程上跑。N 个用户线程的调度在用户进程里实现,因为一切都在进程内部,切换代价要远远小于操做系统 Context Switch。
另外一方面,全部可能阻塞系统级线程的事情,例如 sleep()
、recv()
等,用户态线程必定不能碰,不然它一旦阻塞住也就带着那 8 个系统线程中的一个阻塞了。Go Runtime 接管了全部这样的系统调用,并用一个统一的 Event loop 来轮询和分发。
另外,因为用户态线程很轻量,咱们彻底不必再用线程池,若是须要开线程就直接建立。好比 Java 中的 WebServer 几乎必定有个线程池,而 Go 能够给每一个请求开辟一个 goroutine 去处理。并发编程从未如此美好!
以上方案中,Promise、Reactive 本质上仍是回调函数,只是框架的存在必定程度上下降了开发者的心智负担。而 async/await
和用户态线程的解决方案要优雅和完全的多,前者经过编译期的 CPS 变换帮用户创造出 CPS 式的函数调用;后者则绕开操做系统、从新实现一套线程机制,一切调度工做由 Runtime 接管。
不知道是否是由于历史包袱过重,Java 语言自己提供的异步编程支持弱得可怜,即使是 CompletableFuture 仍是在 Java 8 才引入,其后果就是不少库都没有异步的支持。虽然 Quasar 在没有语言级支持的状况下引入了 CPS 变换,可是因为缺乏周边生态的支持,实际很难用在项目中。
最后,关注公众号Java技术栈,在后台回复:面试,能够获取我整理的 Java 多线程系列面试题和答案,很是齐全。
近期热文推荐:
1.600+ 道 Java面试题及答案整理(2021最新版)
2.终于靠开源项目弄到 IntelliJ IDEA 激活码了,真香!
3.阿里 Mock 工具正式开源,干掉市面上全部 Mock 工具!
4.Spring Cloud 2020.0.0 正式发布,全新颠覆性版本!
以为不错,别忘了随手点赞+转发哦!