达达集团技术

京东到家话费券系统NIO实战

一 背景介绍

1.1 系统负载高

1.2 CPU利用率低

1.3 原因分析

二 IO模型介绍

2.1 同步阻塞IO

2.2 同步非阻塞IO

2.3 IO多路复用

2.4 异步IO

三 技术选型

3.1 协程的本质

3.2 协程框架

3.3 线程和协程对比

3.4 线程池弊端

四 项目实战

4.1 引入第三方jar包

4.2 声明挂起方法

4.3 协程使用简单示例

4.4 系统代码实践

4.5 成果展示

4.6 风险提示

五 总结扩展

一 背景介绍

京东到家话费券系统对外提供的服务调用频率存在不均衡的情况,比如活动页服务调用量是万级QPS,而其它普通服务的调用量为百级QPS。在业务高峰时,系统会遇到瞬时请求并发量激增的情况,此时系统的线程数量也会急剧增加,造成系统负载增加,性能下降。为此,我们不得不按照业务高峰期的场景进行机器扩容以保证业务高峰时期的服务稳定,但这会导致机器资源利用率整体上不高,该如何解决这个问题呢?

1.1 系统负载高

为了响应高并发,接口内部开启了大量线程,线程增多,上下文频繁切换以及大量线程等待导致了负载的升高 。

1.2 CPU利用率低

虽然线程数很多,但是每个线程在拿到CPU执行 时间片后,大部分时间处于等待IO返回的阻塞状态,也就是大部分时间CPU都是空转的,因而CPU利用率提升不上去 。

1.3 原因分析

对于CPU来说,任务分为两大类:计算密集型和IO密集型。

计算密集型已经可以最大程度发挥CPU的作用,但是IO密集型一直是提高CPU利用率的难点,既然IO密集型场景会导致CPU利用率上不去,那就不得不分析一下IO模型,了解IO的模型才能找到有效的解决方案。

二 IO模型介绍

2 .1 同步阻塞IO(Blocking IO)

同步阻塞IO指的是用户程序需要等待内核IO操作完成才能继续后面的操作,它是最简单的IO模型。我们传统的调用都是同步阻塞的。

大致流程如图:

2.2 同步非阻塞IO(Non-blocking IO)

同步非阻塞指的是用户程序不需要等待内核IO操作完成,内核立即返回给用户一个状态值,但是在内核空间没有数据的情况下,系统调用会再次发起,直到数据从内核空间复制到用户空间。复制完成后,系统调用返回成功。大致流程如图:

2.3 IO多路复用(IO Multiplexing)

IO多路复用是一种新的系统调用,它是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。通过Reactor的方式,将用户线程轮询的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。IO多路复用是最常使用的IO模型,我们都知道的单线程的Redis为啥那么快,一方面是基于内存,另一方面就是IO多路复用。但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO。大致流程如图:

2.4 异步IO(Asynchronous IO)

异步IO,指的是用户空间线程向内核空间注册各种IO事件的回调函数,由内核去主动调用,在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,用户线程直接使用即可。异步IO模型使用了Proactor设计模式实现了这一机制。大致流程如图: 本文改造的接口为同步阻塞的IO模型,在高并发场景下,会有大量线程处于阻塞状态,性能低下,JAVA上成熟的非阻塞IO(NIO)技术可解决该问题。基于epoll的NIO框架Netty在一些框架级别的应用中已经得到了广泛使用,但在快速迭代的业务系统中的应用依然有一定的局限性。NIO 消除了线程的同步阻塞,意味着只能异步处理IO的结果。遇到需要进行I/O操作的地方,就直接让出CPU资源,然后注册一个回调函数,其他逻辑则继续往下走,I/O结束后带着结果向事件队列里插入执行结果,然后由事件调度器调度回调函数,这与业务开发者顺序化的思维模式有一定差异,代码复杂度高,降低了代码可读性与可维护性。当业务逻辑复杂以及出现多次远程调用的情况下,多级回调难以实现和维护。相反同步IO虽然效率低,但是很好写。

三 技术选型

我们是否能用同步的方式编码,达到异步的效果与性能呢?那就是协程。它兼顾可维护性与可伸缩性。充分提高硬件利用率,实现高吞吐量。

3.1 协程的本质

协程的本质是异步+回调,它的核心点在于调度那块由它来负责解决,遇到阻塞操作,立刻yield掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别。而这一切都是发生的用户态上,没有发生在内核态上,也就是说没有上下文切换的开销 ,它核心思想在于参与者让出(yield)控制流时,记住自身状态,以便在控制流返回时能从上次让出的位置恢复(resume)执行。简言之,协程的核心就在于控制流的主动让出和恢复,也就是这个“协”字。

