背景
昨天朋友圈被一篇文章(以下簡稱“coobjc介紹文章”)刷屏了:剛剛,阿里開源 iOS 協程開發框架 coobjc!。可能大部分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
原始碼好了。
這裡下載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 <stdio.h>
#include <ucontext.h>
#include <unistd.h>
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
的函式:
ucontext | coobjc | 含義 |
---|---|---|
setcontext | coroutine_setcontext | 設定協程上下文 |
getcontext | coroutine_getcontext | 獲取協程上下文 |
makecontext | coroutine_create | 建立一個協程上下文 |
這麼一來,我們之前的程式可以改寫成如下:
#import <coobjc/coroutine_context.h>
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】: