作者简介
Ryan,携程Java开发工程师,对高并发、网络编程等领域有浓厚兴趣。
IO密集型系统在高并发场景下,会有大量线程处于阻塞状态,性能低下,JAVA上成熟的非阻塞IO(NIO)技术可解决该问题。目前Java项目对接NIO的方式主要依靠回调,代码复杂度高,降低了代码可读性与可维护性。近年来Golang、Kotlin等语言的协程(Coroutine)能达到高性能与可读性的兼顾。
本文利用开源的Quasar框架提供的协程对系统进行NIO改造,解决以下两个问题:
1)提升单机任务的吞吐量,保证业务请求突增时系统的可伸缩性。
2)使用更轻量的协程同步等待IO,替代处理NIO常用的异步回调。
一、Java异步编程与非阻塞IO
本文改造的系统处理来自前台的任务,通过HTTP请求对端服务,还通过RPC调用内部服务。当业务高峰时,系统会遇到瞬时并发任务量数十倍激增的情况,系统的线程数量急剧增加造成性能下降。为此,不得不扩容以保证业务高峰时期的性能。
基于epoll的NIO框架Netty在一些框架级别的应用中已经得到了广泛使用,但在快速迭代的业务系统中的应用依然有一定的局限性。NIO 消除了线程的同步阻塞,意味着只能异步处理IO的结果,这与业务开发者顺序化的思维模式有一定差异。当业务逻辑复杂以及出现多次远程调用的情况下,多级回调难以实现和维护。
1.1 Java中的异步工具
Java项目大多使用JDK8,除线程外可以获得的异步的编程支持包括CompletableFuture,以及开源的RxJava、Vert.x等反应式编程框架等。这些工具使用了基于响应式编程的链式调用逐级传递事件,未从根本解决回调问题。
如下为将一段简单的逻辑判断使用CompletableFuture进行异步改造后的对比。原始版本使用getA方法获得第一步的请求结果,根据其相应选择使用getB1还是getB2获取第二步的响应作为结果。
HttpResponse a = getA();
HttpResponse b ;
if(a.getBody().equals("1")){
b=getB1();
}
else{
b=getB2();
}
String ans=b.getBody();
首先将三个获取响应的方法改为异步。此处假设getB1与getB2内部已经具有复杂逻辑,且不属于同一领域,不适合合并为一个方法。
private CompletableFuture<HttpResponse> getA();
private CompletableFuture<HttpResponse> getB1();
private CompletableFuture<HttpResponse> getB2();
然后使用CompletableFuture的链式调用,将两个步骤组合起来:
String ans = getA()
.thenCompose(a -> {
if (a.getBody().equals("1")) {
return getB1();
} else {
return getB2();
}
}).get()
.getBody();
使用CompletableFuture的链式回调后,代码变得不友好。RxJava等框架同样具有这个问题。这类反应式的编程工具更适合于数据流的传递。对于if/else、switch/case,乃至while/for、break/continue这类过程控制语句,实现与维护的难度都很大。业务系统需要类似于线程的同步等待,同时具有低资源消耗的编码工具,配合 NIO使用。当时使用NIO时,由于可以不占用线程,可以使用一种资源消耗更小的协程来等待。
1.2 协程
协程是一种进程自身来调度任务的调度模式。协程与线程不同之处在于,线程由内核调度,而协程的调度是进程自身完成的。协程只是一种抽象,最终的执行者是线程,每个线程只能同时执行一个协程,但大量的协程可以只拥有少量几个线程执行者,协程的调度器负责决定当前线程在执行那个协程,其余协程处于休眠并被调度器保存在内存中。
和线程类似,协程挂起时需要记录栈信息,以及方法执行的位置,这些信息会被协程调度器保存。协程从挂起到重新被执行不需要执行重量级的内核调用,而是直接将状态信息还原到执行线程的栈,高并发场景下,协程极大地避免了切换线程的开销。下图展示了协程调度器内部任务的流转。
协程中调用的方法是可以挂起的。不同于