了解一下 Java21 的 VirtualThread
0、自上而下
从用户接口的角度,虚拟线程的使用方式即 ThreadPerTaskExecutor,只是这里为每个任务创建的线程是虚拟线程
从任务执行的角度,任务包装为 Continuation 后提交到 ForkJoinPool 执行
从调度的角度,Continuation 是“可挂起”的任务,执行->挂起->唤醒->执行,如此反复直至任务执行完毕
1、Continuation
按照 Ron Pressler 在《Loom项目提案》中的描述:
Continuation 是 Java 中的『协程』抽象,这是个有栈协程(相对于 Rust async 的无栈协程),但是这个栈不包含 JNI stack frames,换句话说就是无法在 JNI 调用中挂起,有个专有的名词 Pinned 描述这种情况,后面会介绍。
如果要在外部使用 Continuation 需要添加参数 --add-exports java.base/jdk.internal.vm=ALL-UNNAMED
第 9 行的 yield 就会让 VM 记录当前的栈帧,让出线程;
第 18 行再次 run 的时候,VM 就会恢复栈帧,从上次让出的地方继续执行。
ContinuationScope 标识 Continuation 的作用域,该 API 支持层次结构的 Continuation
但是 VirtualThread 创建 VThreadContinuation 时传入的 scope 是固定的,所以在虚拟线程的场景对 Continuation 的使用还是比较简单。
“栈帧”的记录与恢复逻辑是在 JVM 中实现的,Java 代码中只留了一个尾巴 StackChunk,这里不展开,详见 《Continuations - Under the Covers》
2、VirtualThread
VirtualThread 对 Continuation 进一步封装,另外还声明了调度器的实现。
2.1、调度器
DEFAULT_SCHEDULER 是一个 ForkJoinPool,用于任务的执行。
UNPARKER 是一个调度线程池,用于唤醒定时的任务,例如 Thread.sleep(100)。
2.2、线程状态
VirtualThead 在 ForkJoinPool 的『平台线程』中开始执行,就会将自身状态设置为 RUNNING,遇到“挂起”的操作就会转为 PARKING 和 YIELDING 状态,然后让出『平台线程』。
YIELDING 和 PARKING 状态
“挂起”操作的入口主要有两个:
Thread.yield --> VirtualThread.tryYield --> yieldContinuation
LockSupport.park --> System.parkVirtualThread --> VirtualThread.park[Nanos] --> yieldContinuation
这里就看到了熟悉的 Continuation.yield(VTHREAD_SCOPE)
在 yield 之后就会再次提交到 ForkJoinPool,等待再次调度执行。
park 有两个情况
如果设置了等待时间,那么向 UNPARKER 提交一个延迟任务进行 unpark
如果没有设置等待时间,那么由 System 进行 unpark
PINNED 状态
PINNED 状态换一个说法也就是 yieldContinuation 失败,无法让出『平台线程』,可以理解为退回成物理线程的状态,会影响吞吐量。
通过 JFR 事件可以统计 PINNED 的时长。
如果要统计 PINNED 的位置可以设置 jdk.tracePinnedThreads 环境变量。
3、ForkJoinPool
调度器设置的默认线程池最大值为 256,而不是可用核数,其主要原因应该就是 PINNED 的问题。
那么问题来了,这个值设置为多少合适呢?或者说如何识别 Pinned 的严重程度呢?
版权声明: 本文为 InfoQ 作者【袁世超】的原创文章。
原文链接:【http://xie.infoq.cn/article/1afbd7bd5eb8b642227e68355】。文章转载请联系作者。
评论