这几年异步编程是个比较热门的话题。
今天我们在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 *)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 { }
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 *)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](