响应式编程为 Java 的企业版应用提供了更高的性能,并降低了内存消耗,主要是通过减少进程的上下文切换来实现的。因为类似的上下文切换对 CPU 和内存的消耗是极大,所以要尽可能的减少这样的切换操作。不过,响应式编程带来的这种性能上的提高,代价是降低了软件的维护性,这样的代价交换是否值得呢?让我们在本文中深入地讨论一下这个问题吧。
在 Java 的早期时代,抽象线程是区别于其他编程语言的一大优势。直至今日,Java 依然提供了便捷的并行编程和同步的机制。在此基础上,我们可以非常轻松地实现 Web 框架,使 Web 请求可以直接与 servlet API 中的线程绑定在一起,以一种“虚拟”的方式处理请求,摒弃掉同步和并发的问题。调用链在到达 Tabbed Browsing 和 Ajax 之前,还可以设计确定同一 Session 的两个请求不需要并行执行在一个线程,对于普通开发者而言,就不需要担心 Session 级别的并行问题了。
但是目前,Java 的线程在实现上有一个严重的缺陷:Java 线程的实现被操作系统认定为系统进程,这使得线程的切换等价于操作系统的上下文切换,这个代价非常高。如果一个应用程序每分钟内只处理几千个请求,或许没什么问题。可是目前 Web 程序的需求与日俱增,不断增长的用户量和请求应用致使企业应用现在每分钟处理的数据量远远高于 15 年前。由操作系统线程来处理请求的这种模型渐渐地进入了瓶颈,尤其是当你在程序执行的过程中,阻塞住当前程序来访问数据库或者访问其他的微服务,这种情境下特别明显。
并行计算的时间应明显高于 Java 将线程实现为操作系统线程的时间,但是目前请求的执行时间很短,操作系统进程上下文的切换时间很长,完全不成比例。
而这正是响应式编程的用武之地,它与目前的 Java 线程模型完全相反。目前的线程模型是保证所有的事情都在当前线程执行,但是在响应式编程中,异步是一个准则。一个程序执行过程被认定为一系列异步执行的事件,每个事件都被 Publisher 创建,你不需要关心 Publisher 在哪个线程中创建。在响应式程序里面,程序代码包含了监听和执行异步事件的功能,而且会在必要时提供新的事件。
这个方式在某些场景下很有效果,比如说在程序里面访问外部数据库。传统的企业级 Java 程序里面,系统会发送一段 SQL 语句到数据库上面,阻塞住程序,直到数据库返回查询结果。但是在响应式编程里面,程序会跳过等待结果的过程,正常向下执行代码。当你发送一个 SQL 请求到数据库的时候,会用一个 Pushlisher 来替代阻塞的过程,调用者可以注册这个 Pushlisher,这样的话,在之后数据库返回结果的时候,就会通知 Publisher,然后 Publisher 会通知调用者。
这种方式对于解决回调地狱很有帮助,很多异步编程也确实是这样做的。
响应式编程的好处就是执行的代码和执行的线程是分开的。因此在操作系统的层面上,上下文切换的代价比较低。
这种方式在服务端的架构里面有很大的意义,只保留一个线程处理程序进程层面的代码,跟 Node.js 很相似。如果这个线程被阻塞的话,那整个服务器就不会再处理其他请求了。因此,在 JavaScript 中,每个调用在一开始的时候就是异步执行,在任何情况下都可以通过响应式的 API 或者其他有用的抽象方式,来解决回调地狱的问题。
但是正如文章开篇里面提到的,响应式编程也有一些比较严重的问题,写入的代码和执行的代码分离开来,导致阅读和编写代码的难度增加,对于这种异步的代码,编写单元测试和调试代码都很困难。
在与传统的企业应用集成的过程中,也出现了很多新的问题,像安全认证、事务还有链路追踪等仍然附加在当前线程里面。当你开始执行响应式编程的程序时,这种机制也无法奏效,所以需要找到一个更好的解决办法。
Project Reactor 是 Spring 的 Reactive Web Framework 的基础,目前支持测试,调试等等(详情链接)。不过,也是由于响应式编程的复杂度很高,所以开发者开始寻找是否还有其他的替代方案,来解决 Java 线程切换代价高的问题。
响应式编程的替代方案
上文提到了,当前的 Web 应用程序,代码的执行时间和 Java 线程之间切换的时间不成比例。Java 的线程是以 1:1 的形式分配给操作系统的。尽管 Java 有线程池的方式来帮你在不进行上下文切换的情况下执行代码,但是终究只是一个笨拙的解决方式。
类似于 C#、JavaScript、Kotlin 等这一类的编程语言在这个领域已经走在了前面,他们在编程语言中集成了这部分功能,可以帮助你执行一小段异步代码,并在返回的时候阻塞,直到执行结束。在 C#和 JavaScript 中,可以使用 Async 和 Await 关键字,在 Go 和 Kotlin 中,同样包括了协程的概念来提供类似的功能。
以上这些概念的想法都是类似的,如果我发现需要执行一个比较长时间的请求(例如:访问数据库),代码不应该阻塞住,而是在响应结束并返回结果的时候,指定执行需要的代码,并且实现代码要简单易读,方便调试和测试。
Java 1 中的绿色线程
所有的努力都是为了保证开发人员能够轻松地开发、测试、维护的调试响应式程序代码,但是关键问题在于,响应式编程在运行时机制下,是否能够提供更好的性能。上面提到的一些替代方案里面,响应式编程又是否是解决问题的正确方法?虽然以上问题,目前无法得到一个确切的答案,但是,其他语言的替代方案(Asyn,Await 和协程)是添加在编程语言内部的,并不是第三方类库。那么 Java 中额外的第三方类库是不是有点多余呢?
我们先来回顾一下 Java 的历史,了解一些有趣的背景故事:
Java 1.1 的版本中,整个线程模型的实现被称为“绿色线程”,Java 虚拟机只在单核系统中执行,Java 线程在虚拟机中使用自己的调度算法。在 Java 的虚拟内存管理下,线程切换和上下文切换非常快,几乎没有内存开销。
这个方式的优点是,Java 程序中同步访问数据的操作并不复杂,根本不会出现对变量真正的并行访问,所有的内容都在一个系统进程里面执行,因此可以说这只是一个“虚拟”的并行。
当然,问题也随之而来,这类程序在多核系统里面,只能使用一个系统内核,根本无法发挥计算机的整体性能。在实践中,这个缺点被不断地放大,甚至足以掩盖它的优点。
因此,绿色线程的想法很快就被终止了,在 Java1.2 中,提供了一个切换绿色线程和本地线程(详情链接)的开关。在 Java1.3 中,就只支持本地线程了。直到今天,所有的计算机内核都可以得以利用。
Project loom 结合了线程模型
Project Loom是在去年推出,JVM 背后的想法就是重新提供类似绿色线程的支持。但与过去不同的是,这些内容不是为了取代当前操作系统的线程模式,而是更好的支持它。因此两个线程模型会在 JVM 虚拟机中并存,而且可以在程序中同时使用。
操作系统线程继续由 Java 的 Thread 类实现,而新的绿色线程,则交由 Fiber 类管理。如果有必要的话,他们可以拥有一个共同的基类,这样现有的代码就不需要为了 Fiber 而修改代码,也不需要知道线程背后的情况。在 Java 虚拟内存的管理下,通过 Fiber 切换上下文几乎没有开销,同步也应该会变得更高效。有一个实现思路是,调度程序确保一个线程里面执行两个彼此相互依赖(比如说访问相同的变量)的 Fibers,这样的话它们就永远不会并行执行,也没有了同步的必要。
为了实现 Fibers,Java 的执行线程将会被分为两部分:执行过程和调度。执行过程标记了执行状态,例如上下文要执行代码的状态,包括调用参数、堆栈等。而调度则负责匀速的执行所有要执行的部分。
将调度程序与执行过程分离的好处是,目前调度和执行的上下文都由操作系统管理,我们无法干预。而分离之后,JVM 可以控制只执行其中的一个或者多个。因此,绿色线程(Fibers)可以只由 Java 自己实现,且 Java 既有的调度器也可以被重用(例如 Fork-Join 池)。
还有一个有趣的优点是,分离之后,执行过程包含的内容可以作为一个 Java API 开放给每个开发者,并成为 Java 语言的一个特性。
结论
程序通过操作系统线程,每个请求一个线程执行的模式,带来了很大的性能问题,这个问题被响应式编程的方式解决了。但与此同时,也带来了很多开发和维护上的问题,因为响应式编程的代码更难测试和维护。
操作系统中进程的切换带来了很大的损失,绿色线程是一个比较可行的解决方案。但是在 Java1.3 之后就不再支持了,主要原因是它不能在多核系统上执行。Project Loom 是绿色线程的一个变种,这个提议会伴随着 Java 过程执行的理念,作为一个附属品提供给开发者。这部分内容也是参考了其他编程语言的特性,例如 Go 和 Kotlin 的协程。
针对 Project Loom,大家可能比较关心它何时集成到 JDK 中?会对响应式编程产生多大的影响?因为单从性能角度上来看,相比响应式编程,它对于 Java 生态的意义不大。而这些内容,我们还得再观察一段时间,才能有定论。
原文链接:The fight for performance – Is reactive programming the right approach?
评论 4 条评论