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](

阅读 731

推荐阅读
二师兄的博客
用户专栏

个人博客,希望这次能多写点

4 人关注
40 篇文章
专栏主页
目录