iOS協程coobjc的設計篇-棧切換
協程 (Coroutine) 是一種輕量級的非搶占式使用者态線程。本文主要介紹阿裡開源的iOS協程架構coobjc的設計思考。
1. 協程簡介
Subroutine: 子程式,可以了解為函數
Coroutine: 協程
Subroutine 的調用順序是确定的,比如下圖左A調B,B執行完畢傳回, Subroutine調用和傳回是通過壓棧出棧來實作的。而Coroutine的調用則和Subroutine不同,比如下圖右A調C, 但是在C執行過程中可能會suspend, 然後在其他時候再resume回來繼續執行,也就是執行順序是不确定的。 也就是Coroutine的重要特質: suspend 和 resume, suspend也會叫做yield.

本圖來自:
https://llvm.org/devmtg/2016-11/Slides/Nishanov-LLVMCoroutines.pdf另一個需要了解的概念就是非搶占式,這個概念也比較容易了解,意思就是這裡的yield和resume不是搶占式執行的,是由代碼主動控制的yield和resume。而不是像線程一樣,由核心決定是否配置設定時間片給他。是以實作一個協程的核心就是實作yield和resume操作。
2. 有棧式 vs 無棧式
協程在實作上分為有棧協程(stackful coroutine)和無棧協程(stackless coroutine)。
首先要了解一個概念: 棧。
建立線程的時候,首先會給你配置設定一片記憶體作為它的資料棧。而線上程中執行的子程式(函數)的調用和傳回的過程就是壓棧和出棧的過程:
圖例:一個調用棧: F -> G -> H
如果協程有自己的棧,那就是有棧協程; 如果沒有,那就是無棧協程。像現有語言的實作中,Python, Kotlin, c++标準 等中就是定義的無棧協程, go語言中實作的是有棧協程。
有棧協程比較好了解,就是2個調用棧之間互相切換執行,就類似是2個獨立的線程;那麼無棧協程呢? 無棧協程更輕量, 不會建立單獨的棧空間, 隻儲存單個frame的狀态(一般是到堆上),然後frame可以多次恢複。
圖例: LLVM無棧式協程代碼編譯示例。
無棧式協程一般需要通過編譯器配合,将上圖中左邊代碼編譯成右邊的帶有多個标簽的代碼段,llvm.coro.begin 傳入的記憶體作為儲存目前frame的記憶體,通過實作方法可重入來達到代碼暫停和恢複的效果。 而不使用編譯器,通過一些switch case或者go to之類的封裝或許可以做做一些簡單的demo, 但是想要實用就比較有局限性了。
介紹了兩種方式,簡單列下他們的優缺點:
- 有棧式協程:
- 優點: 實作簡單;有自己的調用棧,可以在自己調用棧的任意地方yield/resume。
- 缺點: 自定義棧空間 + 執行順序不确定可能會踩到坑,比如基于runloop的ObjC的autorelease回收機制;比如backtrace、JSCore等代碼裡面棧位址檢測邏輯。
- 無棧式協程:
- 優點: 隻要保持單個frame, 理論上性能更佳,使用更靈活。不會踩到上述有棧式協程的坑。
- 缺點: 實作難度較大,需要從編譯器入手。
其實上面的比較來看,無棧式協程是更完美的方案,但是牽涉到編譯器到語言層面的改動較大,實作較複雜。我們coobjc還是選擇了有棧式協程來實作,這也決定了我們會踩到上面說到的坑,不過我們都給出了其中一些問題目前的解決方案。
3. 棧切換
既然是做有棧式協程,那麼就要解決最基本的需求: 棧切換。
實作 yield 和 resume 的本質就是從一個調用棧切換到另一個調用棧。切換是簡單的,主要的工作就是調用棧上下文的儲存和恢複。
網絡上流行的切換棧的方案:
- ucontext
- setjmp 和 longjmp
首先
ucontext
在iOS的arm架構上是沒有實作的(x86上是有的),用不了。 setjmp 和 longjmp 沒有儲存調用棧的操作,還需要自己寫。那不如自己手寫彙編來實作ucontext同樣的邏輯就可以了。
有同學會疑惑不是有人用 setjmp和 longjmp實作了協程了麼? 這裡引用雲風blog中的一段話來說明下: “setjmp 也可以用來模拟 coroutine 。但是會遇到一個難以逾越的難點:正确的 coroutine 實作需要為每個 coroutine 配備一個獨立的資料棧,這是 setjmp 無法做到的。雖然有一些 C 的 coroutine 庫用 setjmp/longjmp 實作。但使用起來都會有一定隐患。多半是在單一棧上預留一塊空間,然後給另一個 coroutine 運作時覆寫使用。當資料棧溢出時,程式會發生許多怪異的現象,很難排除這種溢出 bug 。” 原文:
https://blog.codingnow.com/2010/05/setjmp.html這2中方案在iOS上看起來都行不通,但是我們還有辦法,那就是用彙編模拟ucontext的原理自己實作一套。
4. 彙編實作
既然是用彙編來模拟ucontext的實作,那麼我們看到ucontext的幾個方法:
int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);
void makecontext(ucontext_t *ucp, (void *func)(), int argc, ...);
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
其實我們隻需要使用彙編實作
getcontext
和
setcontext
就夠我們來實作棧切換了。 makecontext用c來實作就好了, swapcontext其實就是對get和set的使用。
getcontext:是把目前寄存器裡面的需要儲存的資訊儲存到記憶體。
setcontext:從記憶體把寄存器的值恢複,然後跳轉到需要恢複的位址執行。
做的事情非常簡單,也就是寄存器的儲存和恢複,但是要儲存和恢複哪些寄存器呢? 而且這兩個方法的本身代碼也要使用到寄存器,該怎麼使用才不會污染到要儲存的目标寄存器呢?
這裡面需要的知識就是你要知道每個cpu架構的 調用約定 (Calling convention)。因為我們要從這個約定中擷取這些關鍵資訊:哪些寄存器是需要儲存的,哪些寄存器是可以用的,那個是stack pointer,哪些是參數寄存器,壓棧棧怎麼執行的。 知道這些資訊,我們才能在不污染到寄存器和棧的情況下做儲存和恢複。
而這些文檔一般都是公開的,直接Google搜尋就可以了。如arm64的:
http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf這裡貼出了coobjc中arm64的
get_context
的實作來說明:
_coroutine_getcontext:
stp x18,x19, [x0, #0x090]
stp x20,x21, [x0, #0x0A0]
stp x22,x23, [x0, #0x0B0]
stp x24,x25, [x0, #0x0C0]
stp x26,x27, [x0, #0x0D0]
str x28, [x0, #0x0E0];
stp x29, x30, [x0, #0x0E8]; // fp, lr
mov x9, sp
str x9, [x0, #0x0F8]
str x30, [x0, #0x100] // store return address as pc
stp d8, d9, [x0, #0x150]
stp d10,d11, [x0, #0x160]
stp d12,d13, [x0, #0x170]
stp d14,d15, [x0, #0x180]
mov x0, #0
ret
其中隻儲存了 x19 - x30, sp, d8 - d15。 為什麼呢?就是基于文檔中的 5.1.1和5.1.2節中的内容:
"A subroutine invocation must preserve the contents of the registers r19-r29 and SP."
"Registers v8-v15 must be preserved by a callee across subroutine calls; the remaining registers (v0-v7, v16-v31)
do not need to be preserved (or should be preserved by the caller). Additionally, only the bottom 64-bits of each
value stored in v8-v15 need to be preserved1"
簡單說明一下,就是說整形寄存器中的 r19-r29,sp是必須儲存的; 浮點型寄存器中的v8-v15也是需要儲存的,但是隻有低64位需要儲存,是以代碼中用的是 d8-d15。
而我在代碼中間用到了 x9 寄存器,在文檔中也可以看到 r9-r15是 “Temporary registers”, 臨時寄存器, 也就是可以在這裡使用的。
5. 棧位址對齊
如果你看了
coobjc
的源碼,可能會注意到在
makecontext
的 x86 實作中有這樣奇怪的設定:
// arm64
uctx->GR.__sp = stackBegin;
// armv7
uctx->GR.__sp = stackBegin;
// i386
uctx->GR.__esp = stackBegin - 5 * sizeof(uintptr_t);
// x86
uctx->GR.__rsp = stackBegin - 3 * sizeof(uintptr_t);
就是我們初始化協程的棧的時候,把stack pointer的值做了一個修正。 為什麼要做這樣的修正呢?因為在 process entry 的方法中,需要保證 sp 的位址是按16位元組對齊的。 無論是在arm還是x86上都需要保證這個約定,否則程式執行就會因為不對齊而crash.
那另一個問題?為什麼我們設定的初始值方式不固定,而且看起來并沒有按16-Byte對齊呢? 因為我們是要保證進入到
coroutine_main
方法中執行時的sp是16-Byte對齊的,而不是這裡設定的初始值。而編譯器在編譯函數的時候預設都是需要将frame pointer 和return address壓棧,是以我們需要保證的是壓棧後的位址是對齊的。
6. yield & resume
有了棧切換了,我們怎麼用它來實作 yield 和 resume 這2個基本方法呢?
其實這2個操作就是一次
getcontext
加一次
setcontext
來完成。
下面示例了yield的源碼,resume是同樣的原理。
// optone 保證 skip 的邏輯不會被編譯器優化掉。
__attribute__ ((optnone))
void coroutine_yield(coroutine_t *co)
{
if (co == NULL) {
// if null
co = coroutine_self();
}
BOOL skip = false;
// 儲存目前的調用棧資訊到 co->context 裡面
coroutine_getcontext(co->context);
if (skip) {
// 當調用棧恢複的時候,不必繼續執行後面的代碼了。直接傳回,相當于在調用yield的地方重新恢複了。
return;
}
#pragma unused(skip)
skip = true;
co->status = COROUTINE_SUSPEND;
// 将調用棧恢複到co->pre_context記錄的堆棧上
coroutine_setcontext(co->pre_context);
}
7. 小結
本文是coobjc的設計篇的第一篇,主要介紹了coobjc種協程實作基本原理的調研思路, 還有棧切換實作的原理。基于這個原理,我們才能開發整個協程架構。
參考文檔:
本文作者:念紀,來自淘寶用戶端iOS架構組
淘寶基礎平台團隊正在舉行2019實習生(2020年畢業)和社招招聘,崗位有iOS Android用戶端開發工程師、Java研發工程師、C/C++研發工程師、前端開發工程師、算法工程師,歡迎投遞履歷至
[email protected]如果你想更詳細了解淘寶基礎平台團隊,歡迎觀看
團隊介紹視訊更多淘寶基礎平台團隊的技術分享,可關注淘寶技術微信公衆号AlibabaMTT