八月即将过去,又快开学了,小小感慨下。
本月,最值得欣慰的就是XETN服务器能够用ab测试了,单进程下RPS差不多是nodejs的4倍,当然离预期依旧很遥远,也就只能欺负欺负node了。
开发的时候异步IO带来的EAGAIN甚是讨厌,而攻克这个难题的关键就是协程,之前写过关于Fiber的文章,只不过它并不是那么灵活,出于对内部机制的好奇,我准备再造一个轮子,自己实现协程。
本文先介绍setjmp和longjmp,它是实现协程的核心。不过通常情况下,大家只是用它来实现C的异常机制。
0x00 什么是jmp
想必各位一定都知道goto,也被灌输过诸如“goto是邪恶的”之类的偏见。setjmp和longjmp是加强版的goto,能够做到跨函数,由于涉及到栈的问题,它远比goto更加“邪恶”。
这里的“邪恶”是有原因的,如果你了解细节,那么你便更进一步;如果不了解,那么可能今后也只能做些告诫后生goto很“邪恶”的事,对此深信不疑,并觉得自豪。
那么,为何goto只是一个关键字,而setjmp和longjmp却成了两个函数?别急,这便是本文的内容。。。
0x01 使用方式
先是函数原型:
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
由于寄存器的存在,在跳转之前,我们需要先将当前跳转点的寄存器内存保存下来,它们的存储位置就是jmp_buf。至少PC的内容总得保存吧,各位说是不?不过由于并不能直接操作PC寄存器,因此这里使用setjmp和longjmp来解决这个问题,思想和动态链接时get_pc_thunk一样。
由于jmp_buf保存的就是跳转点的寄存器内容,因此我们当前可以简单地认为它就表示我们希望跳转的目的地。
在默认情况下,setjmp返回0,表示初始jmp_buf,当使用longjmp跳转的时候,setjmp返回的就是val参数的内容,因此不要蠢蠢地将val设置成0。写到这里不知各位是不是有点好奇这个是怎么做到的?为何返回的内容和val有关?依旧是那句话,不急,接下来会介绍。
来一个示例:
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void foo() {
printf("before jmp\n");
int ret = setjmp(env);
if(ret == 0) {
return;
} else {
printf("return %d\n", ret);
}
printf("after jmp\n");
}
int main() {
foo();
longjmp(env, 999);
return 0;
}
和结果比对下,看看你是否猜对了。
before jmp
return 999
after jmp
为了说明它俩的“邪恶”,并以此引出栈的问题,我们稍微修改下代码,给大家来个“可爱”的SegmentFault。
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void foo() {
printf("before jmp\n");
int ret = setjmp(env);
if(ret == 0) {
return;
} else {
printf("return %d\n", ret);
}
printf("after jmp\n");
}
int main(int argc, char* argv[]) {
foo();
longjmp(env, 999);
return 0;
}
和之前的例子并没有什么不同啊,但这段代码却会“吐核”的。问题就在于,大家并没有很好地理解栈的概念,jmp_buf保存的可不是完整的上下文,只是寄存器的内容(由于种种原因,SP的保存有问题),那么栈里的内容怎么办?因为它俩使用的可不是什么正儿八经的call和ret,而是直接通过恢复PC来实现跳转,此时栈的内容早已面目全非,main尚未结束,就直接把它给弹了,关键是人家的参数和你不一致啊,让.finit如何是好?
那么我们该如何正确使用setjmp和longjmp呢?我的建议是让整个longjmp串起来最终回到最初调用的那个函数。
比如上面的例子可以这样修改一下:
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
jmp_buf ctx;
void foo() {
printf("before jmp\n");
int ret = setjmp(env);
if(ret == 0) {
return;
} else {
printf("return %d\n", ret);
}
printf("after jmp\n");
}
int main(int argc, char* argv[]) {
foo();
if(setjmp(ctx) == 0) {
longjmp(env, 999);
}
return 0;
}
如此一来,我们就能够确保栈的内容和跳转前一致了,只不过这样用起来实在是略“丑”,不是吗?
顺便说下,不要妄想封装setjmp和longjmp,没用的。
0x02 setjmp的实现
重点来了,setjmp是如何实现的,这里我们选用的是state-threads中的setjmp实现,和glibc的差不多,只是重新定义了jmp_buf,这里给出的是i386的汇编,比较简单:
// jmp_buf中各寄存器的定位
#define JB_BX 0
#define JB_SI 1
#define JB_DI 2
#define JB_BP 3
#define JB_SP 4
#define JB_PC 5
// 符号定义,看不懂的略过即可
/* _st_setjmp(__jmp_buf env) */
.globl _st_setjmp
.type st_setjmp, @function
.align 16
_st_setjmp:
// 获取jmp_buf的地址
movl 4(%esp), %eax
// 保存ebx,esi,edi
movl %ebx, (JB_BX*4)(%eax)
movl %esi, (JB_SI*4)(%eax)
movl %edi, (JB_DI*4)(%eax)
// 保存esp
leal 4(%esp), %ecx
movl %ecx, (JB_SP*4)(%eax)
// 保存返回地址,即调用处的PC
movl 0(%esp), %ecx
movl %ecx, (JB_PC*4)(%eax)
// 保存ebp,调用者的ebp
movl %ebp, (JB_BP*4)(%eax)
// 返回0,通过eax返回
xorl %eax, %eax
ret
.size md_cxt_save, .-md_cxt_save
通常,我们在进入一个新的函数后,会保存调用者的ebp,即如下代码:
pushl %ebp
movl %esp, %ebp
这里并不需要,因为setjmp的作用仅仅是获取调用处的PC及其关键寄存器的内容,并保存到jmp_buf。
0x03 longjmp的实现
同样来自state-threads,上汇编:
/* _st_longjmp(__jmp_buf env, int val) */
.globl _st_longjmp
.type _st_longjmp, @function
.align 16
_st_longjmp:
// 获取jmp_buf的地址
movl 4(%esp), %ecx
// 获取val的值
movl 8(%esp), %eax
// 设置返回地址
movl (JB_PC*4)(%ecx), %edx
// 恢复ebx,esi,edi,ebp,esp
movl (JB_BX*4)(%ecx), %ebx
movl (JB_SI*4)(%ecx), %esi
movl (JB_DI*4)(%ecx), %edi
movl (JB_BP*4)(%ecx), %ebp
movl (JB_SP*4)(%ecx), %esp
// 确保val不为0
testl %eax, %eax
jnz 1f
incl %eax
// 跳转到PC,即返回调用现场
1: jmp *%edx
.size _st_md_cxt_restore, .-_st_md_cxt_restore
eax作为返回值,实现了上面我们所说的通过val来确定返回值的机制,是不是比较好玩?
0x04 缺点及改进
为何st会选择自己实现一套setjmp和longjmp?因为自glibc2.4开始jmp_buf中的esp操作会有点问题,何况自己实现一套也很简单,除了获取PC,其余都是常用的现场保护及还原的概念。
如果你认为光靠setjmp和longjmp就能实现协程,那就错了,由于jmp_buf仅仅保存寄存器的内容,而栈又是共用的,那么栈被破坏的情况就难以避免,更加“邪恶”的是它还会乱弹栈。。。
为此我们需要实现一个完整的上下文保存机制,确保函数的栈和寄存器内容都被恰当保存,最好是在heap中,以支持海量的协程。同时还必须通过一定的设计以及巧妙的封装来保证一定的调用顺序。
0xFF 写在最后
很多时候,大家总是说“don't repeat the wheel”,但是技术的演进是有先后顺序的,这就是为何core space的开发者可以轻轻松松写user space的程序,而user space的开发者却觉得写个内核难如登天,毕竟缺少了一段必须的经历,正如开发者总会假设用户是傻子,只看界面不看内部实现。
阅读代码能够让我们获取更多奇技淫巧,而阅读后造个轮子,则可能为下一个程序提供基础。
一切的程序开发领域的问题都可以通过添加中间层来解决,不知各位是否认同这句话,关键就看你是在中间层之上还是之下,我希望自己位于这个中间层之下,同时希望这个中间层不是柏林墙。