异步编程是所有软件开发中都需要面临的一个问题, 它让软件的运行效率更高, 但也可能会让问题变得更复杂.
网络请求等异步 I/O 操作在日常开发中都很常见, 在 iOS 开发中, Apple 提供了很强大方便的 API 来让我们实现网络请求. 从 iOS 7 / macOS 10.9 开始, Apple 提供了一套全新的 URL 加载系统 -- URL Session, 这使得网络请求在 Apple 的系统上更加地统一和便捷.
在 URL Session 出现之前, 开发者需要通过 NSURLConnection 这个 API 来进行网络请求, 而很多第三方库可能会采用封装 CFNetwork 的方式来实现. 而 URL Session 出现之后几乎所有第三方库都转而采用它. URL Session 的所有 API 都有一个很明显的特点 -- 异步, 启动请求后所有的状态变化和事件都通过 block 或 delegate 方法来处理的.
完全异步之后的网络请求很容易会有这样的问题, 如下面这段 snippet:
- [[session dataTaskWithURL:<#first endpoint URL#>
- completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
- NSURL *nextAPIEndpoint = <#next endpoint URL based on previous API response#>;
- [[session dataTaskWithURL:nextAPIEndpoint
- completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response,
- NSError * _Nullable error) {
- <#handle the final result#>
- }] resume];
- }] resume];
代码很直观但是问题也很明显, 当我们有很多请求是这样的依赖关系的时候, 就出现了回调陷阱. 我们十分不喜欢造火箭代码, 于是就产生了许多解决这个问题的方案, 总结一下无非有这几种:
Promise 化
async-await 模式
同步 API
Promise 化
第一种, 对于 Promise, 相信做前端的小伙伴一定比我还熟悉这个异步模式, 它是回调的另一种写法, 将原本需要放到回调里的东西放到 then handler 中, then handler 可能会返回一个新的 Promise,then 的返回值也是一个 Promise, 这样通过一种链式的写法就解决了回调陷阱的问题.
async-await 模式
第二种也是前端或者说 JavaScript 这类单线程模型语言常用的异步编程模式, 用 Node.js 举例, 它的 JavaScript 引擎 v8 运行在单线程中, 当出现 I/O 操作时, Node.js 通过 C++ 层将相应的操作交给 libuv,libuv 根据操作系统选择采用 epoll 或 select 等多路复用 I/O 接口来同时监听多个 I/O 操作的事件, 当有 I/O 事件到达时便会唤醒, 然后转到 JavaScript 层中去做处理. 相同地, 这类语言或框架也会使用回调的方式来处理 I/O, 而语言层面又有协程的支持, 做一下 CPS https://en.wikipedia.org/wiki/Continuation-passing_style 变换即可将这种回调的写法变成同步的写法, 就像这样:
- function callbackStyle() {
- io.doSomething('some args', function (res) {
- console.log(res);
- });
- }
- async function asyncStyle() {
- const res = await io.doSomething('some args');
- console.log(res);
- }
async-await 可以从语言层面做支持, 也可以用协程来模拟, 这就需要对原来的异步方法做 thunkify 的处理, 即:
- const _doSomething = thunkify(io.doSomething)
- const thunk = _doSomething('some args'); // returns a function that receive a callback.
- thunk(function (res) {
- console.log(res);
- });
这样就能利用协程来模拟 async-await 了, 详细的原理可以参考 https://www.npmjs.com/package/co 这个异步库, 本文就不详细赘述其实现原理了.
上面两种方法相信大家都看出共同点了, 那就是 I/O 操作原本就是异步操作, 我们添加一层抽象时并没有改变任何平台特性, 没有把一个线程变成两个线程, 也没有把原本的异步方法变成同步方法, 这就是零开销抽象(Zero-Cost Abstractions).
同步 API
了解了这些, 我们再着重来看第三种方法, 所谓的同步 API 就是将原本的异步操作通过一定的手段变成一个同步操作. 首先我们来看一个例子:
- dispatch_semaphore_t sem = dispatch_semaphore_create(0);
- [[session dataTaskWithURL:<#some URL#>
- completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
- dispatch_semaphore_signal(sem);
- }] resume];
- dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
原本 NSURLSession API 是一个异步的 API, 通过信号量或条件变量就可以将其变成一个同步的 API. 它之所以可以 work, 是因为 NSURLSession 内部会开启一个新的线程和 run loop 来处理 I/O 等事务. 这种方法放到 UI 相关的 API 或 Node.js 这类单线程引擎中就会出问题, 因为死锁了.
通过这种手段将异步 API 变成同步 API 之后, 我们就可以轻松地解决请求依赖的问题了, 因为代码的执行变成了顺序的. 然而同步 API 在主线程上肯定不行, 会阻塞 UI, 没关系, native 可以随便开线程, 把这些请求再丢到一个新的线程中就完美解决了.
问题是解决了, 但这种写法真的好吗? 其实不见得. 我们来算一下, 首先 NSURLSession 自己一定是会创建 (或占用, 因为严格讲是 libdispatch 来管理线程的创建) 一个线程的, 然后网络请求的整个过程又占用了一个线程, 最后再加上一个主线程. 为了解决请求依赖问题, 本来可以用两个线程, 我们用了三个线程. 线程真的跟 Goroutine, 协程不一样, 它的开销还是比较大的.
What about NSOperation?
其实我们完全可以借鉴前两种方法, Apple 给我们提供了一个异步任务管理的 API -- NSOperation, 它支持依赖管理, 但注意与 semaphore 的区别, 它并不是通过阻塞的方式来等待依赖任务的完成, 而是在依赖任务完成时再执行.
NSOperation 支持两种任务, 同步和异步任务. 同步任务是指其主要代码会阻塞当前线程; 而异步任务是指其主要代码会自行开启线程等, 不阻塞当前线程, 调用者需要通过 KVO 的方式来监听其是否完成.
创建一个同步 NSOperation 子类很简单, 只要重写其 main 方法即可, 这时调用 start 方法启动时, 线程会阻塞等待 main 方法执行完毕. 你也可以通过 NSOperationQueue, 把它分发给指定的队列去执行.
创建一个异步 NSOperation 子类可能需要做更多的工作, 我们需要至少重写 start,isExecuting,isFinished,isAsynchronous 方法. 在 start 方法中通过开启线程, 调用其他异步 API 的方式启动这个任务, 同时你需要自己触发 KVO 来通知框架管理依赖.
对于有依赖的 NSOperation, 在其所有依赖没有全部完成前 (ready 属性不为 YES) 是不能调用 start 的, 默认实现会抛出异常, 但是当我们将 NSOperation 对象加入 NSOperationQueue 后, 框架内部会通过 KVO 监听 ready, 来在合适的时机来启动任务.
如下图的调用栈可以看出这一点:
许多开发者会使用 NSOperation 来管理网络请求, 但是通常都会采用同步 NSOperation 的方式来实现, 即 main 方法中启动网络请求, 然后通过信号量等方式阻塞 main 方法. 使用时把 NSOperation 对象放入一个非 main queue 的 NSOperationQueue 中, 再用其依赖管理机制控制顺序. 这是不正确的做法.
正确的做法是编写一个异步 NSOperation, 在 start 方法中启动 NSURLSessionTask, 然后在完成时的回调里设置 finished 和 executing 的值, 并且使用的时候直接添加到 main queue 中. 这样我们就能做到既使用了 NSOperation 管理依赖, 又没有开启多余不必要的线程.
总结
写了这么多, 其实主要观点就一个, 使用一个 API 时如果它本身就是异步的, 就尽量不要把它先变成同步再控制依赖或执行顺序. 本文用网络请求举例, 其实可以推广到所有的异步 API 中去.
- EOF -
来源: https://juejin.im/entry/5b77ed1551882542f8245070