iOS知識梳理 異步編程 - coobjc學習

這幾年異步編程是個比較熱門的話題。

今天我們在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,不過是能夠保存上下文的,但是這裏的上下文只是寄存器的內容,並非完整的棧。

參考談一談setjmp和longjmp

ucontext

參考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框架設計

[coobjc usage](

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章