天天看點

iOS協程coobjc的設計篇-棧切換iOS協程coobjc的設計篇-棧切換

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.

iOS協程coobjc的設計篇-棧切換iOS協程coobjc的設計篇-棧切換

本圖來自:

https://llvm.org/devmtg/2016-11/Slides/Nishanov-LLVMCoroutines.pdf

另一個需要了解的概念就是非搶占式,這個概念也比較容易了解,意思就是這裡的yield和resume不是搶占式執行的,是由代碼主動控制的yield和resume。而不是像線程一樣,由核心決定是否配置設定時間片給他。是以實作一個協程的核心就是實作yield和resume操作。

2. 有棧式 vs 無棧式

協程在實作上分為有棧協程(stackful coroutine)和無棧協程(stackless coroutine)。

首先要了解一個概念: 棧。

建立線程的時候,首先會給你配置設定一片記憶體作為它的資料棧。而線上程中執行的子程式(函數)的調用和傳回的過程就是壓棧和出棧的過程:

iOS協程coobjc的設計篇-棧切換iOS協程coobjc的設計篇-棧切換

圖例:一個調用棧: F -> G -> H

如果協程有自己的棧,那就是有棧協程; 如果沒有,那就是無棧協程。像現有語言的實作中,Python, Kotlin, c++标準 等中就是定義的無棧協程, go語言中實作的是有棧協程。

有棧協程比較好了解,就是2個調用棧之間互相切換執行,就類似是2個獨立的線程;那麼無棧協程呢? 無棧協程更輕量, 不會建立單獨的棧空間, 隻儲存單個frame的狀态(一般是到堆上),然後frame可以多次恢複。

iOS協程coobjc的設計篇-棧切換iOS協程coobjc的設計篇-棧切換

圖例: 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