Go语言出现后,Java还是最佳选择吗?

简介: 阿里妹导读:随着大量新生的异步框架和支持协程的语言(如Go)的出现,在很多场景下操作系统的线程调度成为了性能的瓶颈,Java也因此被质疑是否不再适应最新的云场景了。4年前,阿里JVM团队开始自研Wisp2,将Go语言的协程能力带入到Java世界。

image
阿里妹导读:随着大量新生的异步框架和支持协程的语言(如Go)的出现,在很多场景下操作系统的线程调度成为了性能的瓶颈,Java也因此被质疑是否不再适应最新的云场景了。4年前,阿里JVM团队开始自研Wisp2,将Go语言的协程能力带入到Java世界。既享受Java的丰富生态,又获得异步程序的性能,Wisp2让Java平台历久弥新。

Java平台一直以生态的繁荣著称,大量的类库、框架帮助开发者们快速搭建应用。而其中大部分Java框架类库都是基于线程池以及阻塞机制来服务并发的,主要原因包括:

  1. Java语言在核心类库中提供了强大的并发能力,多线程应用可以获得不俗的性能;
  2. Java EE的一些标准都是线程级阻塞的(比如JDBC);
  3. 基于阻塞模式可以快速地开发应用。

但如今,大量新生的异步框架和支持协程的语言(如Go)的出现,在很多场景下操作系统的线程调度成为了性能的瓶颈。Java也因此被质疑是否不再适应最新的云场景了。

4年前,阿里开始自研Wisp2。它主要是用在IO密集的服务器场景,大部分公司的在线服务都是这样的场景 (离线应用都是偏向于计算,则不适用)。它在功能属性上对标Goroutine的Java协程,在产品形态、性能、稳定性上都达到了一个比较理想的情况。到现在,已经有上百个应用,数万个容器上线了Wisp1/2。Wisp协程完全兼容多线程阻塞的代码写法,仅需增加JVM参数来开启协程,阿里巴巴的核心电商应用已经在协程模型上经过两个双十一的考验,既享受到了Java的丰富生态,又获得了异步程序的性能。

Wisp2主打的是性能和对现有代码的兼容性,简而言之,现有的基于多线程的IO密集的Java应用只需要加上Wisp2的JVM参数就可以获得异步的性能提升。

作为例子,以下是消息中间件代理(简称mq)和drds只添加参数不改代码的压测比较:

image

可以看到上下文切换以及sys CPU显著降低,RT减少、QPS分别提升11.45%,18.13%。

Quick Start

由于Wisp2完全兼容现有的Java代码,因此使用起来十分简单,有多简单?

如果你的应用是“标准”的在线应用(使用/home/admin/$APP_NAME/setenv.sh配置参数),那么在admin用户下输入如下命令就可以开启Wisp2了:

curl https://gosling.alibaba-inc.com/sh/enable-wisp2.sh | sh

否则需要手动升级JDK和Java参数:

ajdk 8.7.12_fp2 rpm

sudo yum install ajdk -b current # 也可以通过yum安装最新jdk
java -XX:+UseWisp2 .... # 使用Wisp参数启动Java应用

然后就可以通过jstack验证协程确实被开启了。

Carrier线程是调度协程的线程,下方的- Coroutine [...]表示一个协程,active表示协程被调度的次数,steal表示被work stealing的次数,preempt表示时间片抢占次数。

image

下图是DRDS在ecs上压测时的top -H,可以看出来应用的数百个线程被8个Carrier线程托管,均匀地跑在CPU核数个线程上面。下方一些名为java的线程是gc线程。

image

过多线程的开销

误区1: 进内核引发上下文切换

我们看一段测试程序:

pipe(a);
while (1) {
  write(a[1], a, 1);
  read(a[0], a, 1);
  n += 2;
}

image

执行这段程序时上下文切换非常低,实际上上面的IO系统调用都是不会阻塞的,因此内核不需要挂起线程,也不需要切换上下文,实际发生的是用户/内核态的模式切换。

上面的程序在神龙服务器测得每个pipe操作耗时约334ns,速度很快。

误区2: 上下文切换的开销很大

