当前位置: 首页 > 科技观察

Go语言出现后,Java还是一个好的选择吗?

时间:2023-03-12 12:39:01 科技观察

随着大量新的异步框架和支持协程的语言(比如Go)的出现,操作系统的线程调度成为很多场景下的性能瓶颈,Java也因此被质疑能否它不再适合最新的云场景。四年前,阿里JVM团队开始研发Wisp2,将Go语言的协程能力带入Java世界。既享受了Java丰富的生态,又获得了异步程序的性能,Wisp2让Java平台长盛不衰。Java平台一向以生态繁荣着称,大量的类库和框架帮助开发者快速构建应用。大多数Java框架类库都是基于线程池和阻塞机制来为并发服务的。主要原因包括:1、Java语言在核心类库中提供了强大的并发能力,多线程应用程序可以获得很好的性能。;2、JavaEE的一些标准是线程级阻塞的(比如JDBC);3、基于阻塞模式,可以快速开发应用。但是现在,随着大量新的异步框架和支持协程的语言(比如Go)的出现,操作系统的线程调度成为了很多场景下的性能瓶颈。Java因此被质疑是否不再适合最新的云场景。四年前,阿里开始自主研发Wisp2。主要用于IO密集型的服务器场景,大??部分公司的线上业务都是这种情况(线下应用偏向于计算,所以不适用)。它在功能属性上对比Goroutine的Java协程,在产品形态、性能、稳定性等方面都达到了理想的境界。截至目前,Wisp1/2上已经启动了数百个应用程序和数万个容器。Wisp协程完全兼容多线程阻塞代码编写。只需要增加JVM参数即可启动协程。阿里巴巴核心电商应用在双十一就通过了协程模型的考验,尽享Java的丰富。生态,并获得了异步程序的性能。Wisp2专注于性能和与现有代码的兼容性。总之,现有的多线程IO密集型Java应用只需要添加Wisp2JVM参数就可以获得异步性能的提升。举个例子,下面是消息中间件代理(简称mq)和drds的压测对比,只加参数,不改代码:可以看到上下文切换和sysCPU明显减少,RT降低,QPS分别增长了11.45%和18.13%。快速入门由于Wisp2与现有的Java代码完全兼容,因此非常易于使用。这有多容易?如果你的应用是“标准”在线应用(使用/home/admin/$APP_NAME/setenv.sh配置参数),那么在admin用户下输入以下命令启动Wisp2:curlhttps://gosling.alibaba-inc.com/sh/enable-wisp2.sh|sh否则需要手动升级JDK和Java参数:ajdk8.7.12_fp2rpmsudoyuminstallajdk-bcurrent#也可以通过yum-XX:+UseWisp2...安装最新的jdkjava。#使用Wisp参数启动Java应用,然后可以通过jstack验证协程是否确实开启。载体线程是调度协程的线程。最下面的-Coroutine[...]表示协程,active表示协程被调度的次数,steal表示被work窃取的次数,preempt表示时间片抢占的次数。下图是在ecs上做压力测试时DRDS的top-H。可以看出,应用中的数百个线程由8个承载线程管理,平均运行在CPU核心的线程上。下面一些名为java的线程是gc线程。线程过多的开销误区一:进入内核触发上下文切换先看一个测试程序:pipe(a);while(1){write(a[1],a,1);read(a[0],a,1);n+=2;}执行这个程序时,上下文切换很低。其实上面的IO系统调用是不会阻塞的,所以内核不需要挂起线程或者切换上下文。实际发生的是用户模式/内核模式模式切换。上面的程序在神龙服务器上实测每次管道操作耗时334ns左右,速度很快。误区二:上下文切换的开销很大。本质上,无论是用户态还是内核态的上下文切换都是非常轻量级的,甚至有一些硬件指令支持。比如pusha可以帮助我们保存通用寄存器。同一个进程的线程共享页表,所以上下文切换的开销一般只有:保存各种寄存器切换sp(call指令会自动将pc压入栈)在几十条指令内就可以完成。开销既然近内核和上下文切换都不慢,那么多线程的开销在哪里呢?我们不妨看看一个被阻塞的系统调用futex的热点分布:可以看到上面的热点有很多调度开销。我们看一下流程:调用系统调用(可能需要阻塞);系统调用确实需要阻塞,内核需要确定下一个要执行的线程(调度);执行上下切换。因此,以上两种误解与多线程的开销有一定的因果关系,但真正的开销来自于线程阻塞唤醒调度。综上所述,通过线程模型提高Web服务器性能的原则是:活跃线程数约等于CPU数。每个线程都不需要被阻塞。以下文章将密切关注这两个主题。为了满足以上两个条件,使用eventloop+异步回调是一个极好的选择。异步和协程的关系为了简单起见,我们以Netty在异步服务器上的写操作为例(写操作也可能会被阻塞):privatevoidwriteQuery(Channelch){ch.write(Unpooled.wrappedBuffer("query.getBytes()).sync();logger.info("writefinish");}这里的sync()会阻塞线程。不符合预期。由于netty本身是一个异步框架,我们引入一个回调:privatevoidwriteQuery(Channelch){ch.write(Unpooled.wrappedBuffer("query".getBytes())).addListener(f->{logger.info("writefinish");});}注意这里异步写入调用后,writeQuery会返回。因此,如果逻辑要求write之后执行的代码必须出现在callback中,write就是函数的最后一行。这是最简单的情况。如果函数有其他调用者,则需要进行CPS转换。需要不断地提取程序的“下半部分”,也就是continuation,似乎给我们造成了一些精神负担。这里引入kotlin协程来帮助我们简化程序:suspendfunChannel.aWrite(msg:Any):Int=suspendCoroutine{cont->write(msg).addListener{cont.resume(0)}}suspendfunwriteQuery(ch:Channel){ch.aWrite(Unpooled.wrappedBuffer("query".toByteArray()))logger.info("writefinish")}这里引入了一个神奇的suspendCoroutine,我们可以得到当前Continuation的引用,执行一段代码,最后暂停当前??的协程过程。Continuation表示继续当前计算,我们可以通过Continuation.resume()恢复执行上下文。因此,我们只需要在写操作完成时回调cont.resume(0),回到suspendCoroutine处的执行状态(包括调用者writeQuery),程序继续执行,代码返回,日志被执行。从writeQuery开始,我们就完成了同步写入的异步操作。当协程被suspendCoroutine切换掉后,线程可以继续调度其他可执行的协程去执行,所以不会真的阻塞,这样我们就实现了性能的提升。从这个角度来看,我们只需要有一个保存/恢复执行上下文的机制,使用非阻塞+回调的方式放弃/恢复阻塞库函数中的协程,这样写出的程序就可以同步形式可以和异步效果一样。理论上只要有一个封装了所有JDK阻塞方法的库,我们就可以自由编写异步程序了。重写的阻塞库函数本身需要足够流行才能被大多数程序使用。据我所知,vert.x的kotlin支持已经做了这样的封装。vert.x虽然很流行,但是不能兼顾代码中的遗留代码、锁阻塞等逻辑。因此,它不能被视为最普遍的选择。其实Java程序都有一个绕不过去的库——JDK。Wisp对JDK中的所有阻塞调用都实现了非阻塞+事件恢复协程,以支持协程调度。Wisp在给用户带来最大便利的同时,也兼顾了现有代码的兼容性。支持上面的方式,不需要阻塞每个线程。Wisp在Thread.start()处将线程转换为协程,以实现另一个目的:活动线程数约等于CPU数。因此,所有现有的Java多线程代码只需使用Wisp协程即可获得异步性能。手动异步/Wisp性能对比对于基于传统编程模型的应用,考虑到逻辑的清晰度、异常处理的便利性以及现有库的兼容性,转异步的成本是巨大的。与异步编程相比,使用Wisp具有明显的优势。下面我们分析新应用前提下的技术选择,只考虑性能。基于现有组件编写新的应用如果我们要编写一个新的应用,我们通常会依赖JDBC、Dubbo、Jedis等通用协议/组件。如果库内部使用了阻塞的形式,没有暴露回调接口,那么我们就不能使用基于这些库来写异步应用(除非线程池被包裹起来,而是本末倒置)。下面假设我们依赖的库都支持回调,比如dubbo。1)假设我们使用Netty来接受请求,我们称之为入口eventLoop。接收到的请求可以在Netty的handler中进行处理,也可以使用业务线程池进行实时io。2)假设处理请求的时候需要调用dubbo,因为dubbo不是我们写的,所以里面有自己的Nettyeventloop,所以我们处理IO到dubbo内部的Nettyeventloop,等待后端响应和打回来。3)DubboeventLoop收到响应后在eventloop或回调线程池中调用callback。4)后续逻辑可以在回调线程池或者原业务线程池中继续处理。5)为了完成对客户端的响应,入口的事件循环必须一直写回响应。我们可以看到这种封装导致的eventLoop的碎片化。即使我们完全使用回调形式,我们在处理请求时或多或少也要在多个eventLoop/线程池之间传递,每个线程都无法运行到一个满度,导致频繁的os调度。每个线程都不需要阻塞,这与上述原则相悖。因此,虽然减少了线程数,节省了内存,但我们获得的性能提升却变得非常有限。完全从头开发对于一个功能有限的新应用(比如nginx只支持http和mail协议),我们可以不依赖已有的组件重写应用。比如我们可以基于Netty写一个数据库代理服务器,与客户端的连接和与后端真实数据库的连接共享同一个eventloop。这样一个对线程模型进行精确控制的应用,通常可以获得非常好的性能,通常性能会比将非异步程序转为协程的性能更高,原因如下:线程控制更精确:例如,我们可以控制proxy的clientconnection和backend都绑定到同一个netty线程,所有的操作都可以threadLocalized,没有coroutines的运行时和调度开销(大约1%),但是在使用上还是有优势的coroutines:对于jdkblock中无处不在的synchronized,wisp可以正确切换schedules。AdaptiveWorkload基于以上背景,我们已经知道Wisp或者其他协程适用于IO密集型Java编程。否则,没有线程切换,你只需要在CPU上跑多少就跑多少,操作系统不需要过多的干预。这是一个更倾向于离线或者科学计算的场景。在线应用通常需要访问RPC、DB、缓存和消息,并且被阻塞。非常适合使用Wisp来提高性能。最早的Wisp1也深度定制了这些场景。比如hsf接受的请求处理,会自动将线程池替换成协程。设置IO线程数为1后,使用epoll_wait(1ms)代替selector.wakeup()。etc.因此,我们经常面临的挑战之一是Wisp是否只适合阿里巴巴内部的工作负载?Wisp1就是这种情况。已对连接应用程序的参数和Wisp的实现进行了深度适配。对于Wisp2,所有的线程都会被转成协程,不做任何适配。为了证明这一点,我们使用了web领域最权威的techempowerbenchmak集来进行验证,我们选择了常见的阻塞测试如com.同时,还有一定的提升空间)来验证Wisp2在常用开源组件下的性能。可以看到在高压下qps/RT会优化10%到20%。ProjectLoomProjectLoom作为OpenJDK上的标准协程实现值得关注。作为Java开发者,我们应该拥抱Loom吗?我们先对Wisp和Loom做一些比较:1)Loom使用序列化保存上下文,节省内存,但是切换效率低。2)Wisp使用独立栈,和go类似。协程切换只需要切换寄存器,效率高但是比较耗内存。3)Loom不支持ObectMonitor,但Wisp支持。synchronized/Object.wait()会占用线程,不能充分利用CPU。死锁也是有可能发生的,根据Wisp的经验,死锁是一定会发生的(Wisp后来也陆续支持了ObectMonitor)。4)Wisp支持栈上有nativefunction时的切换(反射等),Loom不支持。对dubbo这样的框架不友好,栈下几乎都有反射。根据我们的判断,Loom至少需要2年时间才能达到稳定和功能齐全的状态。Wisp性能优异,功能更完备,产品本身也更成熟。作为Oracle项目,Loom有很大机会进入Java标准。我们也在积极参与社区,希望能为社区贡献一些Wisp的功能。同时,Wisp目前完全兼容Loom的FiberAPI。如果我们的用户基于FiberAPI进行编程,我们可以保证代码的行为在Loom和Wisp上完全相同。FAQ协程也有调度,为什么开销小?我们一直强调协程适用于IO密集型场景,也就是说通常任务会阻塞等待一小段时间的IO,然后再进行调度。这种情况下,只要系统的CPU没有被充分利用,简单的先进先出的调度策略基本可以保证相对公平的调度。同时,我们采用了完全无锁的调度实现,相比内核大大降低了调度开销。为什么Wisp2不使用ForkJoinPool来调度协程?ForkJoinPool本身很好,但是不适合Wisp2场景。为了更容易理解,我们可以唤醒一个协程,看一个Executor.execute()操作。ForkJoinPool虽然支持任务窃取,但是execute()操作是随机的还是线程队列操作(取决于是否是异步模式)。协程会在哪个线程被唤醒的行为也是非常随机的。在Wisp的底层,窃取的成本有点高,所以我们需要一个亲和力,让协程尽可能的绑定在一个固定的线程上。工作窃取仅在线程繁忙时发生。我们实施了自己的workStealingPool来支持此功能。从调度开销/延迟等各项指标来看,基本可以和ForkJoinPool持平。还有一个方面就是为了支持类似go的M和P机制,我们需要把被协程阻塞的线程踢出调度器。这些功能不适合在ForkJoinPool中更改。你如何看待Reactive编程?Reactive编程模型已经被业界广泛接受,是一个重要的技术方向;同时,在Java代码中也很难完全避免阻塞。我们认为协程可以作为底层的worker机制来支持Reactive编程,即保留了Reactive编程模型,不用太担心阻塞用户代码导致整个系统阻塞。下面是Quasar和Loom的作者RonPressler最近的一次演讲,他在演讲中明确指出回调模型会给当前的编程带来很多挑战。Wisp已经经历了4年的研发,我将其分为几个阶段:1)Wisp1,不支持objectMonitor,并行类加载,可以运行一些简单的应用程序;2)Wisp1,支持objectMonitor,推出电商核心,不支持workStealing,只能将一些短任务转为协程(否则工作量不均),netty线程仍然是线程,需要一些复杂和tricky的配置;3)Wisp2支持workStealing,所以所有的线程都可以转化为coroutines进程,上面的netty问题就不存在了。目前的主要限制是什么?目前,主要的限制是不能有阻塞的JNI调用。Wisp通过在JDK中插入hook来实现预阻塞调度。如果是用户自定义的JNI,就没有hook的机会。最常见的场景是使用Netty的EpollEventLoop:1)Ant的bolt组件默认开启该功能,可以通过-Dbolt.netty.epoll.switch=false关闭,对性能影响不大。2)也可以使用-Dio.netty.noUnsafe=true,其他不安全的功能可能会受到影响。3)(推荐)netty4.1.25及以上版本,只支持通过-Dio.netty.transport.noNative=true关闭jniepoll,见358249e5