这几年异步编程是个比较热门的话题。

今天我们在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 startPromise { <pending> }test endlong_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 out2019-11-05 11:19:39.660899+0800 JFDemos[57239:5352156] co start2019-11-05 11:19:39.660994+0800 JFDemos[57239:5352156] test before2019-11-05 11:19:39.662987+0800 JFDemos[57239:5352156] co run2019-11-05 11:19:39.663110+0800 JFDemos[57239:5352156] test after2019-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](