协程是一种比线程更加轻量级的函数 。正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。

协程和线程并非矛盾,也不是说协程就比线程好。协程的优势在于对IO密集型业务的痛点处理,而这部分是线程的软肋 那协程和线程是什么关系呢?

3.2 协程框架

JVM原生是不支持协程的。不过一些第三方jar包提供了协程功能,如kilim,Quasar。而kilim已经很久未更新了,当前流行度较高的就是Quasar。

3.2.1 Quasar框架原理

在介绍Quasar之前,我们先了解下字节码增强技术又名插桩。所谓字节码增强就是对现有字节码进行修改或者动态生成全新字节码的过程。

字节码增强技术有两种:ASM和JavaAssist

ASM使用场景:需要手动操纵字节码的需求,可以使用ASM。它可以直接生产 .class文件,也可以在类被装载入JVM之前动态修改类行为

ASM的应用场景:AOP以及热部署等

ASM对字节码操作的过程:ClassReader读取字节码—》Visitor处理字节码—》ClassWriter生成新的字节码

ASM缺点:虽然可以达到修改字节码的效果,但是代码实现上更偏底层,和Java语言的编程习惯有较大差距

JavaAssist优点:编程简单。直接使用java编码的形式,不需要了解虚拟机指令。

JavaAssist缺点:只能在类加载前对类中字节码进行修改,JVM是不允许我们在运行时动态重载一个类的,而这就使得字节码增强技术的使用场景变得很窄,毕竟大部分运行的Java系统都是在运行状态的。

那么如何实现在JVM运行时去动态的重新加载类呢?这就不得不引出Java下的类库接口:java.lang.instrument.Instrumentation

instrument是JVM提供的一个可以修改已加载类的类库,专门为Java插桩服务提供支持。它需要依赖JVMTI的Attach API机制实现,在JDK 1.6以前,instrument只能在JVM刚启动加载类时生效,而在JDK 1.6之后,instrument支持了在运行时对类定义的修改。

Instrumentation的接口定义如下,要使用instrument的类修改功能, 我们需要定义一个类文件转换器ClassFileTransformer。

