在Java中使用协程 - 协程实践

本文最后更新于:2022年12月19日 晚上

1、前言

协程在2022年已经不是什么新鲜的东西,甚至可以说有一点烂大街了,但是最近在工作中真正地用上了协程,并且带来了很大的性能提升,这里做一个简单的分享。

其实在这段时间的工作中都在优化这个服务的性能(一个日常单机1000+QPS的应用),应该算并发比较高的服务了,虽然平均rt等指标看上去都很正常,但是P95 P99等都不那么好看,甚至有一点糟糕,一些服务的兜底率也偏高。

期间尝试过优化代码、调整JVM参数控制GC,虽然有一定的提升,但是P95 P99始终是一把悬着的利剑,有时候服务的最大响应时间也会彪上1s。

这个时候我们来分析一下这个服务:高并发、IO型,并没有太多的计算,那么这个服务的线程切换一定非常频繁,因为它不仅需要处理上游的请求,还要同时向下游发起调用,所以当时我想到了开启协程试一下,正巧公司JDK使用的都是支持协程的dragonwell,同时之前其他应用也有开启的前例,并没有出现任何问题。

2、Java如何使用协程

Java原生并不支持协程,但是阿里提供了支持协程的JDK:

阿里巴巴有着最丰富的Java应用场景,覆盖电商,金融,物流等众多领域,世界上最大的Java用户之一。 作为OpenJDK的下游, Alibaba Dragonwell是阿里巴巴内部OpenJDK定制版AJDK的开源版本, AJDK为在线电商,金融,物流做了结合业务场景的优化,运行在超大规模的,100,000+ 服务器的阿里巴巴数据中心。Alibaba Dragonwell是OpenJDK的下游(friendly fork),使用了和OpenJDK一样的licensing。阿里会更紧密地和OpenJDK等开源社区协作, 贡献更多的patches, 促进Java技术的持续发展。

dragonwell只支持linux和windows系统,所以使用macos的各位暂时不能在本机上进行调试(实际上应该也不需要)

这里就不赘述如何安装JDK了,dgragonwell有很多其他功能,我们这里主要介绍Wsip这个功能:

Wisp2 是在JVM层面实现的有栈对称式协程。相较于Kotlin、Kilim等字节码方案来说,Wisp在JDK阻塞接口上插入了调度支持,因此可以让现有Java应用无需改动地获得协程所带来的性能提升。

简单来说就是Wsip可以在无侵入的情况下实现Java的协程。

3、开启协程并调试

对于Java应用来说,并不是所有线程都适合开启协程的,像Netty、一些内核线程等都不太适合开启协程,我们可以通过设置白名单的方式将我们的一些业务线程转换为协程。

使用如下启动参数:

1
2
3
4
5
6
#开启协程
-XX:+UseWisp2
#不让所有的线程都转换为协程
-Dcom.alibaba.wisp.enableThreadAsWisp=true
#指定开启协程的线程池
-Dcom.alibaba.wisp.threadAsWisp.white=name:DubboServerHandler*;name:your-thred-pool-name*

这样,我们可以让你的业务线程池、Dubbo业务线程池开启协程,实践中可以根据需要调整参数。

到这里其实我们就成功开启了协程,那么问题来了,怎么知道协程是否成功开启了呢?

答案是使用jstack,jstack等工具已经支持了协程。

服务运行后jstack pid > o.txt,我们用vi/vim搜索你自己的线程名,就可以得到如下结果:

1
2
- Coroutine [0x7f227dae47e0] "thread-pool-name-1" active=2394347 steal=378779 steal_fail=968 preempt=0 park=0/-1
Wsip...
  • active表示协程被调度的次数
  • steal表示work stealing发生的次数
  • preempt 表示抢占的次数

如果下面的堆栈出现了Wsip的字样,那说明协程已经成功开启啦

4、性能

过程固然重要,但是如果性能不好那也只是徒劳。

Wsip官方提供的性能测试中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
             o      .   _______ _______
\_ 0 /______//______/| @_o
/\_, /______//______/ /\
| \ | || | / |


static final ExecutorService THREAD_POOL = Executors.newCachedThreadPool();

public static void main(String[] args) throws Exception {
BlockingQueue<Byte> q1 = new LinkedBlockingQueue<>(), q2 = new LinkedBlockingQueue<>();
THREAD_POOL.submit(() -> pingpong(q2, q1)); // thread A
Future<?> f = THREAD_POOL.submit(() -> pingpong(q1, q2)); // thread B
q1.put((byte) 1);
System.out.println(f.get() + " ms");
}

private static long pingpong(BlockingQueue<Byte> in, BlockingQueue<Byte> out) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 1_000_000; i++) out.put(in.take());
return System.currentTimeMillis() - start;
}
1
2
3
4
5
6
$java PingPong
13212 ms

// 开启Wisp2
$java -XX:+UnlockExperimentalVMOptions -XX:+UseWisp2 -XX:ActiveProcessorCount=1 PingPong
882 ms

可以看到对于线程切换频繁的应用,协程带来的提升是巨大的。

在实践中也印证了这个理论,在开启协程前一个上游应用的调用监控中:P95 P99大约都在250-500ms来回横跳(抖动),服务的尾请求非常的不稳定,开启协程后基本都稳定在200-250ms,几乎没有抖动,应用自己的监控,接口最大响应时间也几乎没有以前的抖动,可以说性能提升巨大。

5、其他

其实本篇文章基本没有什么技术含量,算是一个小小的日记,对于很多Java开发者来说,有的可能接触过其他语言,如Go的协程,但是大多数人实际上是没有在生产环境中实际使用过协程的。如果你也正在为一些性能问题所困扰,或许可以试试这款JDK,网络上很多人因为阿里的原因对这个JDK存在偏见,但是凡事都得真正使用一下,才能知道它到底几斤几两。

当然,协程也不是什么银弹,之前也有其他服务开启过协程,并没有带来什么收益,需要具体应用具体分析。

参考:

dgragonwell-docs