基於彙編的 C/C++ 協程 - 切換上下文

在前一篇文章《基於彙編的 C/C++ 協程 - 背景知識》中提到一個用於 C/C++ 的協程所須要實現的兩大功能:html

  1. 協程調度
  2. 上下文切換

其中調度,其實在技術實現上與其餘的線程、進程調度沒有什麼特別的差別,同時也要看具體業務的需求。限制 C/C++ 協程應用的最大技術條件是上下文切換。理由在前文也說了。linux

既然本系列講的是基於彙編的 C/C++ 協程,那麼這篇文章咱們就來說講使用匯編來進行上下文切換的原理。git

本文地址:http://www.javashuo.com/article/p-pqrzzlzl-da.htmlgithub

參考資料

上下文切換的具體內容

首先咱們須要明白上下文切換具體須要作什麼工做。我想,看這篇文章的讀者應該對編譯原理和操做系統基礎知識已經有必定的基礎了吧?小程序

協程的切換要作的事情,和進程的切換,實際上是差很少的。這裏咱們將本文涉及的要點提一下:segmentfault

進程的建立和刪除

當進程開始執行、以及進程執行結束的時候,操做系統還有別的工做:微信

  1. 當進程開始,操做系統要找到進程的入口,而且配置好上下文,而後將 CPU 交給進程
  2. 若是進程執行結束,則銷燬進程資源,並正確返回到調用方(好比父進程)

進程調度時的上下文切換

當觸發進程切換時(不管是進程調用阻塞的系統調用,可是操做系統主動觸發 schedule),操做系統要作如下的幾件事情:架構

  1. 奪取 CPU 使用權
  2. 保存當前用戶進程的上下文
  3. 調用調度函數,找到下一個應當佔用 CPU 時間片的進程
  4. 恢復下一個進程的上下文
  5. 將 CPU 交回給待繼續的進程

示例代碼

沒有調查就沒有發言權,沒有實驗也就沒有講解權。實際上本人已經有實現的代碼了。後文就以個人代碼爲脈絡來講明。oracle

相關說明:異步

  • 代碼只支持 x86_64 或 x64 架構。
  • 原來我打算繼續開發下去,支持 i386 的;不事後來放棄了,由於我看到了已經用於大規模應用於微信的協程庫 libco——這個我在之後的文章會講。

協程的建立和執行

程序入口參見 main.cpp 文件的第 67 至 91 行,_true_main() 函數。

建立協程

建立協程使用的是 AMCCoroutineAdd() 函數,函數定義在這裏。能夠參照 struct _CoroutineInfo 結構體。

要執行協程,咱們須要爲協程做如下準備:

分配棧空間

協程執行起來就像進程同樣,須要有堆棧來實現函數調用。線程的堆棧是由操做系統分配的;協程因爲工做在用戶態,所以只能由咱們寫代碼分配了。

在個人代碼中,棧空間使用 mmap() 分配。固然也可使用 malloc()——libco 就是這麼作的。

棧空間的使用,是經過向棧寄存器直接賦值來實現的。這在後面再講。

定位協程函數出入口

協程函數入口其實就是提供的協程函數自己,所以咱們只須要直接將函數的地址直接保存下來就好了。

可是協程出口就比較複雜了。協程執行到出口位置時(也就是協程函數的 return 語句)即表明協程結束。此時協程庫應該可以正確捕捉而且記錄下協程結束的狀態,而且正確的切換到下一個應當被切換的堆棧。

被切換至的堆棧,多是另外一個協程,也有多是協程庫的調用線程。

這一段代碼我使用太重定向協程函數返回地址來實現的,須要搭配彙編使用。能夠參見代碼_coroutine_did_end() 函數。該函數在協程初始化的時候,保存在了 func_ret_addr 成員變量中。

請注意這個變量在結構體中的偏移值:64,下文的 asm_amc_coroutine_enter() 彙編函數就用上了。