本质上来说无论是用户态还是内核态的上下文切换都是很轻量的,甚至有一些硬件指令来支持,比如pusha可以帮助我们保存通用寄存器。同一个进程的线程共享页表,因此上下文切换的开销一般只有:

  • 保存各种寄存器
  • 切换sp(call指令会自动将pc压栈)

可以在数十条指令内完成。

开销

既然近内核以及上下文切换都不慢,那么多线程的开销究竟在哪?

我们不妨看一个阻塞的系统调用futex的热点分布:

image

可以看到上面的热点中有大量涉及调度的开销。我们来看过程:

  1. 调用系统调用(可能需要阻塞);
  2. 系统调用确实需要阻塞,kernel需要决定下一个被执行的线程(调度);
  3. 执行上下切换。

因此,上面2个误区与多线程的开销都有一定因果关系,但是真正的开销来源于线程阻塞唤醒调度。

综上,希望通过线程模型来提升web server性能的原则是:

  1. 活跃线程数约等于CPU个数
  2. 每个线程不太需要阻塞

文章后续将紧紧围绕这两个主题。

为了满足上述两个条件,使用eventloop+异步callback的方式是一个极佳的选择。

异步与协程的关系

为了保持简洁,我们以一个异步服务器上的Netty写操作为例子(写操作也存在阻塞的可能):

private void writeQuery(Channel ch) {
  ch.write(Unpooled.wrappedBuffer("query".getBytes())).sync();
  logger.info("write finish");
}

这里的sync()会阻塞线程。不满足期望。由于netty本身是一个异步框架,我们引入回调:

private void writeQuery(Channel ch) {
  ch.write(Unpooled.wrappedBuffer("query".getBytes()))
    .addListener(f -> {
      logger.info("write finish");
    });
}

注意这里异步的write调用后,writeQuery会返回。因此假如逻辑上要求在write后执行的代码,必须出现在回调里,write是函数的最后一行。这里是最简单的情形,如果函数有其他调用者,那么就需要用CPS变换。

需要不断的提取程序的"下半部分",即continuation,似乎对我们造成一些心智负担了。这里我们引入kotlin协程帮助我们简化程序:

suspend fun Channel.aWrite(msg: Any): Int =
    suspendCoroutine { cont ->
        write(msg).addListener { cont.resume(0) }
    }

suspend fun writeQuery(ch: Channel) {
    ch.aWrite(Unpooled.wrappedBuffer("query".toByteArray()))
    logger.info("write finish")
}

这里引入了一个魔法suspendCoroutine,我们可以获得当前Continuation的引用,并执行一段代码,最后挂起当前协程。Continuation代表了当前计算的延续,通过Continuation.resume()我们可以恢复执行上下文。因此只需在写操作完成时回调cont.resume(0),我们又回到了suspendCoroutine处的执行状态(包括caller writeQuery),程序继续执行,代码返回,执行log。从writeQuery看我们用同步的写法完成了异步操作。当协程被suspendCoroutine切换走后,线程可以继续调度其他可以执行的协程来执行,因此不会真正阻塞,我们因此获得了性能提升。

从这里看,只需要我们有一个机制来保存/恢复执行上下文,并且在阻塞库函数里采用非阻塞+回调的方式让出/恢复协程,就可以使得以同步形式编写的程序达到和异步同样的效果了。

理论上只要有一个库包装了所有JDK阻塞方法,我们就可以畅快地编写异步程序了。改写的阻塞库函数本身需要足够地通用流行,才能被大部分程序使用起来。据我所知,vert.x的kotlin支持已经做了这样的封装。

虽然vert.x很流行,但是无法兼顾遗留代码以及代码中的锁阻塞等逻辑。因此不能算是最通用的选择。实际上Java程序有一个绕不过的库——JDK。Wisp就是在JDK里所有的阻塞调用出进行了非阻塞+事件恢复协程的方式支持了协程调度,在为用户带来最大便利的同时,兼顾了现有代码的兼容性。

上述方式支持了,每个线程不太需要阻塞,Wisp在Thread.start()处,将线程转成成了协程,来达到了另一目的: 活跃线程数约等于CPU个数。因此只需要使用Wisp协程,所有现有的Java多线程代码都可以获得异步的性能。

