异步编程的几种方式,你知道几种?

做者: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

然而做为一位合格的程序员,你必定也据说过,线程是昂贵的:编程

  • 线程的上下文切换有很多的代价,占用宝贵的 CPU 时间;
  • 每一个线程都会占用一些(至少 1 页)内存。

这两个缘由驱使咱们尽量避免建立太多的线程,而异步编程的目的就是消除 IO wait 阻塞——绝大多数时候,这是咱们建立一堆线程、甚至引入线程池的罪魁祸首。后端

Continuation

回调函数知道的人不少,但了解 Continuation 的人很少。Continuation 有时被晦涩地翻译成“计算续体”,我们仍是直接用单词好了。promise

把一个计算过程在中间打断,剩下的部分用一个对象表示,这就是 Continuation。操做系统暂停一个线程时保存的那些现场数据,也能够看做一个 Continuation。有了它,咱们就能在这个点接着刚刚的断点继续执行。服务器

打断一个计算过程听起来很厉害吧!实际上它每时每刻都在发生——假设函数 f() 中间调用了 g(),那 g() 运行完成时,要返回到 f() 刚刚调用 g() 的地方接着执行。这个过程再天然不过了,以致于全部编程语言(汇编除外)都把它掩藏起来,让你在编程中感受不到调用栈的存在。

操做系统用昂贵的软中断机制实现了栈的保存和恢复。那有没有别的方式实现 Continuation 呢?最朴素的想法就是,把全部用获得的信息包成一个函数对象,在调用 g() 的时候一块儿传进去,并约定:一旦 g() 完成,就拿着结果去调用这个 Continuation。

这种编程模式被称为 Continuation-passing style(CPS):

  1. 把调用者 f() 还未执行的部分包成一个函数对象 cont,一同传给被调用者 g()
  2. 正常运行 g() 函数体;
  3. 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。

你应该已经发现了,这也就是回调函数,我只是换了个名字而已。

异步的朴素实现:Callback

光有回调函数其实并无卵用。对于纯粹的计算工做,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。

一颗语法糖:Promise

回调函数哪里都好,就是不大好用,以及太丑了。

第一个问题是可读性大大降低,因为咱们绕开操做系统自制 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 的例子:

CPS 变换:Coroutine 与 async/await

不管是反应式仍是 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 多线程系列面试题和答案,很是齐全。

References

  1. https://blog.tsunanet.net/201...
  2. http://reactivex.io/
  3. https://zhuanlan.zhihu.com/p/...
  4. http://docs.paralleluniverse....
  5. http://morsmachine.dk/go-sche...
  6. https://medium.com/@ThatGuyTi...

近期热文推荐:

1.600+ 道 Java面试题及答案整理(2021最新版)

2.终于靠开源项目弄到 IntelliJ IDEA 激活码了,真香!

3.阿里 Mock 工具正式开源,干掉市面上全部 Mock 工具!

4.Spring Cloud 2020.0.0 正式发布,全新颠覆性版本!

5.《Java开发手册(嵩山版)》最新发布,速速下载!

以为不错,别忘了随手点赞+转发哦!

相关文章
相关标签/搜索