這幾年異步編程是個比較熱門的話題。
今天我們在iOS平臺下簡單聊聊異步編程和coobjc。
首先要回答一個問題,我們爲什麼需要異步編程?
早年的時候,大家都很習慣於開一個線程去執行耗時任務,即使這個耗時任務並非CPU密集型任務,比如一個同步的IO或網絡調用。但發展到今日,大家對這種場景應該使用異步而非子線程的結論應當沒什麼疑問。開線程本身開銷相對比較大,並且多線程編程動不動要加鎖,很容易出現crash或更嚴重的性能問題。而iOS平臺,系統API有不少就是這種不科學的同步耗時調用,並且GCD的API算是很好用的線程封裝,這導致iOS平臺下很容易濫用多線程引發各種問題。
總而言之,原則上,網絡、IO等很多不耗CPU的耗時操作都應該優先使用異步來解決。
再來看異步編程的方案,iOS平臺下常用的就是delegate和block回調。delegate導致邏輯的割裂,並且使用場景比較注重於UI層,對大多數異步場景算不上好用。
而block回調語法同樣有一些缺陷。最大的問題就是回調地獄:
[NSURLConnection sendAsynchronousRequest:rq queue:nil completionHandler:^(NSURLResponse * _Nullable response, NSData * _Nullable data, NSError * _Nullable connectionError) {
if (connectionError) {
if (callback) {
callback(nil, nil,connectionError);
}
}
else{
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
NSString *imageUrl = dict[@"image"];
[NSURLConnection sendAsynchronousRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:imageUrl]] queue:nil completionHandler:^(NSURLResponse * _Nullable response, NSData * _Nullable data, NSError * _Nullable connectionError) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
if (connectionError) {
callback(nil, dict,connectionError);
}
else{
UIImage *image = [[UIImage alloc] initWithData:data];
if (callback) {
(image, dict, nil);
}
}
});
}];
});
}
}]
不過iOS開發中好像並沒有覺得很痛,至少沒有前端那麼痛。可能是因爲我們實際開發中對比較深的回調會換成用delegate或notificaiton機制。但這種混雜的使用對代碼質量是個挑戰,想要保證代碼質量需要團隊做很多約束並且有很高的執行力,這其實很困難。
另一個方案是響應式編程。ReactiveCocoa和RxSwift都是這種思想的踐行者,響應式的理念是很先進的,但存在調試困難的問題,並且學習成本比較高,想要整個團隊都用上且用好,也是挺不容易的。
而這幾年最受關注的異步模型是協程(async/await)方案,go的橫空出世讓協程這一概念深入人心(雖然goroutine不是嚴格意義上的協程),js對async/await的支持也飽受關注。
swift有添加async/await語法的提案,不過估計要再等一兩年了。
不過今年阿里開源了一個coobjc庫,可以爲iOS提供async/await的能力,並且做了很多工作來對iOS編程中可能遇到的問題做了完善的適配,非常值得學習。
協程方案
先拋開coobjc,我們來看看有哪些方案可以實現協程。
protothreads
protothreads是最輕量級的協程庫,其實現依賴了switch/case語法的奇技淫巧,然後用一堆宏將其封裝爲支持協程的原語。實現了比較通用的協程能力。
具體實現可以參考這篇一個“蠅量級” C 語言協程庫。
不過這種方案是沒辦法保留調用棧的,以現在的眼光來看,算不上完整的協程。
基於setjmp/longjmp實現
有點像goto,不過是能夠保存上下文的,但是這裏的上下文只是寄存器的內容,並非完整的棧。
ucontext
ucontext提供的能力就比較完整了,能夠完整保存上下文包括棧。基於ucontext可以封裝出有完整能力的協程庫。參考coroutine
但是ucontext在iOS是不被支持的:
int getcontext(ucontext_t *) __OSX_AVAILABLE_BUT_DEPRECATED(__MAC_10_5, __MAC_10_6, __IPHONE_2_0, __IPHONE_2_0) __WATCHOS_PROHIBITED __TVOS_PROHIBITED;
void makecontext(ucontext_t *, void (*)(), int, ...) __OSX_AVAILABLE_BUT_DEPRECATED(__MAC_10_5, __MAC_10_6, __IPHONE_2_0, __IPHONE_2_0) __WATCHOS_PROHIBITED __TVOS_PROHIBITED;
int setcontext(const ucontext_t *) __OSX_AVAILABLE_BUT_DEPRECATED(__MAC_10_5, __MAC_10_6, __IPHONE_2_0, __IPHONE_2_0) __WATCHOS_PROHIBITED __TVOS_PROHIBITED;
int swapcontext(ucontext_t * __restrict, const ucontext_t * __restrict) __OSX_AVAILABLE_BUT_DEPRECATED(__MAC_10_5, __MAC_10_6, __IPHONE_2_0, __IPHONE_2_0) __WATCHOS_PROHIBITED __TVOS_PROHIBITED;
編譯器實現
編譯器當然什麼都能做...這裏主要是單指一種通過編譯器實現無棧協程的方式。
協程是否有單獨的棧,可以分爲有棧協程和無棧協程。有棧協程當然能力更完善,但無棧協程更輕量,在性能和內存佔用上應該略有提升。
但在當語言最開始沒有支持協程,搞出個有棧協程很容易踩各種坑,比如autorelease機制。
無棧協程由編譯器處理,其實比較簡單,只要在編譯時在特定位置生成標籤進行跳轉即可。
如下:LLVM的無棧式協程代碼編譯示例
這種插入、跳轉其實比較像前面提到的switch case實現的奇技淫巧,但奇技淫巧是有缺陷的,編譯器實現就很靈活了。
彙編實現
使用匯編可以保存各個寄存器的狀態,完成ucontext的能力。
關於調用棧,其實棧空間可以在創建協程的時候手動開闢,把棧寄存器指過去就好了。
比較麻煩的是不同平臺的機制不太一樣,需要寫不同的彙編代碼。
coobjc
回到今天的主角coobjc,它使用了彙編方案實現有棧協程。
其實現原理部分,iOS協程coobjc的設計篇-棧切換講得非常好了,強烈推薦閱讀。
這裏還是關注一下其使用。
async/await/channel
coobjc通過co_launch
方法創建協程,使用await等待異步任務返回,看一個例子:
- (void)viewDidLoad{
...
co_launch(^{
NSData *data = await(downloadDataFromUrl(url));
UIImage *image = await(imageFromData(data));
self.imageView.image = image;
});
}
上述代碼將原本需要dispatch_async兩次的代碼變成了順序執行,代碼更加簡潔
await接受的參數是個Promise或Channel對象,這裏先看一下Promise:
// make a async operation
- (COPromise<id> *)co_fetchSomethingAsynchronous {
return [COPromise promise:^(COPromiseResolve _Nonnull resolve, COPromiseReject _Nonnull reject) {
dispatch_async(_someQueue, ^{
id ret = nil;
NSError *error = nil;
// fetch result operations
...
if (error) {
reject(error);
} else {
resolve(ret);
}
});
}];
}
// calling in a coroutine.
co_launch(^{
id ret = await([self co_fetchSomethingAsynchronous]);
NSError *error = co_getError();
if (error) {
// error
} else {
// ret
}
});
Promise是對一個異步任務的封裝,await會等待Promise的reject/resolve的回調。
這裏需要注意的是,coobjc的await跟javascript/dart是有點不一樣的,對於javascript,調用異步任務的時候,每一層都要顯式使用await,否則對外層來說就不會阻塞。看下面這個例子:
function timeout(ms) {
return new Promise(resolve => {
setTimeout(() => resolve("long_time_value"), ms);
});
}
async function test() {
const v = await timeout(100);
console.log(v);
}
console.log('test start');
var result = test();
console.log(result);
console.log('test end');
test函數,在外面調用的時候,如果沒有await,那麼在test函數內遇到await時,外面就直接往下執行了。test函數返回了一個Promise對象。這裏的輸出順序是:
test start
Promise { <pending> }
test end
long_time_value
dart的async/await也是這樣。
但coobjc不是的,它的await是比較簡單的,它會阻塞住整個調用棧。來看一下coobjc的demo:
- (void)coTest
{
co_launch(^{
NSLog(@"co start");
id ret = [self test];
NSError *error = co_getError();
if (error) {
// error
} else {
// ret
}
NSLog(@"co end");
});
NSLog(@"co out");
}
- (id)test {
NSLog(@"test before");
id ret = await([self co_fetchSomethingAsynchronous]);
NSLog(@"test after");
return ret;
}
- (COPromise<id> *)co_fetchSomethingAsynchronous {
return [COPromise promise:^(COPromiseFulfill _Nonnull resolve, COPromiseReject _Nonnull reject) {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"co run");
id ret = @"test";
NSError *error = nil;
// fetch result operations
if (error) {
reject(error);
} else {
resolve(ret);
}
});
}];
}
coTest方法中,直接調用了[self test]
,這裏是順序執行的,日誌輸出順序如下
2019-11-05 11:19:39.456798+0800 JFDemos[57239:5352156] co out
2019-11-05 11:19:39.660899+0800 JFDemos[57239:5352156] co start
2019-11-05 11:19:39.660994+0800 JFDemos[57239:5352156] test before
2019-11-05 11:19:39.662987+0800 JFDemos[57239:5352156] co run
2019-11-05 11:19:39.663110+0800 JFDemos[57239:5352156] test after
2019-11-05 11:19:39.663194+0800 JFDemos[57239:5352156] co end
這兩種方式,應該是前者更靈活一點,但是後者更符合直覺。主要是如果在其它語言用過async/await,需要注意一下這裏的差異。
Channel
Channel 是協程之間傳遞數據的通道, Channel的特性是它可以阻塞式發送或接收數據。
看個例子
COChan *chan = [COChan chanWithBuffCount:0];
co_launch(^{
NSLog(@"1");
[chan send:@111];
NSLog(@"4");
});
co_launch(^{
NSLog(@"2");
id value = [chan receive_nonblock];
NSLog(@"3");
});
這裏初始化chan時bufferCount設爲0,因此send時會阻塞,如果緩存空間不爲0,沒滿之前就不會阻塞了。這裏輸出順序是1234。
Generator
Generator不是一個基本特性,其實算是種編程範式,往往基於協程來實現。簡單而言,Generator就是一個懶計算序列,每次外面觸發next()之類的調用就往下執行一段邏輯。
比如使用coobjc懶計算斐波那契數列:
COCoroutine *fibonacci = co_sequence(^{
yield(@(1));
int cur = 1;
int next = 1;
while(co_isActive()){
yield(@(next));
int tmp = cur + next;
cur = next;
next = tmp;
}
});
co_launch(^{
for(int i = 0; i < 10; i++){
val = [[fibonacci next] intValue];
}
});
Generator很適合使用在一些需要隊列或遞歸的場景,將原本需要一次全部準備好的數據變成按需準備。
Actor
actor是一種基於消息的併發編程模型。關於併發編程模型,以及多線程存在的一些問題,之前簡單討論過,這裏就不多說了。
Actor可以理解爲一個容器,有自己的狀態,和行爲,每個Actor有一個Mailbox,Actor之間通過Mailbox通信從而觸發Actor的行爲。
Mail應當實現爲不可變對象,因此實質上Actor之間是不共享內存的,也就避免了多線程編程存在的一大堆問題。
類似的,有個CSP模型,把通信抽象爲Channel。Actor模型中,每個Actor都有個Mailbox,Actor需要知道對方纔能發送。而CSP模型中的Channel是個通道,實體向Channel發送消息,別的實體可以向這個Channel訂閱消息,實體之間可以是匿名的,耦合更低。
coobjc雖然實現了Channel,不過似乎更傾向於Actor模型一點,coobjc爲我們封裝了Actor模型,簡單使用如下:
COActor *countActor = co_actor_onqueue(get_test_queue(), ^(COActorChan *channel) {
int count = 0;
for(COActorMessage *message in channel){
if([[message stringType] isEqualToString:@"inc"]){
count++;
}
else if([[message stringType] isEqualToString:@"get"]){
message.complete(@(count));
}
}
});
co_launch(^{
[countActor sendMessage:@"inc"];
[countActor sendMessage:@"inc"];
[countActor sendMessage:@"inc"];
int currentCount = [await([countActor sendMessage:@"get"]) intValue];
NSLog(@"count: %d", currentCount);
});
co_launch_onqueue(dispatch_queue_create("counter queue1", NULL), ^{
[countActor sendMessage:@"inc"];
[countActor sendMessage:@"inc"];
[countActor sendMessage:@"inc"];
[countActor sendMessage:@"inc"];
int currentCount = [await([countActor sendMessage:@"get"]) intValue];
NSLog(@"count: %d", currentCount);
});
可以看到這裏避免了多線程間的衝突問題。在很多場景下是比多線程模型更優的,也是這幾年的發展趨勢。
小結
coobjc爲objc和swift提供了協程能力,以及基於協程的一些便捷的方法和編程範式。但對比Javascript/dart/go等原生支持協程的語言,這種hack的方式添加的語法畢竟不是特別友好。
目前objc下降swift上升的趨勢已經很明顯了,而swift原生支持async/await也就在一兩年內了。coobjc出現在這個時間其實還是有點小尷尬的。
其它參考
[coobjc usage](