手工异步/Wisp性能比较

对于基于传统的编程模型的应用,考虑到逻辑清晰性、异常处理的便利性、现有库的兼容性,改造成异步成本巨大。使用Wisp相较于异步编程优势明显。

下面我们在只考虑性能的新应用的前提下分析技术的选择。

基于现有组件写新应用

如果要新写一个应用我们通常会依赖JDBC、Dubbo、Jedis这样的常用协议/组件,假如库的内部使用了阻塞形式,并且没有暴露回调接口,那么我们就没法基于这些库来写异步应用了(除非包装线程池,但是本末倒置了)。下面假设我们依赖的所有库都有回调支持,比如dubbo。

1)假设我们使用Netty接受请求,我们称之为入口eventLoop,收到请求可以在Netty的handler里处理,也可以为了io的实时性使用业务线程池。

2)假设请求处理期间需要调用dubbo,因为dubbo不是我们写的,因此内部有自己的Netty Eventloop,于是我们向dubbo内部的Netty eventLoop处理IO,等待后端响应后回调。

3)dubbo eventLoop收到响应后在eventloop或者callback线程池调用callback。

4)后续逻辑可以在callback线程池或者原业务线程池继续处理。

5)为了完成对客户端的响应最终总是要由入口的eventloop来写回响应。

我们可以看到由于这种封装导致的eventLoop的割裂,即便完全使用回调的形式,我们处理请求时多多少少要在多个eventLoop/线程池之间传递,而每个线程又都没法跑到一个较满的程度,导致频繁地进入os调度。与上述的每个线程不太需要阻塞原则相违背。因此虽然减少了线程数,节约了内存,但是我们得到的性能收益变得很有限。

完全从零开始开发

对于一个功能有限的新应用(比如nginx只支持http和mail协议)来说我们可以不依赖现有的组件来重新写应用。比如我们可以基于Netty写一个数据库代理服务器,与客户端的连接以及与真正后端数据库的连接共享同一个eventloop。

这样精确控制线程模型的应用通常可以获得很好的性能,通常性能是可以高于通过非异步程序转协程的,原因如下:

  • 线程控制更加精确:举个例子,比如我们可以控制代理的客户端和后端连接都绑定在同一个netty线程,所有的操作都可以threadLocal化
  • 没有协程的runtime和调度开销(1%左右)

但是使用协程依旧有一个优势:对于jdk中无处不在的synchronized块,wisp可以正确地切换调度。

适应的Workload

基于上述的背景,我们已经知道Wisp或者其他各种协程是适用于IO密集Java程序设计的。否则线程没有任何切换,只需要尽情地在CPU上跑,OS也不需要过多的干预,这是比较偏向于离线或者科学计算的场景。

在线应用通常需要访问RPC、DB、cache、消息,并且是阻塞的,十分适合使用Wisp来提升性能。

最早的Wisp1也是对这些场景进行了深度定制,比如hsf接受的请求处理是会自动用协程取代线程池,将IO线程数量设置成1个后使用epoll_wait(1ms)来代替selector.wakeup(),等等。因此我们经常受到的一个挑战是Wisp是否只适合阿里内部的workload?

  • 对于Wisp1是这样的,接入的应用的参数以及Wisp的实现做了深度的适配。
  • 对于Wisp2,会将所有线程转换成协程,已经无需任何适配了。

为了证明这一点,我们使用了web领域最权威的techempower benchmak集来验证,我们选择了com.sun.net.httpserver、Servlet等常见的阻塞型的测试(性能不是最好,但是最贴近普通用户,同时具备一定的提升空间)来验证Wisp2在常见开源组件下的性能,可以看到在高压力下qps/RT会有10%~20%的优化。

Project Loom

Project Loom作为OpenJDK上的标准协程实现很值得关注,作为java开发者我们是否应该拥抱Loom呢?

我们首先对Wisp和Loom这里进行一些比较:

1)Loom使用序列化的方式保存上下文,更省内存,但是切换效率低。

2)Wisp采用独立栈的方式,这点和go类似。协程切换只需切换寄存器,效率高但是耗内存。