public interface Instrumentation {      //类文件转换器      void addTransformer(ClassFileTransformer transformer);      //重新加载一个类,加载时触发ClassFileTransformer接口      void retransformClasses(Class... classes) throws UnmodifiableClassException; }

并实现ClassFileTransformer的transform方法

transform(  ClassLoader         loader,                String              className,                Class<?>            classBeingRedefined,                ProtectionDomain    protectionDomain,                byte[]              classfileBuffer)        throws IllegalClassFormatException;

transform方法会在类文件被加载时调用,而在transform方法里,我们可以利用上文中的ASM或Javassist对字节码进行改写和替换,生成新的字节

Quasar框架就是通过利用Java instrument技术对字节码进行修改,使方法挂起前后可以保存和恢复JVM栈帧,方法内部已执行到的字节码位置也通过增加状态机的方式记录,在下次恢复执行可直接跳转至最新位置,就是这个字节码增强技术。

简单概括下Quasar的执行流程:

(1)利用字节码增强技术,将普通的java代码转换为支持协程的代码。

(2)在调用pausable方法的时候,如果pause了就保存当前方法栈的State,停止执行当前协程,将控制权交给调度器 。

(3)调度器负责调度就绪的协程 。

(4)协程resume的时候,自动恢复State,根据协程的pc计数跳转到上次执行的位置,继续执行。大致流程如图:

上面的(3)提到了一个调度器的概念,那这个调度器要如何实现呢?

试想一下,如果我们希望某段业务逻辑通过协程去做,假设这段代码内部有AB两个阻塞的逻辑,而不同的阻塞逻辑里又嵌套A1,A2不同的阻塞代码。如果协程调度中心仅仅对阻塞的地方进行调度,那么阻塞代码A2被调度进去后,后面的代码B直接继续进行也是不合适的,所以最顶层要有一个可以维护任务衍生关系的存在,在子任务运行成功后才能运行外边的大阻塞代码块,这个很类似ForkJoinPool,一个任务里可以fork出其他任务,而该任务挂起后,其再次触发需要在其子任务都执行完成之后。

Quassar就是这么做的,在运行过程中它会将阻塞的任务交给调度中心去执行,调度中心维护好这些有fork关系的任务上下文。那么它是怎么知道哪些方法需要挂起以及织入相关指令呢?

答:有标记的需要挂起以及织入:

方法带有Suspendable 注解;

方法带有SuspendExecution ;

方法为classpath下/META-INF/suspendables、/META-INF/suspendable-supers指定的类或接口或子类 ;

Quasar中对协程的调度过程大致如下:

3.2.2 Quasar框架关键类和接口

结合着调度的流程介绍Quasar Fiber中的的关键类和接口:

Strand是quasar里对Thread和Fiber统一的抽象,Fiber是Strand的用户级线程实现,Thread是Strand内核级线程的实现。

FiberForkJoinScheduler{

//具体执行task的线程池

private final ForkJoinPool fjPool;

//监控fiber timeout的scheduler

private final FiberTimedScheduler timer;

//保存fiber worker线程

private final Set activeThreads;

}

quasar里用ForkJoinPool作为默认scheduler的线程池来调度。

FiberForkJoinTask{ //包装了fiber的ForkJoinTask

private final ForkJoinPool fjPool;

private final Fiberfiber;

}

FiberTimedScheduler是quasar实现的timeout scheduler,用于fiber timeout的处理。

public FiberTimedScheduler(FiberScheduler scheduler, ThreadFactory threadFactory, FibersMonitor monitor) {        this.scheduler = scheduler;        this.worker = threadFactory.newThread(new Runnable() {            @Override            public void run() {                work();            }        });        this.workQueue = USE_LOCKFREE_DELAY_QUEUE ? new SingleConsumerNonblockingProducerDelayQueue<ScheduledFutureTask>() : new co.paralleluniverse.concurrent.util.DelayQueue<ScheduledFutureTask>();        this.monitor = monitor;        worker.start();    }

FiberTimedScheduler默认的work queue为SingleConsumerNonblockingProducerDelayQueue,这是一个多生产单消费的无锁队列,内部是一个lock-free的基于skip list的优先级链表,有兴趣可以看下具体的实现。

scheduler实现逻辑就比较简单了,从SingleConsumerNonblockingProducerDelayQueue内部的优先级队列取数据,如果超时了则调用fiber.unpark()。

3.3 线程和协程对比

(1)线程的切换由操作系统负责调度,切换内容包括内核栈和硬件上下文。线程切换内容是用户态,内核态再到用户态。协程切换时机是用户自己的程序来决定的。协程的切换过程只有用户态(即没有陷入内核态),因此切效率高。

(2)线程是同步机制,而协程则是异步,这点上面已经分析了。

(3)线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。

(4)线程遇到IO操作会阻塞,协程在等待异步任务的结果时,会通知调度器将自己放入挂起队列,释放占用的线程以处理其他的协程。异步任务完毕后,通过回调将异步结果告知协程,并通知调度器将协程重新加入就绪队列执行。

3.4 线程池弊端

线程池—假性吞吐:被拒绝策略丢弃的请求计入吞吐量,但是这部分流量是业务有损的。 线程池—队列等待:队列等待压缩了我们业务的执行时间,这并不是我们所期望的。 线程池—阻塞:线程内部有大量耗时rpc请求,在等待请求响应的这段时间,线程是阻塞的,即使换成工作窃取模式的线程池,也解决不了单个线程内部IO请求阻塞的问题 而如果将线程池用协程来替代,这些问题就都不复存在了。

四 项目实战

4.1 引入第三方jar包

<dependency>            <groupId>co.paralleluniverse</groupId>            <artifactId>quasar-core</artifactId>            <version>0.7.9</version>            <classifier>jdk8</classifier>        </dependency>

4.2 声明挂起方法

在项目主pom下添加quasar-maven-plugin插件,该插件将在编译后的class文件中修改字节码。

<plugin>                <groupId>com.vlkan</groupId>                <artifactId>quasar-maven-plugin</artifactId>                <version>0.7.3</version>                <configuration>                    <check>true</check>                    <debug>true</debug>                    <verbose>true</verbose>                </configuration>                <executions>                    <execution>                        <phase>compile</phase>                        <goals>                            <goal>instrument</goal>                        </goals>                    </execution>                </executions>            </plugin>

4.3 协程使用简单示例

(1)通过co.paralleluniverse.fibers.FiberUtil开启一个协程。

Integer result=FiberUtil.runInFiber(new SuspendableCallable<Integer>() {                @Override                public Integer run() throws SuspendExecution, InterruptedException {                    LOGGER.info("run in fiber begin" );                    Fiber.sleep(100);                    LOGGER.info("run in fiber end" );                    return 1;                }});

(2)通过 co.paralleluniverse.fibers.Fiber开启一个协程。

Fiber<String> fiber1 = new Fiber<>("fiber_1", new SuspendableCallable<String>() {            @Override            public String run() throws SuspendExecution, InterruptedException {                LOGGER.info("fiber_1 begin");               // Fiber.sleep(100);                String str=HttpClient.doGet("http://qa-configcenter.jd.com/");                LOGGER.info("fiber_1 end");                return "1";            }}).start();

4.4 系统代码实践

原:

Future future = ThreadPool().submit(new MarkTask(); return future.get(100,TimeUnit.MILLISECONDS);

新:

Future future = FiberPool.submit(new MarkTask(request); return future.get(100,TimeUnit.MILLISECONDS);

FiberPool核心代码:

public class FiberPool implements ExecutorService{    @Override    public <T> Future<T> submit(Callable<T> task){        Fiber<T> fiber=new Fiber<>("FIBER_POOL", new SuspendableCallable<T>() {            @Override            public T run() throws SuspendExecution, InterruptedException{                try {                    return task.call();                } catch (SuspendExecution e) {                    logger.error("FiberPool 发生SuspendExecution异常",e);                    throw e;                } catch (Exception e) {                    logger.error("FiberPool 发生异常",e);                }                return null;            }        }).start();        return translate(fiber);    }    private <T> Future<T> translate(Fiber<T> fiber){        Future<T> future=new Future<T>() {            @Override            public boolean cancel(boolean mayInterruptIfRunning) {                return false;            }            @Override            public boolean isCancelled() {                return false;            }            @Override            public boolean isDone() {                return false;            }            @Override            public T get() throws InterruptedException, ExecutionException {                return fiber.get();            }            @Override            public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {                return fiber.get(timeout,unit);            }        };        return future;    }}

4.5 成果展示

(1)在使用线程池的时候,设置了future.get(100),可是通过监控可见,接口响应时间达到了200,原因就是:响应时间=队列等待时间+接口请求时间 , 协程引入后,接口吞吐量提升明显。

(2)对于执行了拒绝策略的请求监控标记为失败,线程池随着业务请求的增多,业务损失极为明显。

(3)引入协程后,CPU因为没有了线程的上下文切换,负载也是明显的降下来了。

4.6 风险提示

说了这么多协程的好处,接下来说一下它的风险和注意点吧 :

(1)在synchronized同步块的内部,不能包含挂起协程的语句。当持有锁的协程挂起后会让出线程资源,由于锁的可重入性,另一个运行在同一个线程上的协程再加锁时同样会成功。另一方面,协程挂起后恢复执行时,也可能会在另一个线程上运行。出现两个线程操作共享资源的异常。同时未持有锁的线程释放时,会出现IllegalMonitorStateException异常,大致流程如图:

上图列举了两种场景,即协程恢复后继续由原来线程执行和由其他线程执行的Case,其中的坑一目了然,我就不多赘述了 。

(2)Quasar协程不是Java的语言标准,没有JVM层面的支持,使用时必须手动抛出异常声明每一个挂起方法,对代码有一定的侵入性。使用不当时,可能出现异常,比如代码的try/catch时可能同时捕获SuspendExecution异常,而忘记标记方法,此方法字节码不会被修改,结合Quasar的原理可以看出,当没有织入字节码时,挂起方法恢复执行,无法还原方法栈帧和执行状态,将会出现语句被重复执行、空指针等错误。运行时空指针、死循环的症状,排查的重点是是否漏加SuspendExecution标记。

五 总结扩展

异步编程最佳的实现方式是:以同步的方式编码,达到异步的效果与性能,兼顾可维护性与可伸缩性。

本文利用开源的Quasar框架提供的协程对系统进行NIO改造,解决以下两个问题:

( 1)提升 单机任务的吞吐量,保证业务请求突增时系统的可伸缩性。

( 2)使用更轻量的 协 程,替代处理 NIO 常用的异步回调。

openjdk 的官网上,2021/11/15 这天创建了一个新的特性—虚拟线程:

官方介绍虚拟线程是一种成本低廉、轻量级的用户模式的线程实现,它可以充分利用可用硬件,大幅减少编写、维护和监测高并发应用的工作量,这俨然就是协程的概念。相信不久的将来Java生态将会发生里程碑级别的改变 。