Kilim是一个Java的actor框架,让你可以在JVM里使用基于协程的actor模型,bluedavy曾经介绍过,这里不再赘言。这篇blog的目的在于分析下kilim实现的基本原理,看看怎么在JVM上实现协程。
在一些语言层面上支持协程的语言,如lua、ruby,都是直接在VM级别支持协程,VM帮你做context的保存和恢复。JVM没有提供这样的指令来保存和恢复方法栈的状态,因此kilim的实现还是需要在bytecode级别做文章。首先,试想下,如果是你来实现协程,你会怎么做?协程的两个基本原语resume和yield,resume运行协程,yield让出执行权,下次resume的时候会从yield的地方重新执行,并且context保持不变。可见,你需要做这么几个事情:
1、在yield的时候保存当前context。
2、在resume的时候恢复context,并根据pc计数来决定从哪里恢复执行。
3、半协程的实现来说,还需要一个调度器来调度所有协程。
4、为了做到用户代码透明,可能需要某种手段去修改用户代码,自动帮你做上面三个事情。
kilim的实现就是干了这么几个事情:
1、利用字节码增强,将普通的java代码转换为支持协程的代码。
2、在调用pausable方法的时候,如果pause了就保存当前方法栈的State,停止执行当前协程,将控制权交给调度器
3、调度器负责调度就绪的协程
4、协程resume的时候,自动恢复State,根据协程的pc计数跳转到上次执行的位置,继续执行。
下面是来自kilim文档的一个例子,同一段代码,在字节码增前前后的变化:
左边是原始代码,右边是通过字节码增强后的代码。其中h方法是pausable的,也就是说可能被暂停阻塞的,g方法因为调用了h这个方法也变成了pausable。
首先看,原始的h方法是没有传入任何参数的,增强后的代码,多了个参数Fiber,指向当前的协程,同样,g方法本来只有一个参数n,现在在后面也多了个Fiber类型的参数,同样是指向当前执行的协程。
其次,在原始的g方法里,一旦调用,马上进入一个for循环。但是在增强后的代码,多了个switch派发的过程,这就是前面提到的,根据当前的 Fiber的pc计数,跳转到上一次执行的地方执行。如果是第一次resume,也就是启动协程,那么就将初始循环的i设置为0,进入原始代码的循环部分。Fiber有一个pc计数,称为程序计数器,用于指向恢复context的时候需要跳转到位置。
第三,在g方法里调用h这个可被暂停阻塞的方法的时候,在h方法前后多了一些调用:
f.down(); h(f); f.up();
kilim的Fiber将每个pauseable方法的调用组织成一个栈,每个pauseable方法都有一个activation
frame,翻译过来可以称为活动栈帧,这个栈帧记录了当前的栈的State,注意这个栈跟java本身的方法调用栈区分开来,一个是VM层面的,一个是
kilim框架层面的。这里的down方法就是将栈向下延伸,表示将调用一个pauseable方法,并且设置当前State和pc计数。
调用了down之后,才是调用实际的h方法,最后还要调用一次up,顾名思义,就是说一次pauseable方法调用完成,fiber的活动栈要递增一层,回到上一层。但是h方法调用可能出现四种情况:
1、正常的顺利返回,没有状态需要恢复,所谓NOT_PAUSINGNO_STATE
2、也是正常返回,有状态需要恢复,也就是NOT_PAUSINGHAS_STATE
3、h方法暂停阻塞,当前没有保存状态,需要保存状态,这是第一次暂停的时候,称为PAUSINGNO_STATE
4、h方法暂停阻塞,当前已经有状态,不需要保存状态,这是第一次暂停之后的resume再次暂停,称为PAUSINGHAS_STATE,通常不需要处理什么。
第四,可以看到,在up之后,就要根据up返回的上述4种状态执行不同的逻辑:
if (f.isPausing){ //第一次暂停,没有状态 if (!f.hasState){ //new一个State_I2,并保存i和n f.state = new State_I2(i,n); //记录pc,还记的前面的switch吗? f.pc = H1; } return; } else if (f.hasState) //正常返回,有状态需要恢复,恢复i和n State_I2 st = (State_I2) f.state; i = st.i1; n = st.i2; }
这里没有处理NOT_PAUSINGNO_STATE和PAUSINGHAS_STATE,因为这两种情况在这里不需要处理。
通过上面的分析,我想大家对kilim的实现应该已经有一个很基本的认识。下一步,我们分析一个实际的代码例子,查看整个运作流程。