3)Loom不支持ObectMonitor,Wisp支持。

  • synchronized/Object.wait()将占用线程,无法充分利用CPU。
  • 还可能产生死锁,以Wisp的经验来说是一定会产生死锁(Wisp也是后来陆续支持ObectMonitor的)。

4)Wisp支持在栈上有native函数时切换(反射等等),Loom不支持。

  • 对dubbo这样的框架不友好,栈底下几乎都带有反射。

总根据我们的判断,Loom至少还要2年时间才能到达一个稳定并且功能完善的状态。Wisp的性能优秀,功能要完整很多,产品本身也要成熟很多。Loom作为Oracle项目很有机会进入Java标准,我们也在积极地参与社区,希望能将Wisp的一些功能实现贡献进社区。

同时Wisp目前完全兼容Loom的Fiber API,假如我们的用户基于Fiber API来编程,我们可以保证代码的行为在Loom和Wisp上表现完全一致。

FAQ

协程也有调度,为什么开销小?

我们一直强调了协程适用于IO密集的场景,这就意味了通常任务执行一小段时间就会阻塞等待IO,随后进行调度。这种情况下只要系统的CPU没有完全打满,使用简单的先进先出调度策略基本都能保证一个比较公平的调度。同时,我们使用了完全无锁的调度实现,使得调度开销相对内核大大减少。

Wisp2为什么不使用ForkJoinPool来调度协程?

ForkJoinPool本身十分优秀,但是不太适合Wisp2的场景。

为了便于理解,我们可以将一次协程唤醒看到做一个Executor.execute()操作,ForkJoinPool虽然支持任务窃取,但是execute()操作是随机或者本线程队列操作(取决于是否异步模式)的,这将导致协程在哪个线程被唤醒的行为也很随机。

在Wisp底层,一次steal的代价是有点大的,因此我们需要一个affinity,让协程尽量保持绑定在固定线程,只有线程忙的情况下才发生workstealing。我们实现了自己的workStealingPool来支持这个特性。从调度开销/延迟等各项指标来看,基本能和ForkJoinPool打平。

还有一个方面是为了支持类似go的M和P机制,我们需要将被协程阻塞的线程踢出调度器,这些功能都不适宜改在ForkJoinPool里。

如何看待Reactive编程?

Reactive编程模型已经被业界广泛接受,是一种重要的技术方向;同时Java代码里的阻塞也很难完全避免。我们认为协程可以作为一种底层worker机制来支持Reactive编程,即保留了Reactive编程模型,也不用太担心用户代码的阻塞导致了整个系统阻塞。

这里是Ron Pressler最近的一次演讲,作为Quasar和Loom的作者,他的观点鲜明地指出了回调模型会给目前的编程带来很多挑战 。

Wisp经历了4年的研发,我将其分为几个阶段:

1)Wisp1,不支持objectMonitor、并行类加载,可以跑一些简单应用;

2)Wisp1,支持了objectMonitor,上线电商核心,不支持workStealing,导致只能将一些短任务转为协程(否则workload不均匀),netty线程依旧是线程,需要一些复杂且trick的配置;

3)Wisp2,支持了workStealing,因此可以将所有线程转成协程,上述netty问题也不再存在了。

目前主要的限制是什么?

目前主要的限制是不能有阻塞的JNI调用,wisp是通过在JDK中插入hook来实现阻塞前调度的,如果是用户自定义的JNI则没有机会hook。

最常见的场景就是使用了Netty的EpollEventLoop:

1)蚂蚁的bolt组件默认开启了这个特点,可以通过-Dbolt.netty.epoll.switch=false 来关闭,对性能的影响不大。

2)也可以使用-Dio.netty.noUnsafe=true , 其他unsafe功能可能会受影响。

3)(推荐) 对于netty 4.1.25以上,支持了通过-Dio.netty.transport.noNative=true 来仅关闭jni epoll,参见358249e5

原文发布时间为:2019-10-31
作者: 梁希
本文来自云栖社区合作伙伴“阿里技术”,了解相关信息可以关注“阿里技术”。

