Introduction
我们知道线程是程序执行的一个最基本的单位,任何程序的执行,都依赖于线程的执行。而线程通常是操作系统的基本组成,通常创建一个线程,比如在Win32上,用CreateThread创建一个线程,操作系统实际会创建2个对象,一个是用户态的线程,另一个是内核态的线程,而我们的代码运行在用户态线程。当线程切换时,比如调用WaitForSingleObject,或者调用WriteFile执行阻塞IO时,通常会涉及到线程的切换,而切换需要进入到内核态,由此带来的开销是比较可观的。而Fiber,也就是纤程,完全运行在用户态,各个线程的切换也只在用户态完成,所以切换开销较小。线程的调度,通常是由操作系统的线程调度器完成,在现代OS中,通常使用抢占式调度策略。而纤程的调用,完全依赖于程序员自己,即实现一种合作式调度,只有在主动提出切换时,才会进行切换。
纤程,在其他的语言,比如Python、Lua中都有实现。Python中提供了generate,可以用来简单地模拟纤程,而另外一个强大的greenlet库,则是Python中纤程的另一实现。在Lua中,我们可以使用内置的coroutine库。
Basic Usage
Win32
在Win32中,我们可以使用ConvertThreadToFiber/ConvertFiberToThread,CreateFiber/DeleteFiber来管理纤程的创建与销毁。对于一个线程,如果要调用纤程,那么必须调用Convert,将一个线程转化为纤程。当需要切换到另一个纤程时,只需要调用SwitchToFiber,这样现有的线程的一些状态将被保存,等待以后恢复执行。
Linux
Linux中有context api,可以用来完成类似的功能。我们调用getcontext/makecontext,来初始化纤程,用swapcontext/setcontext来切换纤程。
setjmp/longjmp
说到这里,我们不得不提起另一个看似可以用来完成类似工作的古老的C API,setjmp/longjmp。setjmp用来保存当前的执行环境,longjmp用来还原上次的执行,这样可以实现non-local goto的功能。但是这里有一个问题,就是当我们调用longjmp回到setjmp保存的状态继续执行时,如果longjmp的调用者与setjmp的调用者不是同一个函数,那么longjmp所在的栈的状态将是undefined[1]。也就是说,我们不能通过在longjmp之前,save状态,之后再longjmp回来,这是未定义的行为。当然,在win32上,你可以这么做,而且还工作地很好(我在实现中使用了这一技巧)。但是在linux上,你将会遇到运行时错误,提示stack smashing(gcc的stack保护机制,__fortify_fail),也就是栈被破坏了(这令我debug了很长时间,最后放弃,网上看到许多实现用这方法,但是不奏效)。setjmp/longjmp还有其他陷阱,比如,在win32上,他不会保存/恢复SEH异常链,等等。
How Fiber works
其实无论是哪种方法,我们只需要明白Fiber是如果工作的,那么就可以实现出自己的fiber来(当然这里还需要考虑其他一些CPU相关的情况)。
Fibe类似于线程,都有一个栈用来保存当前的调用所需的状态。所以我们首先需要为fiber创建一个栈。其次由于每个fiber肯定需要一个入口函数(就像线程一样),在切换时,需要进入到这个入口,然后执行。其实代码的执行在x86 CPU上,就是修改EIP指针,将其指向这个入口函数即可。在切换纤程时,也就是保存我们的栈的状态,x86上,ESP和EBP是两个重要的寄存器,保存了当前的栈的状态。我们还需要保存其他的通用寄存器,EBX、EDI、ESI,因为不同纤程显然会修改寄存器。这里不保存其他3个寄存器:EAX、ECX、EDX的原因是,这些寄存器都是caller-save的[4],也就是说,如果调用者使用了这些寄存器,那么在调用其他函数前,必须先保存这些寄存器。
这里涉及到两个问题,一个是修改EIP指针,另一个是保存/恢复寄存器值。
Modify EIP
因为EIP指针只有在特权模式才能够修改(操作系统工作在特权模式),我们用户态程序是无法直接修改的。但是我们知道,jmp指令时可以间接修改EIP的。还有另一个方法是用ret指令,ret指令会从栈顶取出值作为EIP的值,这样就实现了跳转。这里我选择使用push + ret的方式来修改eip,因为jmp要求使用相对于下一条指令的偏移作为操作数(Relative jumpping)。
Save/Restore GPR(general-purpose registers)
保存和回复GPR比较简单,用几条汇编指令即可完成。
Save/Restore Stack Pointer
在Save/Restore EBP/ESP时,需要格外小心,因为一旦我们修改了ESP,那么在之后所有的对栈的操作都将在新的栈上进行。
Implementation
先来看一下fiber_context的成员,这个struct用来保存寄存器和栈的指针/大小等。
struct fiber_context
{
#ifdef FIBER_X86
// registers
uintptr_t ebp;
uintptr_t esp;
uintptr_t eip;
// callee-save general-purpose registers
// see http://www.agner.org/optimize/calling_conventions.pdf
uintptr_t ebx;
uintptr_t esi;
uintptr_t edi;
#else
# error Unsupported platform
#endif
char* stack;
int stack_size;
void* userarg;
};
所有callee-save的GPR,以及ebp/esp/eip都会被保存。这个比较简单。
fiber_make_context
这个函数用来创建一个context,初始化执行环境。
void fiber_make_context(fiber_context* context, fiber_entry entry, void* arg)
{
assert(context && entry);
// default alignment of the stack
const int alignment = 16;
context->esp = (reinterpret_cast<uintptr_t>(context->stack) + context->stack_size) & ~(alignment - 1);
context->ebp = context->esp;
context->eip = reinterpret_cast<uintptr_t>(entry);
context->userarg = arg;
// push the argument onto the stack
char* top = reinterpret_cast<char*>(context->esp);
memcpy(top, &context->userarg, sizeof(void*));
// make space for the pushed argument
context->esp -= sizeof(void*);
// clear all callee-save general purpose registers
context->ebx = context->esi = context->edi = 0;
}
这里,我们初始化了esp,和ebp,同时指向栈顶。注意,x86 CPU使用full-descending stack,也就是逆向增长的栈。因为用户可以提供一个可选参数,所以我们必须事先将这个参数压栈,这样在将EIP重定向后,入口函数就可以正常存取这个参数了。自然,压栈后,我们必须减小esp的值,为参数留出空间。EIP的值指向入口函数的地址,这个很容易理解。其余部分只是简单地初始化GPR的默认值。
fiber_get_context
该函数用来保存当前的执行状态。因为我们可以通过fiber_set_context来从一个由fiber_get_context初始化的context中恢复执行,这里必须考虑这种特殊情况。我们不能简单地保存esp/ebp的值,因为一旦我们从fiber_get_context返回,该函数的stack frame将会被销毁,这样如果保存的esp/ebp指向的是fiber_get_context的frame的话,那么很显然会出现运行时错误。我们唯一能够做的就是,保存调用者的栈。
对于每一个c/c++函数,编译器都会在入口处安插指令来保存调用者的ebp,并且修改ebp/esp来创建新的stack frame。所以我们不能写一个普通函数来完成这个工作。我们需要一种方法,不让编译器生成prolog/epilog,这样我们就有更多的控制权。在VC中,我们可以使用naked函数,在GCC,我们只能写汇编源代码。
这里在保存调用者的esp时,我进行了一些修正,以得到调用fiber_get_context之间的值(这个值包括被压栈的参数)。在x86上,当调用一个函数时,调用者与被调用者栈的布局是这样的__declspec(naked) void fiber_get_context(fiber_context* context) { // TODO: how much space need to reserve for assert ? //assert(context); // save the current context in `context' and return __asm { // save current stack pointer to context mov ecx, dword ptr [esp + 0x4] ; fixup, point to the argument, ignore return address mov dword ptr [ecx], ebp ; context->ebp mov eax, esp // fixup esp, ignore return address, as the eip is set to the caller's address add eax, 0x4 mov dword ptr [ecx + 0x4], eax ; context->esp mov eax, dword ptr [esp] mov dword ptr [ecx + 0x8], eax ; context->eip // save callee-save general-purpose registers mov dword ptr [ecx + 0xc], ebx; context->ebx mov dword ptr [ecx + 0x10], esi; context->esi mov dword ptr [ecx + 0x14], edi; context->edi ret } }
所以,要拿到返回值,只需要将ebp加上4即可。但是由于我们是一个naked function,所以,这里我们只能通过esp来取值,考虑压栈的eax, ebx,对esp加上8,即可得到返回地址。
fiber_set_context
该函数用于切换到另一个context,调用该函数后,会直接切换到新的fiber,而控制流不会返回,所以我们可以不用考虑栈的使用情况,而只需简单回复即可。
这个函数看上去比较简单,只是简单地恢复寄存器的值,并设置eip指针。void fiber_set_context(fiber_context* context) { __asm { ; restore the enviroment for context mov eax, context mov ebp, dword ptr [eax] ; context->ebp mov esp, dword ptr [eax + 0x4] ; context->esp ; restore callee-save general-purpose registers mov ebx, dword ptr [eax + 0xc] ; context->ebx mov edx, dword ptr [eax + 0x10] ; context->edx mov esi, dword ptr [eax + 0x14] ; context->esi mov edi, dword ptr [eax + 0x18] ; context->edi push dword ptr [eax + 0x8] ; context->eip ret ; should never return here } }
fiber_swap_context
这个函数会保存当前的context,并切换到新的context。这个函数可以用fiber_get_context/fiber_set_context来实现。
void fiber_swap_context(fiber_context* oldcontext, fiber_context* newcontext)
{
assert(oldcontext && newcontext);
// save the current context in the oldcontext and set the current context from newcontext
__asm
{
push oldcontext
call fiber_get_context
// fixup oldcontext->esp, ignore pushed arguments, since we'll resume from restore
mov eax, oldcontext
add dword ptr[eax + 0x4], 0x4
// fixup return address
mov dword ptr[eax + 0x8], offset restore
// switch to newcontext
push newcontext
call fiber_set_context
restore:
}
}
在保存当前执行环境时需要注意一点,因为我们调用fiber_get_context时save的eip指针的值应该是call的下一条指令的地址,在这里就是push eax,但是我们不希望这样,因为随后我们就会调用fiber_set_context。所以我们必须修正EIP,以跳过对fiber_set_context的调用。这里还有一点需要注意,那就是esp的值,由于我们调用fiber_get_context手动对参数进行压栈,所以在从restore恢复执行时,esp的值还包含oldcontext这个参数。但是restore之后我们就直接返回了,所以我们必须修正esp的值,减去压栈的oldcontext(其实这里理论上不用修正esp,因为在函数的epilog中,会自动将ebp赋值给esp,所以修正没有意义,但是由于VC会在函数最后安插栈完整性的检查代码,所以为了防止这个错误,必须修正)。
TODO
这里我们只保存了GPRs,没有对其他的寄存器,比如浮点控制寄存器等进行保存。
Summary
在无法大量创建线程的环境中,纤程提供了一定的解决方案,因为纤程更加轻量,从而可以实现更高的并发性。
Source Code
代码存放在github上便于下载,git url为git://github.com/alexshen/fiber.git,网页地址为https://github.com/alexshen/fiber
Reference
[1] setjmp.h Wikipeida
[2] qemu coroutine
[3] Gcc Inline Assembly
[4]Calling conventions for different C++ compilers and operating systems