在前一篇文章《基於匯編的 C/C++ 協程 - 背景知識》中提到一個用於 C/C++ 的協程所需要實現的兩大功能:
- 協程調度
- 上下文切換
其中調度,其實在技術實現上與其他的線程、進程調度沒有什么特別的差異,同時也要看具體業務的需求。限制 C/C++ 協程應用的最大技術條件是上下文切換。理由在前文也說了。
既然本系列講的是基於匯編的 C/C++ 協程,那么這篇文章我們就來講講使用匯編來進行上下文切換的原理。
本文地址:https://segmentfault.com/a/1190000013177055
參考資料
- 基於 epoll 設計類似 libevent 的異步 I/O 庫 - 接口
- linux平台學x86匯編(十九):C語言中調用匯編函數
- X64的函數調用規則
- x86 和 x64 匯編調用C 函數參數傳遞規則(GCC)
- 從匯編角度淺析C程序
- x86寄存器簡介
- 協程分析之 context 上下文切換
- Linux中的局部變量和棧
- X86-64寄存器和棧幀
- 作為值的標簽
- 用戶態調度要保存些什么
上下文切換的具體內容
首先我們需要明白上下文切換具體需要做什么工作。我想,看這篇文章的讀者應該對編譯原理和操作系統基礎知識已經有一定的基礎了吧?
協程的切換要做的事情,和進程的切換,其實是差不多的。這里我們將本文涉及的要點提一下:
進程的創建和刪除
當進程開始執行、以及進程執行結束的時候,操作系統還有別的工作:
- 當進程開始,操作系統要找到進程的入口,並且配置好上下文,然后將 CPU 交給進程
- 如果進程執行結束,則銷毀進程資源,並正確返回到調用方(比如父進程)
進程調度時的上下文切換
當觸發進程切換時(不論是進程調用阻塞的系統調用,但是操作系統主動觸發 schedule),操作系統要做以下的幾件事情:
- 奪取 CPU 使用權
- 保存當前用戶進程的上下文
- 調用調度函數,找到下一個應當占用 CPU 時間片的進程
- 恢復下一個進程的上下文
- 將 CPU 交回給待繼續的進程
示例代碼
沒有調查就沒有發言權,沒有實驗也就沒有講解權。實際上本人已經有實現的代碼了。后文就以我的代碼為脈絡來說明。
相關說明:
- 代碼只支持 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()
將主線程的上下文保存在一個全局變量中 - 第二句將堆棧指針移動了一個單位,效果上就是忽略了在函數
asm_amc_coroutine_dump()
中保存的函數返回地址,使得全局變量中保存的是AMCCoroutineRun()
的返回地址。
切換到待調用的協程上下文中
調用匯編函數 asm_amc_coroutine_enter()
,直接進入協程。函數很簡單:
五句命令的含義分別是:
- 拷貝主線程的 rbx 寄存器值給協程——實際上這一句我不太懂,求高人指教。
- 重定向堆棧地址——這個堆棧,會在進入協程函數后才使用到。
- 重定向堆棧基址——同樣地,進入協程函數后才使用到,所以這里不影響程序執行。
- 這就是前文提到的
func_ret_addr
成員,將這個地址壓入堆棧,使得協程函數結束時即進入相應的函數中,這樣我們就可以檢測到一個協程已經執行完畢了。而由於協程是單線程運行的,因此我們可以使用全局變量判斷出剛剛結束的是哪一個協程。 - 強制跳轉到協程的入口處開始執行。
前文不是說了一大堆需要保存的上下文嗎,為什么這里賦值的寄存器那么少?很簡單,協程還沒有開始執行呢,那些寄存器都不用恢復,讓協程直接用就行了。
注意,這個函數實際上是不會返回的。返回到主線程的工作已經交給了被重定向了的 _coroutine_did_end()
函數來完成。
協程的切換
獲取 CPU 使用權
當切換協程時,調度函數需要獲取 CPU 使用權,其實很簡單:只是要求協程程序自己主動調用相關的函數,從而達到交出 CPU 使用權的目的。
參見 main.cpp 文件的第 33 至 62 行。這里定義了兩個一模一樣的函數,相當於兩個協程
作為 demo 程序,這里協程只調用了一個函數 AMCCoroutineSchedule()
提請切換協程。
保存協程現場
這里調用的是匯編函數 asm_amc_coroutine_dump()
。實際上這個函數在前面保存主線程現場中已經使用過了,這里我們再詳細說明一下函數的實現:
除了標號之外的最前面的七行很好理解,就是將必要的現場保存起來。至於倒數第二、三行的 movq 16(%rsp), %rsi
和 movq %rsi, 56(%rdi)
就很耐人尋味啦。
寄存器 rsi
在 GCC 中是作為第二參數使用的。這個函數中沒有第二個參數,因此就只是作為臨時變量而已。16(%rsp)
這一句,和前文中 “保存主線程的現場” 中的第二句代碼的作用異曲同工。
另外,協程上下文的保存,還包含函數外面的一句 C 代碼:
這句話把被切換掉的協程恢復的現場重定向為 AMCCoroutineSchedule()
的 return
語句。效果是跳過了下面的 asm_amc_coroutine_restore()
函數,避免重復調度。
調度
本 demo 中沒有實質性的調度,只是輪詢而已,找到協程鏈上的下一個協程並執行。
恢復下一個協程的上下文並交出 CPU
這個過程就是下面兩句:
只是簡單的調用 asm_amc_coroutine_restore()
匯編函數的過程。這個匯編函數我就不貼上來了,因為其邏輯和前面的 asm_amc_coroutine_enter()
相同,只是保存的現場比較多而已。
協程的結束和銷毀
前文說到,當協程結束的時候,會調用 return
返回。這個時候在匯編中做了以下的事情:
- 從堆棧中取出函數的返回地址
- 調用
retq
返回(retq 同時會將返回地址出棧丟掉)
這就是我們前文中將協程返回地址重定向的原理基礎。
協程結束后,會返回到 _coroutine_did_end()
函數中。這里需要注意的是,返回的位置是該函數的入口,因此反匯編會發現,這個函數還額外做了壓棧的動作。不過沒關系,因為這個動作是在即將被銷毀的協程堆棧中進行的,因此不用擔心內存泄露啥的。
這個函數做了以下幾個操作:
將堆棧切換回主線程
調用匯編函數 asm_amc_coroutine_switch_sp_rip_to()
把當前的堆棧切換的主線程中。之所以要立刻切換掉,是因為協程已經結束了,協程的資源也應該銷毀。如果還在協程的堆棧上工作的話,那么堆棧銷毀掉后會導致 segment fault。
銷毀協程的堆棧和其他資源
這很好理解了,前面給協程分配了堆棧,用完了肯定要還的。
其他協程調度
如果還有其他未完成的協程,那就調度過去,和前文一樣。
返回到主線程
這里用的則是 asm_amc_coroutine_return_to_main()
匯編函數,和切換協程的函數就是差在第一句匯編語句上:
這句話后面的注釋也說了,其實還是玩堆棧。這句話將這個匯編函數原來的返回地址出棧掉,采用之前重定向的地址——也就是主線程調用 AMCCoroutineRun()
之后的下一句代碼
后記
個人覺得我關於協程的兩篇文章恐怕看的人很少,或許現在用 C/C++ 寫后台服務的人很少了吧,sad ……
計划這系列文章是分三個部分的,分別是:
- 協程介紹
- 匯編原理
- libevent 結合協程(libco)進行同步服務開發
前兩部分就這樣了,最后一部分,目前代碼已經完成了,下一篇文章就是原理文檔,歡迎閱讀~
https://segmentfault.com/a/1190000013177055