共计 7624 个字符,预计需要花费 20 分钟才能阅读完成。
这几年异步编程是个比较热门的话题。
今天我们在 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](