相关文章
|
2月前
|
存储 Java 索引
用Java语言实现一个自定义的ArrayList类
自定义MyArrayList类模拟Java ArrayList核心功能,支持泛型、动态扩容(1.5倍)、增删改查及越界检查,底层用Object数组实现,适合学习动态数组原理。
114 4
|
2月前
|
Java
Java语言实现字母大小写转换的方法
Java提供了多种灵活的方法来处理字符串中的字母大小写转换。根据具体需求,可以选择适合的方法来实现。在大多数情况下,使用 String类或 Character类的方法已经足够。但是,在需要更复杂的逻辑或处理非常规字符集时,可以通过字符流或手动遍历字符串来实现更精细的控制。
255 18
|
2月前
|
存储 安全 Java
【Golang】(4)Go里面的指针如何?函数与方法怎么不一样?带你了解Go不同于其他高级语言的语法
结构体可以存储一组不同类型的数据,是一种符合类型。Go抛弃了类与继承,同时也抛弃了构造方法,刻意弱化了面向对象的功能,Go并非是一个传统OOP的语言,但是Go依旧有着OOP的影子,通过结构体和方法也可以模拟出一个类。
206 1
|
3月前
|
存储 Java Go
对比Java学习Go——函数、集合和OOP
Go语言的函数支持声明与调用,具备多返回值、命名返回值等特性,结合`func`关键字与类型后置语法,使函数定义简洁直观。函数可作为一等公民传递、赋值或作为参数,支持匿名函数与闭包。Go通过组合与接口实现面向对象编程,结构体定义数据,方法定义行为,接口实现多态,体现了Go语言的简洁与高效设计。
|
3月前
|
存储 Java 编译器
对比Java学习Go——程序结构与变量
本节对比了Java与Go语言的基础结构,包括“Hello, World!”程序、代码组织方式、入口函数定义、基本数据类型及变量声明方式。Java强调严格的面向对象结构,所有代码需置于类中,入口方法需严格符合`public static void main(String[] args)`格式;而Go语言结构更简洁,使用包和函数组织代码,入口函数为`func main()`。两种语言在变量声明、常量定义、类型系统等方面也存在显著差异,体现了各自的设计哲学。
|
3月前
|
安全 Java 编译器
对比Java学习Go——基础理论篇
本章介绍了Java开发者学习Go语言的必要性。Go语言以简单、高效、并发为核心设计哲学,摒弃了传统的类继承和异常机制,采用组合、接口和多返回值错误处理,提升了代码清晰度与开发效率。Go直接编译为静态二进制文件,启动迅速、部署简便,其基于Goroutine和Channel的并发模型相较Java的线程与锁机制更轻量安全。此外,Go Modules简化了依赖管理,与Java的Maven/Gradle形成鲜明对比,提升了构建与部署效率。
|
3月前
|
存储 Java Apache
Java语言操作INI配置文件策略
以上步骤展示了基本策略,在实际项目中可能需要根据具体需求进行调整优化。例如,在多线程环境中操作同一份配置时需要考虑线程安全问题;大型项目可能还需考虑性能问题等等。
188 15
|
4月前
|
算法 Java
Java语言实现链表反转的方法
这种反转方法不需要使用额外的存储空间,因此空间复杂度为,它只需要遍历一次链表,所以时间复杂度为,其中为链表的长度。这使得这种反转链表的方法既高效又实用。
423 0
|
4月前
|
JSON Java API
【干货满满】分享拼多多API接口到手价,用Java语言实现
本方案基于 Java 实现调用拼多多开放平台商品详情 API,通过联盟接口获取商品到手价(含拼团折扣与优惠券),包含签名生成、HTTP 请求及响应解析逻辑,适用于电商比价、导购系统集成。
|
Cloud Native Java 测试技术
拼不过 GO?阿里如何重塑云上的 Java
Java 诞生于20年前,拥有大量优秀的企业级框架,践行 OOP 理念,更多体现的是严谨以及在长时间运行条件下的稳定性和高性能。反观如今,在要求快速迭代交付的云场景下,语言的简单性似乎成了首要的要求,而传统的 Java 语言显得有一些过于重量了。今天,阿里 JVM 团队技术专家郁磊(花名:梁希)分享 JVM 团队是如何面对和处理集团巨大的业务规模和复杂的业务场景的。
2892 0
拼不过 GO?阿里如何重塑云上的 Java