作者简介:郑淼,腾讯高级工程师,深入参与腾讯自研协程Kona Fiber以及ZGC的优化
本文主要介绍腾讯大数据编译器研发团队自研的Java协程Kona Fiber最近一年来完善易用性(支持synchronized锁、死锁检测、网络操作)的工作。
▍协程用于解决什么问题?
图1.1展示了线程模型的常见做法,图中左侧的queue是一个任务队列,线程从任务队列里取任务执行,遇到IO操作时线程让出cpu。
图1.1
互联网业务通常是高并发的,所谓高并发是指同时有多个任务被执行。如果用图1.1的线程模型去实现,就会存在两个明显的问题:
在协程出现以前,业务的高并发+IO密集型业务的需求是如何满足的呢?对于Java生态,可以选择各种各样的异步编程框架。这些异步编程框架,核心是一个线程能够同时完成多个业务,也就是一个线程对应多个并发(经典的线程模型是一个线程对应一个并发)。异步编程框架在理论上可以完美解决问题,但是异步编程框架存在两个问题:
协程提供了异步编程的能力,又保留了线程模型的简单性。使用协程,用户可以按照线程模型进行编程,同时获得接近异步编程的性能,且可以根据并发数创建任意数量的协程(单机可以创建几十万协程,Loom支持单机几千万的协程数量)
▍OpenJDK社区已有Loom,为什么还要自研Kona Fiber?
Loom作为OpenJDK社区的官方实现,目前基于前沿版本开发(当前为JDK19)。如果用户现在使用Loom,至少面对两个难点:
与Loom相比,Kona Fiber有如下特点:
虽然当前业务使用JDK8/JDK11,但是未来业务的JDK总归是要升级的。从代码演进性的角度,Kona Fiber在接口设计上和Loom保持一致。用户在切换到高版本JDK时,可以不加任何修改直接使用Loom替换Kona Fiber。
Loom采用stack copy的方案实现协程切换,当协程A切换到协程B时,需要将协程A的执行栈从线程拷贝到Java Heap,将协程B的栈从Java Heap中拷贝到当前线程的执行栈上。这种拷贝操作的好处是,协程的栈可以按需使用,不需要为协程的栈预留内存。
Kona Fiber是真正意义上的stackful实现,每个协程有独立的栈,切换时无需拷贝,只需要简单的切换rsp、rbp寄存器,保存一些基本的执行状态即可。因此Kona Fiber的切换效率相比Loom更高,占用的内存相比Loom稍多。图2.1展示了Kona Fiber、Loom、JKU的切换效率、内存占用的对比。
图2.1
▍腾讯自研协程Kona Fiber最近一年的改进
Kona Fiber主要从易用性(支持synchronized锁、死锁检测、网络操作)和完整性(Kona Fiber 11支持ZGC)两个角度进行完善,下图是Kona Fiber和Loom、JKU的对比:
图3.1
在jvm中,synchronized锁膨胀为重量级锁时,锁的owner被标记成线程。如下图所示,VT_0和VT_1都是运行在Worker Thread 0上的协程,VT_0在执行过程中申请了一个重量级锁A,图中的yield表示协程切换操作。当VT_0遇到IO操作,让出执行权限时,VT_1被调度执行。如果VT_1也尝试去获取重量级锁A,因为重量级锁A此时的owner被标记成Worker Thread 0,且Java支持锁重入,所以VT_1也可以执行重量级锁A保护的临界区。
图3.2
协程在设计时,希望用户在使用协程时可以按照线程模型进行编程。如果用户把协程当作线程,那么synchronized的语义就可能失效,导致业务代码的逻辑出现问题。
下图展示了Loom的解决方案,本质上是在持有synchronized锁时不允许协程和线程分离,即协程绑住线程,另一个协程只能去新的线程上执行,cpu被迫执行一个较重的线程切换(即图中的context switch),这就是Loom引入的Pin的概念,即所谓的“协程退化成线程”。当持有synchronized锁时,通过禁止协程切换的方式,防止由于synchronized锁的出现导致临界区失效。
图3.3
下图展示了synchronized锁的三种状态:偏向锁、轻量级锁、重量级。
图3.4
上述方案解决了协程持有synchronized锁切换的问题,当协程申请synchronized锁失败时,协程会block在jvm中,此时仍相当于协程退化成线程。协程在执行时需要挂载到线程上,协程个数通常远远多于运行协程的线程个数。绝大多数情况下,用户不需要感知运行协程的线程(类似用户使用线程编程时,不需要感知物理CPU),这时默认会创建一个ForkJoinPool作为运行协程的调度器。
当协程由于申请synchronized锁失败而block在jvm中时,会在ForkJoinPool线程不足时调用compensate动态调整ForkJoinPool的线程个数。
图3.5
死锁检测是程序员常用的功能。由于协程的个数较多,如果没有辅助工具帮助用户进行死锁检测,逐一排查通常是一件耗时耗力的事。
死锁检测本质上是一个寻找环的过程,下图展示了线程死锁检测的逻辑。首先需要有一个出发点,例如图中的thread0:
图3.6
如下图所示,展示了协程死锁检测的运行结果。
图3.7
如下图所示,展示了基本的TCP操作:
图3.8
问题是,这些操作在jvm上都是同步操作,且阻塞在jvm内部,对应的代码如下所示:
#define BLOCKING_IO_RETURN_INT(FD, FUNC) { \ int ret; \ threadEntry_t self; \ fdEntry_t *fdEntry = getFdEntry(FD); \ if (fdEntry == NULL) { \ errno = EBADF; \ return -1; \ } \ do { \ startOp(fdEntry, &self); \ ret = FUNC; \ endOp(fdEntry, &self); \ } while (ret == -1 && errno == EINTR); \ return ret; \}
代码中的FUNC就对应每种操作,例如connect()、accept()等。这种block在native的操作,会导致协程退化成线程,因此协程需要做额外的适配。
下图展示了协程适配网络操作的基本思路:
图3.9
▍传送门
更多Kona JDK信息请访问下面链接。