CPU 寄存器保存區

當切換協程時,須要切換函數的上下文。切換上下文也稱爲 「保存現場」 和 「恢復現場」。所謂的 「現場」,其實就是必要的 CPU 寄存器值,這些寄存器裏就已經包含了協程的堆棧。

參考資料用戶態調度要保存些什麼中就說明了在 GCC 程序中,須要保存的寄存器內容(x86_64 / x64):

  • rsp:棧指針,指向棧頂,也就是下一個可用的棧地址。
  • rbp:棧基址指針,與 rsp 配合使用。在不少小程序裏面常常是 0,但咱們必須保存它。
  • rbx, r12 - r15:數據寄存器,也是必須保存的現場之一。
  • rip:程序運行的下一個指令地址。這是計算機執行程序的基礎。

線程調用保存的環境更多,不過做爲協程,咱們只須要保存上面這些寄存器就夠了。


啓動協程

啓動線程的入口是 AMCCoroutineRun() 函數。函數的基本邏輯以下:

保存主線程的現場

asm_amc_coroutine_dump(g_pMainThreadInfo);  // dump main thread again to get return point of this function.
g_pMainThreadInfo->reg_rsp += 1 * sizeof(uint64_t);     // ignore return address for function "asm_amc_coroutine_dump"

協程要求單線程執行。本文所謂的主線程,指的就是啓動協程的線程。這兩句的邏輯以下:

  1. 首先 asm_amc_coroutine_dump() 將主線程的上下文保存在一個全局變量中
  2. 第二句將堆棧指針移動了一個單位,效果上就是忽略了在函數 asm_amc_coroutine_dump() 中保存的函數返回地址,使得全局變量中保存的是 AMCCoroutineRun() 的返回地址。

切換到待調用的協程上下文中

調用匯編函數 asm_amc_coroutine_enter(),直接進入協程。函數很簡單:

asm_amc_coroutine_enter:
    movq (%rdi), %rbx
    movq 8(%rdi), %rsp
    movq 16(%rdi), %rbp
    push 64(%rdi)        # create a function return point
    jmp 56(%rdi)

五句命令的含義分別是:

  1. 拷貝主線程的 rbx 寄存器值給協程——實際上這一句我不太懂,求高人指教。
  2. 重定向堆棧地址——這個堆棧,會在進入協程函數後才使用到。
  3. 重定向堆棧基址——一樣地,進入協程函數後才使用到,因此這裏不影響程序執行。
  4. 這就是前文提到的 func_ret_addr 成員,將這個地址壓入堆棧,使得協程函數結束時即進入相應的函數中,這樣咱們就能夠檢測到一個協程已經執行完畢了。而因爲協程是單線程運行的,所以咱們可使用全局變量判斷出剛剛結束的是哪個協程。
  5. 強制跳轉到協程的入口處開始執行。

前文不是說了一大堆須要保存的上下文嗎,爲何這裏賦值的寄存器那麼少?很簡單,協程尚未開始執行呢,那些寄存器都不用恢復,讓協程直接用就好了。

注意,這個函數其實是不會返回的。返回到主線程的工做已經交給了被重定向了的 _coroutine_did_end() 函數來完成。

協程的切換

獲取 CPU 使用權

當切換協程時,調度函數須要獲取 CPU 使用權,其實很簡單:只是要求協程程序本身主動調用相關的函數,從而達到交出 CPU 使用權的目的。

參見 main.cpp 文件的第 33 至 62 行。這裏定義了兩個如出一轍的函數,至關於兩個協程

做爲 demo 程序,這裏協程只調用了一個函數 AMCCoroutineSchedule() 提請切換協程。

保存協程現場

這裏調用的是彙編函數 asm_amc_coroutine_dump()。實際上這個函數在前面保存主線程現場中已經使用過了,這裏咱們再詳細說明一下函數的實現:

