How to implement fiber

來源:互聯網
上載者:User
文章目錄
  • Source Code
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,我們只能寫彙編原始碼。

__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    }}

這裡在儲存調用者的esp時,我進行了一些修正,以得到調用fiber_get_context之間的值(這個值包括被壓棧的參數)。在x86上,當調用一個函數時,調用者與被調用者棧的布局是這樣的

所以,要拿到傳回值,只需要將ebp加上4即可。但是由於我們是一個naked function,所以,這裡我們只能通過esp來取值,考慮壓棧的eax, ebx,對esp加上8,即可得到返回地址。

fiber_set_context

該函數用於切換到另一個context,調用該函數後,會直接切換到新的fiber,而控制流程不會返回,所以我們可以不用考慮棧的使用方式,而只需簡單回複即可。 

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    }}

這個函數看上去比較簡單,只是簡單地恢複寄存器的值,並設定eip指標。

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.