本篇内容介绍了“java异步编程有哪些方式”的有关知识,在实际案例的操作过程中,不少人都会遇到这样的困境,接下来就让小编带领大家学习一下如何处理这些情况吧!希望大家仔细阅读,能够学有所成!
操作系统可以看作是个虚拟机(VM),进程生活在操作系统创造的虚拟世界里。进程不用知道到底有多少 core 多少内存,只要进程不要索取的太过分,操作系统就假装有无限多的资源可用。
基于这个思想,线程(Thread)的个数并不受硬件限制:你的程序可以只有一个线程、也可以有成百上千个。操作系统会默默做好调度,让诸多线程共享有限的 CPU 时间片。这个调度的过程对线程是完全透明的。
那么,操作系统是怎样做到在线程无感知的情况下调度呢?答案是上下文切换(Context Switch),简单来说,操作系统利用软中断机制,把程序从任意位置打断,然后保存当前所有寄存器——包括最重要的指令寄存器 PC 和栈顶指针 SP,还有一些线程控制信息(TCB),整个过程会产生数个微秒的 overhead。
然而作为一位合格的程序员,你一定也听说过,线程是昂贵的:
线程的上下文切换有不少的代价,占用宝贵的 CPU 时间;
每个线程都会占用一些(至少 1 页)内存。
这两个原因驱使我们尽可能避免创建太多的线程,而异步编程的目的就是消除 IO wait 阻塞——绝大多数时候,这是我们创建一堆线程、甚至引入线程池的罪魁祸首。
回调函数知道的人很多,但了解 Continuation 的人不多。Continuation 有时被晦涩地翻译成“计算续体”,咱们还是直接用单词好了。
把一个计算过程在中间打断,剩下的部分用一个对象表示,这就是 Continuation。操作系统暂停一个线程时保存的那些现场数据,也可以看作一个 Continuation。有了它,我们就能在这个点接着刚刚的断点继续执行。
打断一个计算过程听起来很厉害吧!实际上它每时每刻都在发生——假设函数 f() 中间调用了 g(),那 g() 运行完成时,要返回到 f() 刚刚调用 g() 的地方接着执行。这个过程再自然不过了,以至于所有编程语言(汇编除外)都把它掩藏起来,让你在编程中感觉不到调用栈的存在。
操作系统用昂贵的软中断机制实现了栈的保存和恢复。那有没有别的方式实现 Continuation 呢?最朴素的想法就是,把所有用得到的信息包成一个函数对象,在调用 g() 的时候一起传进去,并约定:一旦 g() 完成,就拿着结果去调用这个 Continuation。
这种编程模式被称为 Continuation-passing style(CPS):
鸿蒙官方战略合作共建——HarmonyOS技术社区
把调用者 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 操作)的缺失基本没有什么用。
关注公众号Java技术栈,在后台回复:面试,可以获取我整理的 Java 多线程系列面试题和答案,非常齐全。
用户态线程是把操作系统提供的线程机制完全抛弃,换句话说,不去用这个 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异步编程有哪些方式”的内容就介绍到这里了,感谢大家的阅读。如果想了解更多行业相关的知识可以关注亿速云网站,小编将为大家输出更多高质量的实用文章!
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。