-
Notifications
You must be signed in to change notification settings - Fork 496
Wisp文档
Wisp2 是在JVM层面实现的有栈对称式协程。相较于Kotlin、Kilim等字节码方案来说,Wisp在JDK阻塞接口上插入了调度支持,因此可以让现有Java应用无需改动地获得协程所带来的性能提升。
Wisp2最重要的思想是:保持基于线程的编程模型和接口,让底层尽量提供完整的能力。应用只需添加JVM参数控制,完全透明使用。相较于需要应用修改的方案有如下好处:
- 接入成本极低: 无需源码和编译,即使面临一堆古老jar包也能轻松应对
- 现有的并发机制可复用,比如j.u.c.
- Troubleshooting能力提升:
- 由于开关协程使用相同代码,可以轻易地判断是否是协程相关的问题
- 对于一些工具无法兼容的情况(比如strace),可以暂时关闭协程来进行问题定位
注意: 并不是所有场景都适合使用协程来加速,比如CPU密集的场景就不适合。通过Wisp透明使用这一特点,我们轻松地选择是否使用Wisp,而不被绑定到一种实现上。
以一个pingpong 1,000,000次的程序为例,这是一个需要阻塞,切换密集型的应用。
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;
}
运行,查看执行时间:
$java PingPong
13212 ms
// 开启Wisp2
$java -XX:+UnlockExperimentalVMOptions -XX:+UseWisp2 -XX:ActiveProcessorCount=1 PingPong
882 ms
开启Wisp2后整体运行效率提升了近十多倍,其中发生了什么? 协程在同一个Carrier上,互相切换无需os介入进行调度,效率非常高。
在Java的历史上,J2EE规范为Java服务端的应用编程模型提供了指导性的建议,其中Servlet和JDBC等重要规范都是基于线程来提供并发能力的: Serverlet容器通常通过线程池来为多个请求并发提供服务,请求处理期间需要CRUD数据库则通过JDBC进行,JDBC访问需要阻塞线程,但是一般这个线程也是为Serverlet的单个请求提供服务,因此我们无需担心这种阻塞。
近几年来我们可以看到整个服务端的生态慢慢出现了异步化的转变,比如Servlet 3.0的异步支持、Vert.X都使用了异步技术。促进这些技术蓬勃发展的根本原因是互联网的高速发展催生出了对单机处理能力的极致要求,而线程阻塞模型通常受制于OS对线程的调度能力,而异步模型可以只启用少量线程,并在线程内部批量做不同请求的回调处理,尽量减少了OS在线程调度上的介入,可以获得较高的性能。
Java提供了nio,aio等底层IO基础设施,可以帮助我们以非阻塞事件驱动的方式去编写高性能的java服务端程序,在这种需求背景下,像Netty这样的优秀IO框架也应运而生,这给了我们新的编写纯异步服务的选择。在这一类框架下编程我们需要十分小心,通常IO事件的回调是发生在IO线程中的,假如不慎在回调中引入了阻塞调用,就会导致后续的IO事件无法及时被处理,影响应整个事件框架的正确运行。下面我们引入协程来减少编写异步程序的困难。
private void writeQuery(Channel ch) {
ch.write(Unpooled.wrappedBuffer("query".getBytes())).sync();
logger.info("write finish");
}
这里的sync()
会阻塞线程,不满足期望。由于netty本身是一个异步框架,我们引入回调:
private void writeQuery(Channel ch) {
ch.write(Unpooled.wrappedBuffer("query".getBytes()))
.addListener(f -> logger.info("write finish"));
}
这里仅仅只有一层回调。随着程序变得复杂,会引入过多层的回调、控制流翻转等问题,因此我们需要借助协程来减少这一类回调的编写。我们看一个kotlin协程帮助我们简化程序的例子:
suspend fun Channel.aWrite(msg: Any): Int =
suspendCoroutine { cont ->
write(msg).addListener { cont.resume(0) }
}
suspend fun writeQuery(ch: Channel) {
ch.aWrite(Unpooled.wrappedBuffer("query".toByteArray()))
logger.info("write finish")
}
这里引入了一个特殊的函数suspendCoroutine
,我们可以获得当前Continuation的引用,并执行一段代码,最后挂起当前协程。Continuation
代表了当前计算的延续,通过Continuation.resume()
我们可以恢复执行上下文。因此只需在写操作完成时回调cont.resume(0)
,我们又回到了suspendCoroutine
处的执行状态(包括完整的call stack),程序继续执行,代码返回,输出日志。从writeQuery
看我们用同步的写法完成了异步操作。当协程被suspendCoroutine
切换走后,线程可以继续调度其他可以执行的协程来执行。
从这里看,只需要我们有一个机制来保存/恢复执行上下文,并且在阻塞库函数里采用非阻塞+回调的方式让出/恢复协程,就可以使得以同步形式编写的程序达到和异步同样的效果了。
基于上述例子,我们可以看到需要做下面几件事情来使用协程:
- 使用suspend关键字标记需要支持yield的frame
- 引入一个异步框架,并使用回调接口封装出协程同步接口
基于JVM我们可以省略上述步骤:
- JVM清楚地知道stack的layout,也能对stack进行任意操作,因此所有现有的方法无需用状态机的机制重新编译就可以支持协程切换了
- 所有的阻塞函数(bio,ObjectMonitor)都通过JDK来调用,因此可以在JDK层面做出同步封装。
Wisp 就是基于JVM栈操作、结合Runtime对阻塞方法的支持以及加入调度器,来让应用无需改动地获得异步的性能。
Wisp基于达芬奇项目的JKU协程,是JVM原生的上下文切换方案,不会影响代码执行的效率,且栈上有任何方法时都能suspend协程,没有维护成本,对应用代码完全透明。
Loom作为未来的标准提出了VirtualThread API,让用户显式创建协程;而Wisp2选择了不提供API,而是为Thread提供了一套新的实现机制(标准并没有规定线程的实现方式,可以是pthread,也可以是用户态线程),从用户角度看是完全兼容现在的Java标准的。这个角度上两者的取向是不同的,原因也很简单:
- Loom: 共享栈,协程占用内存少。支持百万级协程的新编程模型。
- Wisp2: 独立栈,切换速度快。基于现有的编程模型,通过减少OS的介入以及高效的调度透明提升性能。
小结一下:
- Wisp2兼容现有的代码编写方式,无需添加注解或者重新编译
- Wisp2对java core libs的兼容较好,因为要做到全部转换
- Wisp2提供高效的协程切换、调度,因为性能是最大诉求
- Wisp2不打算支持海量的协程,因为目标是兼容现有的Java编程模型
Wisp2实现依赖了epoll专有特性,目前仅支持Linux操作系统。
- Carrier: 代码的实际执行者,对应一个操作系统提供的线程,同时包括调度所需的上下文信息(IO事件, 定时事件), 维护调度所需数据结构
- WispEngine: 一组Carrier组成的执行器,协程创建出来后会绑定到一个engine,由engine下的carrier交替执行
- Scheduler: 对应一个WispEngine,carrier之间的协作以及steal策略都由scheduler实现
- WorkStealing: carrier之间互相窃取任务以平衡队列长度
- SysMonitor: SysMonitor 负责监控每个Carrier上任务的执行状态,在长时间未发生切换时抢占协程
- 抢占: 让长时间执行CPU代码的协程主动yield出CPU
- Coroutine: JKU提供的协程,主要提供了切换的能力
- WispTask: 一个协程所需的调度结构,对应一个Coroutine。 一般是映射到一个Thread 对象
- EventPump: 事件源,一般是网络上的fd事件,在Linux下对应一个epoll eventloop
- 全转模式: 将所有线程转为协程
- Coroutine: 主要提供栈切换能力,基于JKU的开源项目
- WispEngine: 依赖Coroutine的切换能力,提供park/unpark/registerEvent/timer等接口供core library调用
- Runtime支持: 在JDK需要阻塞的标准库以及synchronize等需要阻塞的地方调用WispEngine的接口
Wisp实现内部包含一些线程:
- WispSysmon: 定时检查执行时间过长的协程
- WispDaemon: 在线程被转成协程后维持Java的daemon语义
- WispEventPump:用于监听是分发事件,在大部分情况下被继承到WispCarrier中
- WispCarrier: 主要的工作线程,调度并执行协程中的业务逻辑
carrier线程本身会执行不断从workQueue上拉取协程,并切换至该协程中执行,此时carrier被这个协程占据。当协程执行过程中需要挂起,会触发切换至carrier线程原先的上下文,继续触发调度。当carrier发现自身队列全空时会考虑steal任务,帮其他carrier减少压力。我们以一个读取jdbc的协程来举例:
- 协程读取jdbc driver底层的Socket,此时数据包尚未收到,协程需要等待,于是
- 将这个socket的读时间注册到eventPump
- 切换到carrier的逻辑,让出控制流
- carrier扫描队列,发现没有任务需要执行,将自己挂起,节约资源
- eventPump上的事件就绪,唤醒协程
- 将协程入队
- 向os唤醒carrier
- carrier从队列上拿到协程,继续读取socket,并执行后续的逻辑
通过上述方式,协程分片使用Carrier的计算资源。
使用 -XX:+UseWisp2
开启Wisp2后,提供了一种新的线程实现。用户代码继续使用Thread接口,而pthread(内核级线程)被替换成WispTask(用户态线程)。
我们实现了抢占机制来解决协作式调度无法及时让出CPU的问题。当一个协程执行时间过长:
- Sysmon检测到协程执行时间过长
- Sysmon标记carrier为需要抢占
- 触发一个safepoint
- carrier在safeppint结束前检查是否需要抢占,若需要抢占则主动插入一次yield,当前协程让出执行权
由于每个协程栈上的对象引用都需要被GC正确处理,因此协程是影响GC的,其中G1和CMS GC已经被大规模验证,但是在其他的垃圾回收器上并没有在生产环境中被验证。Wisp实现了GC的API接口,可以在GC时回收所有协程栈帧上的对象以及协程局部(coroutine local)的元数据,因此理论上支持所有种类的垃圾回收器。
对于Thread类,Wisp支持几乎所有的Thread API。比如ThreadLocal类在Wisp下在语义上也等同于“CoroutineLocal”。 由于Wisp协程的用户透明化,Thread API在实现中会被隐式转换,代码编写者不需要感知。
-
public static Thread currentThread()
:获取当前协程的线程包装 -
public StackTraceElement[] getStackTrace()
:支持抓取当前协程的栈 -
public static void yield()
:支持从当前协程让出控制权给下一个被调度的协程 -
public static void sleep(long millis) throws InterruptedException
:当前协程实例会sleep给定的时间,并让出控制权到调度器中的下一个协程 -
public void start()
:所有新开启的Thread在Wisp下都透明变成启动一个新的协程,其中的Thread.run()
方法会被转化为在一个协程当中运行。 -
public void interrupt()
:中断指定的协程的执行 -
public static boolean interrupted()
:查看当前协程是否被中断 -
public boolean isInterrupted()
:查看指定的协程是否被中断 -
public final void setDaemon(boolean on)
:支持将协程转化为daemon,语义和线程相同 -
public final boolean isDaemon()
:检查一个协程是否为daemon -
public final void setName(String name)
:设置协程的name -
public final String getName()
:得到协程的name -
public final boolean isAlive()
:查看协程是否存活 -
public final void join() throws InterruptedException
:支持join一个协程 -
public static boolean holdsLock(Object obj)
:查看一个ObjectMonitor是否被当前协程hold -
public long getId()
:得到一个协程的ID
暂时不支持的API:
-
public static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)
暂不支持 -
public void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)
暂不支持 -
public static Map<Thread,StackTraceElement[]> getAllStackTraces()
暂不支持获取所有协程的栈 -
public Thread.State getState()
:协程的状态没有同步到对应的Thread对象
对 ThreadLocal 类的所有使用,在Wisp下全是协程级别的隔离,现存代码正常使用即可。
public final void wait() throws InterruptedException
:在Wisp下会变成协程级别的wait。在此情况下,当前协程在停止过程中,控制权会被转交到调度器中的下一个协程。
public final void notify()
:唤醒等待在此对象上的一个协程
public final void notifyAll()
:唤醒等待在此对象上的所有协程
由于Wisp兼容了Thread API,因此JUC锁(如java.util.concurrent.locks.ReentrantLock
等)在Wisp下自动会转化为协程级别,用户可以正常使用 J.U.C 并发包下的类。
在虚拟机层,ObjectMonitor由线程级别对象锁变为协程级别对象锁,并完整保持了对象锁的语义。具体表现形式如下:
- 当一个ObjectMonitor由于获取不到锁而等待(park)时,Wisp会自动切换控制权到下一个被调度的协程对象,以减少等待时间。
- Wisp在解释器、编译器及Runtime内支持了协程级对象锁,支持协程在对象锁内被 work steal 及退优化等。
- 支持bio,NIO blocking模式的ServerSocketChannel.accept()以及Selector.select()
- 不支持NIO阻塞模式的connect/read/write,在netty、tomcat等框架下不会用以阻塞形式用到上述方法,因此没有在初期支持。近期会支持。
由于偏向锁内部实现的复杂性,且偏向锁会在未来被删除,目前wisp协程不支持偏向锁。在wisp模式下,偏向锁特性会被强制关闭。
假如用户代码阻塞在了JNI,这个阻塞点没有Wisp调度相关的逻辑,因此会阻塞整个carrier,导致大量协程没法被调度。在当今大部分IO都通过netty、JDBC、HttpClient来进行,因此很少会遇到自行编写阻塞JNI,但是有一些例外
- Netty: netty有一个native-transport模式,会直接阻塞在Epoll JNI,解决办法见 FAQ
- UNIXProcess: 使用Process接口创建子进程是,会阻塞在wait系统调用,解决办法见 全转黑名单
IO事件主要通过epoll来监听和分发,epoll不支持磁盘文件fd,因此这部分API没法支持协程,读写磁盘文件一旦阻塞,将会阻塞carrier。后续我们将考虑通过io_uring来提供这方面的支持。
由于现有业务场景大多采用4-16核心的虚拟机以及容器,因此Wisp针对16 核以及以下场景进行了深度的性能调优,当核数增多时(比如64核),会有大量资源浪费在调度上,这块是我们后续的优化目标。
-Dcom.alibaba.wisp.threadAsWisp.black 参数可以禁止一些线程被转换成协程,可以避免一些长期阻塞在JNI的线程影响协程调度,支持三种方式:
- 线程名字: 以name:开头,支持wildcard通配符,例如 -Dcom.alibaba.wisp.threadAsWisp.black=name:biz-thread-*
- 包名: 以package开头,不支持统配
- 类名: 以class开头,根据Thread对象的类型或者Runnable对象的类型匹配
支持组合使用,线程满足任一条件便不会被转成协程。以;分隔,例如 -Dcom.alibaba.wisp.threadAsWisp.black=name:biz-thread-*;class=a.b.Foo;packge=a.b
注意: “;"是shell的特殊字符,需要转义或者加引号
在CPU主频为2.5GHz的物理机下经测试下,平均1s可以切换协程1400万次。
以一个典型的场景,8核心机器,2000qps,每个请求10次RPC来计算,每核每秒需要切换20000 * 2 * 2 / 8 = 10000次,占用CPU约1/1400,不到千分之一。
Techempower Web Framework Benchmarks是web server领域的权威测试框架,Wisp主要优化的是IO密集场景,因此我们以这个框架进行性能验证。获得了10~30%的吞吐量提升,详细情况可以查看 http://dragonwell-jdk.io/ 的Performance章节。
- 在复杂的业务应用中(tomcat + 大量基于netty的中间件)我们获得了大约10%的性能提升。
- 在中间件应用(数据库代理,MQ)中我们获得大约20%的性能提升。
Wisp已经经历了4年的研发迭代,在生产环境长期有大约10w的实例在运行,并且长期处于活跃开发状态,因此拥有较高的稳定性。
Q: 协程也有调度,为什么开销小 A: 我们一直强调了协程适用于IO密集的场景,这就意味了通常任务执行一小段时间就会阻塞等待IO,随后进行调度。这种情况下只要系统的CPU没有完全打满,使用简单的先进先出调度策略基本都能保证一个比较公平的调度。同时,我们使用了完全无锁的调度实现,使得调度开销相对内核大大减少。
Q: Netty的native-transport阻塞了协程调度
A: 也可以使用-Dio.netty.noUnsafe=true
, 其他unsafe功能可能会受影响。对于netty 4.1.25以上,支持了通过-Dio.netty.transport.noNative=true
来仅关闭jni epoll,参见358249e5
Q: Wisp2为什么不使用ForkJoinPool来调度协程
A: ForkJoinPool本身十分优秀,但是不太适合Wisp2的场景。
为了便于理解,我们可以将一次协程唤醒看做一个Executor.execute()
操作,ForkJoinPool
虽然支持任务窃取,但是execute()操作是随机或者本线程队列操作(取决于是否异步模式)的,这将导致协程在哪个线程被唤醒的行为也很随机。
在Wisp底层,一次steal的代价是有点大的,因此我们需要一个affinity,让协程尽量保持绑定在固定线程,只有线程忙的情况下才发生workstealing。我们实现了自己的steal实现来支持这个特性。从调度开销/延迟等各项指标来看,基本能和ForkJoinPool持平。
jstack工具已经支持了协程,格式如下
- Coroutine [0x7f227dae47e0] "http-nio--exec-5" #643 active=2394347 steal=378779 steal_fail=968 preempt=0 park=0/-1
- "http-nio--exec-5" 里面显示的是被转成协程的线程名字
- active表示协程被调度的次数
- steal表示work stealing发生的次数
- preempt 表示抢占的次数
- 然后下方会展示这个协程当前的栈
Alibaba Dragonwell