阿里妹导读:随着大量新生的异步框架和支持协程的语言 (如 Go) 的出现,在很多场景下操作系统的线程调度成为了性能的瓶颈,Java 也因此被质疑是否不再适应最新的云场景了。4 年前,阿里 JVM 团队开始自研 Wisp2,将 Go 语言的协程能力带入到 Java 世界。既享受 Java 的丰富生态,又获得异步程序的性能,Wisp2 让 Java 平台历久弥新。
Java 平台一直以生态的繁荣著称,大量的类库、框架帮助开发者们快速搭建应用。而其中大部分 Java 框架类库都是基于线程池以及阻塞机制来服务并发的,主要原因包括:
- Java 语言在核心类库中提供了强大的并发能力,多线程应用可以获得不俗的性能;
- Java EE 的一些标准都是线程级阻塞的(比如 JDBC);
- 基于阻塞模式可以快速地开发应用。
但如今,大量新生的异步框架和支持协程的语言 (如 Go) 的出现,在很多场景下操作系统的线程调度成为了性能的瓶颈。Java 也因此被质疑是否不再适应最新的云场景了。
4 年前,阿里开始自研 Wisp2。它主要是用在 IO 密集的服务器场景,大部分公司的在线服务都是这样的场景 (离线应用都是偏向于计算,则不适用)。它在功能属性上对标 Goroutine 的 Java 协程,在产品形态、性能、稳定性上都达到了一个比较理想的情况。到现在,已经有上百个应用,数万个容器上线了 Wisp1/2。Wisp 协程完全兼容多线程阻塞的代码写法,仅需增加 JVM 参数来开启协程,阿里巴巴的核心电商应用已经在协程模型上经过两个双十一的考验,既享受到了 Java 的丰富生态,又获得了异步程序的性能。
Wisp2 主打的是性能和对现有代码的兼容性,简而言之,现有的基于多线程的 IO 密集的 Java 应用只需要加上 Wisp2 的 JVM 参数就可以获得异步的性能提升。
作为例子,以下是消息中间件代理 (简称 mq) 和 drds 只添加参数不改代码的压测比较:
可以看到上下文切换以及 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 表示时间片抢占次数。
下图是 DRDS 在 ecs 上压测时的 top -H,可以看出来应用的数百个线程被 8 个 Carrier 线程托管,均匀地跑在 CPU 核数个线程上面。下方一些名为 java 的线程是 gc 线程。
过多线程的开销
误区 1: 进内核引发上下文切换
我们看一段测试程序:
pipe(a);
while (1) {write(a[1], a, 1);
read(a[0], a, 1);
n += 2;
}
执行这段程序时上下文切换非常低,实际上上面的 IO 系统调用都是不会阻塞的,因此内核不需要挂起线程,也不需要切换上下文,实际发生的是用户 / 内核态的模式切换。
上面的程序在神龙服务器测得每个 pipe 操作耗时约 334ns,速度很快。
误区 2: 上下文切换的开销很大
本质上来说无论是用户态还是内核态的上下文切换都是很轻量的,甚至有一些硬件指令来支持,比如 pusha 可以帮助我们保存通用寄存器。同一个进程的线程共享页表,因此上下文切换的开销一般只有:
- 保存各种寄存器
- 切换 sp(call 指令会自动将 pc 压栈)
可以在数十条指令内完成。
开销
既然近内核以及上下文切换都不慢,那么多线程的开销究竟在哪?
我们不妨看一个阻塞的系统调用 futex 的热点分布:
可以看到上面的热点中有大量涉及调度的开销。我们来看过程:
- 调用系统调用(可能需要阻塞);
- 系统调用确实需要阻塞,kernel 需要决定下一个被执行的线程(调度);
- 执行上下切换。
因此,上面 2 个误区与多线程的开销都有一定因果关系,但是真正的开销来源于线程阻塞唤醒调度。
综上,希望通过线程模型来提升 web server 性能的原则是:
- 活跃线程数约等于 CPU 个数
- 每个线程不太需要阻塞
文章后续将紧紧围绕这两个主题。
为了满足上述两个条件,使用 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
阿里云双 11 亿元补贴提前领,进入抽取 iPhone 11 Pro:https://www.aliyun.com/1111/2…
本文作者:梁希
阅读原文
本文来自云栖社区合作伙伴“阿里技术”,如需转载请联系原作者。