asm_amc_coroutine_dump:
    movq %rbx, (%rdi)
    movq %rsp, 8(%rdi)
    movq %rbp, 16(%rdi)
    movq %r12, 24(%rdi)
    movq %r13, 32(%rdi)
    movq %r14, 40(%rdi)
    movq %r15, 48(%rdi)
    movq 16(%rsp), %rsi
    movq %rsi, 56(%rdi)
    retq

除了標號以外的最前面的七行很好理解,就是將必要的現場保存起來。至於倒數第2、三行的 movq 16(%rsp), %rsimovq %rsi, 56(%rdi) 就很回味無窮啦。

寄存器 rsi 在 GCC 中是做爲第二參數使用的。這個函數中沒有第二個參數,所以就只是做爲臨時變量而已。16(%rsp) 這一句,和前文中 「保存主線程的現場」 中的第二句代碼的做用殊途同歸。

另外,協程上下文的保存,還包含函數外面的一句 C 代碼:

g_pCurrentCoroutine->reg_rip = (uint64_t)(&&RETURN);

這句話把被切換掉的協程恢復的現場重定向爲 AMCCoroutineSchedule()return 語句。效果是跳過了下面的 asm_amc_coroutine_restore() 函數,避免重複調度。

調度

本 demo 中沒有實質性的調度,只是輪詢而已,找到協程鏈上的下一個協程並執行。

恢復下一個協程的上下文並交出 CPU

這個過程就是下面兩句:

g_pCurrentCoroutine = g_pCurrentCoroutine->p_next;
asm_amc_coroutine_restore(g_pCurrentCoroutine);

只是簡單的調用 asm_amc_coroutine_restore() 彙編函數的過程。這個彙編函數我就不貼上來了,由於其邏輯和前面的 asm_amc_coroutine_enter() 相同,只是保存的現場比較多而已。

協程的結束和銷燬

前文說到,當協程結束的時候,會調用 return 返回。這個時候在彙編中作了如下的事情:

  1. 從堆棧中取出函數的返回地址
  2. 調用 retq 返回(retq 同時會將返回地址出棧丟掉)

這就是咱們前文中將協程返回地址重定向的原理基礎。

協程結束後,會返回到 _coroutine_did_end() 函數中。這裏須要注意的是,返回的位置是該函數的入口,所以反彙編會發現,這個函數還額外作了壓棧的動做。不過不要緊,由於這個動做是在即將被銷燬的協程堆棧中進行的,所以不用擔憂內存泄露啥的。

這個函數作了如下幾個操做:

將堆棧切換回主線程

調用匯編函數 asm_amc_coroutine_switch_sp_rip_to() 把當前的堆棧切換的主線程中。之因此要馬上切換掉,是由於協程已經結束了,協程的資源也應該銷燬。若是還在協程的堆棧上工做的話,那麼堆棧銷燬掉後會致使 segment fault。

銷燬協程的堆棧和其餘資源

這很好理解了,前面給協程分配了堆棧,用完了確定要還的。

其餘協程調度

若是還有其餘未完成的協程,那就調度過去,和前文同樣。

返回到主線程

這裏用的則是 asm_amc_coroutine_return_to_main() 彙編函數,和切換協程的函數就是差在第一句彙編語句上:

popq %rsi

這句話後面的註釋也說了,其實仍是玩堆棧。這句話將這個彙編函數原來的返回地址出棧掉,採用以前重定向的地址——也就是主線程調用 AMCCoroutineRun() 以後的下一句代碼

後記

我的以爲我關於協程的兩篇文章恐怕看的人不多,或許如今用 C/C++ 寫後臺服務的人不多了吧,sad ……

計劃這系列文章是分三個部分的,分別是:

  • 協程介紹
  • 彙編原理
  • libevent 結合協程(libco)進行同步服務開發

前兩部分就這樣了,最後一部分,目前代碼已經完成了,下一篇文章就是原理文檔,歡迎閱讀~

相關文章
相關標籤/搜索