歡迎光臨
每天分享高質量文章

阿裡開源 iOS 協程開發框架 coobjc 原始碼分析

來自:知識小集(ID:iOS-Tips),作者:kyson老師

連結:https://juejin.im/post/5c78906df265dhttps://juejin.im/post/5c78906df265da2de97092ada2de97092ad

昨天朋友圈被一篇文章(以下簡稱“coobjc介紹文章”)刷屏了:剛剛,阿裡開源 iOS 協程開發框架 coobjc!(https://mp.weixin.qq.com/s/hXmkd0TqTrCD-4kYlZcqvQ)。可能大部分 iOS 開發者都直接懵逼了:

  • 什麼是協程?

  • 協程的作用是什麼?

  • 為什麼要使用它?

因此筆者想給大家普及普及協程的知識,執行一下 coobjc 的 Example,順便分析一下 coobjc 原始碼。

分析

協程的維基百科在這裡:協程。取用裡面的解釋如下:

協程是計算機程式的一類元件,推廣了非搶先多工的子程式,允許執行被掛起與被恢復。相對子例程而言,協程更為一般和靈活,但在實踐中使用沒有子例程那樣廣泛。協程源自 Simula 和 Modula-2 語言,但也有其他語言支援。協程更適合於用來實現彼此熟悉的程式元件,如合作式多工、異常處理、事件迴圈、迭代器、無限串列和管道。根據高德納的說法, 馬爾文·康威於1958年發明瞭術語 coroutine 並用於構建彙編程式。

對,還是一知半解。但最起碼我們瞭解到

  • 協程的英文是“coroutine”,因此我們能理解阿裡的庫起名為 coobjc 的含義。那麼這個詞又是怎麼來的呢?筆者再深挖一下,協程(coroutine)顧名思義就是“協作的例程”(co-operative routines)。

  • 協程是和行程或者執行緒有一定關係的

  • 協程的歷史還是比較悠久的,只是 Objective-C 不支援。筆者經過查閱,發現很多現代語言都支援協程。比如 Python 以及 swift,甚至C語言也是支援協程的。

協程的作用其實在 coobjc 介紹文章中有提及,是為了最佳化 iOS 中的非同步操作。解決瞭如下問題:

  • “巢狀地獄”

  • 錯誤處理複雜和冗長

  • 容易忘記呼叫 completion handler

  • 條件執行變得很困難

  • 從互相獨立的呼叫中組合傳回結果變得極其困難

  • 在錯誤的執行緒中繼續執行

  • 難以定位原因的多執行緒崩潰

  • 鎖和訊號量濫用帶來的卡頓、卡死

聽起來是有點強大,最明顯的好處是可以簡化程式碼;並且在 coobjc 介紹文章也說道,效能也有所保障:當執行緒的數量級大於1000以上時,coobjc 的優勢就會非常明顯。為了證明文章的結論,我們就來執行一下 coobjc 原始碼好了。這裡(https://github.com/alibaba/coobjc)下載 coobjc 原始碼。發現目錄結構如下:

從目錄結構看還是比較清晰的,根據 coobjc 介紹文章中提到的,coobjc 不但提供了基礎的非同步操作還提供了基於 UIKit 的封裝。目錄中

  • cokit 及其子目錄提供的是基於UIKit層的coobjc封裝

  • coobjc 目錄是coobjc的Objective-C版實現的原始碼

  • coswift 目錄是coobjc的Swift版實現的原始碼

  • Example 下有兩個目錄,一個是Objective-C的實現,一個是Swift版的實現的Demo

我們先分析一下 coobjcBaseExample 工程:開啟專案,pod update 一下即可執行,執行結果如下:

可以看到是個簡單的串列頁。

Tips:開啟 podfile 可以發現裡面有庫 coobjc 以外,還有 Specta、Expecta 以及 OCMock。這三個庫這裡不多做介紹了,大家只需要知道這是用於單元測試的。

我們先看一下這個串列的實現邏輯是什麼樣的。我們不難定位到頁面位於 KMDiscoverListViewController 中,其網路請求(這裡是電影串列)程式碼如下:

– (void)requestMovies
{
    co_launch(^{
        NSArray *dataArray = [[KMDiscoverSource discoverSource] getDiscoverList:@”1″];
        [self.refreshControl endRefreshing];

        if (dataArray != nil)
        {
            [self processData:dataArray];
        }
        else
        {
            [self.networkLoadingViewController showErrorView];
        }
    });
}


這裡很容易理解程式碼

NSArray *dataArray = [[KMDiscoverSource discoverSource] getDiscoverList:@”1″];


是請求網路資料的,其實現如下:

– (NSArray*)getDiscoverList:(NSString *)pageLimit;
{
    NSString *url = [NSString stringWithFormat:@”%@&page;=%@”, [self prepareUrl], pageLimit];
    id json = [[DataService sharedInstance] requestJSONWithURL:url];
    NSDictionary* infosDictionary = [self dictionaryFromResponseObject:json jsonPatternFile:@”KMDiscoverSourceJsonPattern.json”];
    return [self processResponseObject:infosDictionary];
}


以上程式碼也能猜出,

id json = [[DataService sharedInstance] requestJSONWithURL:url];


這一行是做了網路請求,但是我們再點選進入類 DataService 看 requestJSONWithURL 方法的實現的時候,發現已經看不懂了:

– (id)requestJSONWithURL:(NSString*)url CO_ASYNC{
    SURE_ASYNC
    return await([self.jsonActor sendMessage:url]);
}


好吧。既然看不懂了,我們就從頭開始學習,協程的含義以及使用。繼而對 coobjc 原始碼進行分析。

協程入門

coobjc介紹文章中有提到

  • 第一種:利用 glibc 的 ucontext元件(雲風的庫)。

  • 第二種:使用彙編程式碼來切換背景關係(實現C協程),原理同 ucontext。

  • 第三種:利用 C 語言語法 switch-case 的奇淫技巧來實現(Protothreads)。

  • 第四種:利用了 C 語言的 setjmp 和 longjmp。

  • 第五種:利用編譯器支援語法糖。

經過篩選最終選擇了第二種。那我們來一個個分析,為什麼 coobjc 摒棄了其他的方式。首先我們看第一種,coobjc 介紹文章中提到 ucontext 在 iOS 中被廢棄了,那如果不廢棄,我們如何去使用 ucontext 呢?如下的一個 Demo 可以解釋一下 ucontext 的用法:

#include 
#include 
#include 

int main(int argc, const char *argv[]){
    ucontext_t context;
    getcontext(&context;);
    puts(“Hello world”);
    sleep(1);
    setcontext(&context;);
    return 0;
}


註:示例程式碼來自維基百科.

儲存上述程式碼到 example.c,執行編譯命令:

gcc example.c -o example


想想程式執行的結果會是什麼樣?

kysonzhu@ubuntu:~$ ./example 
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
^C
kysonzhu@ubuntu:~$


上面是程式執行的部分輸出,不知道是否和你想得一樣呢?我們可以看到,程式在輸出第一個 “Hello world” 後並沒有退出程式,而是持續不斷的輸出 “Hello world”。其實是程式透過 getcontext 先儲存了一個背景關係,然後輸出 “Hello world”,在透過 setcontext 恢復到 getcontext 的地方,重新執行程式碼,所以導致程式不斷的輸出 “Hello world”,在我這個菜鳥的眼裡,這簡直就是一個神奇的跳轉。那麼問題來了,ucontext 到底是什麼?

這裡筆者不多做介紹了,推薦一篇文章,講的比較詳細:ucontext-人人都可以實現的簡單協程庫。這裡我們只需要知道,所謂 coobjc 介紹文章中提到的使用組合語言模擬 ucontext,其實就是模擬的上面例子中的 setcontext 及 getcontext 等函式。為了證明筆者的猜想,筆者開啟了coobjc原始碼庫,發現裡面的唯一的彙編檔案 coroutine_context.s

檢視該檔案,發現了這麼幾個函式:

  • _coroutine_getcontext

  • _coroutine_begin

  • _coroutine_setcontext

果然驗證了筆者的想法。這三個方法被暴露在檔案 coroutine_context.h 中,供後序呼叫:

extern int coroutine_getcontext (coroutine_ucontext_t *__ucp);
extern int coroutine_setcontext (coroutine_ucontext_t *__ucp);
extern int coroutine_begin (coroutine_ucontext_t *__ucp);


接下來說另外一個函式

int  setcontext(const ucontext_t *cut)


該函式是設定當前的背景關係為 cut,setcontext 的背景關係 cut 應該透過 getcontext 或者 makecontext 取得,如果呼叫成功則不傳回。如果背景關係是透過呼叫 getcontext() 取得,程式會繼續執行這個呼叫。如果背景關係是透過呼叫makecontext取得,程式會呼叫 makecontext 函式的第二個引數指向的函式,如果 func 函式傳回,則恢復 makecontext 第一個引數指向的背景關係第一個引數指向的背景關係 context_t 中指向的 uc_link。如果 uc_link 為 NULL,則執行緒退出。

我們畫個表類比一下 ucontext 和 coobjc 的函式:

這麼一來,我們之前的程式可以改寫成如下:

#import 
int main(int argc, const char *argv[]) {
    coroutine_ucontext_t context;
    coroutine_getcontext(&context;);
    puts(“Hello world”);
    sleep(1);
    coroutine_setcontext(&context;);
    return 0;
}


傳回的結果仍然不變,一直列印“hello world”。

深入協程

(1)目錄分析

上圖是 coobjc 的目錄結構,其中

  • core 目錄 提供了核心的協程函式

  • api目錄 是 coobjc 基於 Objective-C 的封裝

  • csp 目錄 從庫 libtask 引入,提供了一些鏈式操作

  • objc 提供了 coobjc 物件宣告週期管理的一些類

下麵的文章,筆者會先從核心的 core 目錄開始研究,後面的大家理解起來也就不複雜了。

(2)協程的構成

上面我們只簡單的介紹了 coobjc,也瞭解到 coobjc 基本都是參考了 ucontext。那下麵的例子中,筆者盡可能先介紹 ucontext,然後再應用到 coobjc 對應的方法中。

我們繼續討論上文提到的幾個函式,並說明一下其作用:

int  getcontext(ucontext_t *uctp)


這個方法是,獲取當前背景關係,並將背景關係設定到 uctp 中,uctp 是個背景關係結構體,其定義如下:

_STRUCT_UCONTEXT
{
    int                     uc_onstack;
    __darwin_sigset_t       uc_sigmask;     /* signal mask used by this context */
    _STRUCT_SIGALTSTACK     uc_stack;       /* stack used by this context */
    _STRUCT_UCONTEXT        *uc_link;       /* pointer to resuming context */
    __darwin_size_t         uc_mcsize;      /* size of the machine context passed in */
    _STRUCT_MCONTEXT        *uc_mcontext;   /* pointer to machine specific context */
#ifdef _XOPEN_SOURCE
    _STRUCT_MCONTEXT        __mcontext_data;
#endif /* _XOPEN_SOURCE */
};

/* user context */
typedef _STRUCT_UCONTEXT    ucontext_t;     /* [???] user context */



以上是 ucontext 的資料結構,其內部的幾個屬性介紹一下:

  • 噹噹前背景關係(如使用 makecontext 建立的背景關係)執行終止時系統會恢復 uc_link 指向的背景關係;

  • uc_sigmask 為該背景關係中的阻塞訊號集合;

  • uc_stack 為該背景關係中使用的棧;

  • uc_mcontext 儲存的背景關係的特定機器表示,包括呼叫執行緒的特定暫存器等。

其實還蠻好理解的,ucontext 其實就存放一些必要的資料,這些資料還包括拯救成功或者失敗的情況需要的資料。

相比較而言,coobjc 的定義和 ucontext 有一定區別:

/**
     The structure store coroutine’s context data.
     */

struct coroutine {
    coroutine_func entry;                   // Process entry.
    void *userdata;                         // Userdata.
    coroutine_func userdata_dispose;        // Userdata’s dispose action.
    void *context;                          // Coroutine’s Call stack data.
    void *pre_context;                      // Coroutine’s source process’s Call stack data.
    int status;                             // Coroutine’s running status.
    uint32_t stack_size;                    // Coroutine’s stack size
    void *stack_memory;                     // Coroutine’s stack memory address.
    void *stack_top;                    // Coroutine’s stack top address.
    struct coroutine_scheduler *scheduler;  // The pointer to the scheduler.
    int8_t   is_scheduler;                  // The coroutine is a scheduler.

    struct coroutine *prev;
    struct coroutine *next;

    void *autoreleasepage;                  // If enable autorelease, the custom autoreleasepage.
    bool is_cancelled;                      // The coroutine is cancelled
};
typedef struct coroutine coroutine_t;


其中

    struct coroutine *prev;
    struct coroutine *next;


表明其是一個連結串列結構。既然是連結串列,那麼就會有新增元素,以及刪除某個元素的方法,果然我們在 coroutine.m 中發現了對應的連結串列操作方法:

// add routine to the queue
void scheduler_add_coroutine(coroutine_list_t *l, coroutine_t *t) {
    if(l->tail) {
        l->tail->next = t;
        t->prev = l->tail;
    } else {
        l->head = t;
        t->prev = nil;
    }
    l->tail = t;
    t->next = nil;
}

// delete routine from the queue
void scheduler_delete_coroutine(coroutine_list_t *l, coroutine_t *t) {
    if(t->prev) {
        t->prev->next = t->next;
    } else {
        l->head = t->next;
    }

    if(t->next) {
        t->next->prev = t->prev;
    } else {
        l->tail = t->prev;
    }
}


其中 coroutine_list_t 是為了標識連結串列的頭尾節點:

/**
 Define the linked list of scheduler’s queue.
 */

struct coroutine_list {
    coroutine_t *head;
    coroutine_t *tail;
};
typedef struct coroutine_list coroutine_list_t;


為了管理所有的協程狀態,還設定了一個排程器:

/**
 Define the scheduler.
 One thread own one scheduler, all coroutine run this thread shares it.
 */

struct coroutine_scheduler {
    coroutine_t         *main_coroutine;
    coroutine_t         *running_coroutine;
    coroutine_list_t     coroutine_queue;
};
typedef struct coroutine_scheduler coroutine_scheduler_t;


看命名就大概能猜到,main_coroutine 中包含了主協程(可能是即將設定資料的協程,或者即將使用的協程);running_coroutine 是當前正在執行的協程。

(3)協程的操作

協程擁有和執行緒一樣類似的操作,例如建立,啟動,出讓控制權,恢復,以及死亡。對應的,我們在 coroutine.h 看到瞭如下的幾個函式宣告:

//關閉一個協程如果它已經死亡
void coroutine_close_ifdead(coroutine_t *co);
//新增協程到排程器,並且立刻啟動
void coroutine_resume(coroutine_t *co);
//新增協程到排程器
void coroutine_add(coroutine_t *co);
//出讓控制權
void coroutine_yield(coroutine_t *co);


為了更好的控制各個操作中的資料,coobjc 還提供了以下兩個方法:

void coroutine_setuserdata(coroutine_t *co, void *userdata, coroutine_func userdata_dispose);
void *coroutine_getuserdata(coroutine_t *co);


至此,coobjc 的核心程式碼都分析完成了。

(4)協程的Objective-C層面的封裝

我們再次回到文章開頭的例子 - (void)requestMovies 方法的實現中,第一步就是呼叫一個 co_launch() 的方法,這個方法最終會呼叫到

+ (instancetype)coroutineWithBlock:(void(^)(void))block onQueue:(dispatch_queue_t _Nullable)queue stackSize:(NSUInteger)stackSize {
    if (queue == NULL) {
        queue = co_get_current_queue();
    }
    if (queue == NULL) {
        return nil;
    }
    COCoroutine *coObj = [[self alloc] initWithBlock:block onQueue:queue];
    coObj.queue = queue;
    coroutine_t  *co = coroutine_create((void (*)(void *))co_exec);
    if (stackSize > 0 && stackSize 1024
*1024) {   // Max 1M
        co->stack_size = (uint32_t)((stackSize % 16384 > 0) ? ((stackSize/16384 + 1) * 16384) : stackSize/16384);        // Align with 16kb
    }
    coObj.co = co;
    coroutine_setuserdata(co, (__bridge_retained void *)coObj, co_obj_dispose);
    return coObj;
}

– (void)resumeNow {
    [self performBlockOnQueue:^{
        if (self.isResume) {
            return;
        }
        self.isResume = YES;
        coroutine_resume(self.co);
    }];
}


這兩個方法。其實程式碼已經很容易理解了,第一個方法是建立一個協程,第二個是啟動。最後我們在說一下文章開頭提到的 await 方法,其實最終就交給 chan 去處理了:

– (COActorCompletable *)sendMessage:(id)message {
    COActorCompletable *completable = [COActorCompletable promise];
    dispatch_async(self.queue, ^{
        COActorMessage *actorMessage = [[COActorMessage alloc] initWithType:message completable:completable];
        [self.messageChan send_nonblock:actorMessage];
    });
    return completable;
}


所有的操作雖然丟到了同一個執行緒中,但其實最終是透過 chan 來排程了。關於 chan 就不在本文討論範圍了,後面如果有時間,筆者會再進行對 chan 的分析。

總結

本文介紹了協程的概念,透過對比 ucontext 以及 coobjc 來說明協程的用法,並分析了 coobjc 的原始碼,希望對大家有所幫助。

擴充套件閱讀

  • iOS單元測試:Specta + Expecta + OCMock + OHHTTPStubs + KIF
    https://blog.csdn.net/colorapp/article/details/47007431

  • 我所理解的ucontext族函式
    https://www.jianshu.com/p/dfd7ac1402f0

  • 一個“蠅量級” C 語言協程庫
    https://coolshell.cn/articles/10975.html

  • 協程(Coroutine)並不是真正的多執行緒
    https://www.cnblogs.com/wonderKK/p/4062591.html

  • ucontext-人人都可以實現的簡單協程庫
    https://blog.csdn.net/qq910894904/article/details/41911175

贊(0)

分享創造快樂