Apple没有把 UIKit 设计为线程安全的类是有意为之的,将其打造为线程安全的话会使很多操作变慢。而事实上 UIKit 是和主线程绑定的,这一特点使得编写并发程序以及使用 UIKit 十分容易的,你唯一需要确保的就是对于 UIKit 的调用总是在主线程中来进行。
对于一个像 UIKit 这样的大型框架,确保它的线程安全将会带来巨大的工作量和成本。将 non-atomic 的属性变为 atomic 的属性只不过是需要做的变化里的微不足道的一小部分。通常来说,你需要同时改变若干个属性,才能看到它所带来的结果。为了解决这个问题,苹果可能不得不提供像 Core Data 中的 performBlock:
和 performBlockAndWait:
那样类似的方法来同步变更。另外你想想看,绝大多数对 UIKit 类的调用其实都是以配置为目的的,这使得将 UIKit 改为线程安全这件事情更显得毫无意义了。
然而即使是那些与配置共享的内部状态之类事情无关的调用,其实也不是线程安全的。如果你做过 iOS 3.2 或之前的黑暗年代的 app 开发的话,你肯定有过一边在后台准备图像时一边使用 NSString 的 drawInRect:withFont:
时的随机崩溃的经历。值得庆幸的事,在 iOS 4 中 苹果将大部分绘图的方法和诸如 UIColor
和 UIFont
这样的类改写为了后台线程可用。
但不幸的是 Apple 在线程安全方面的文档是极度匮乏的。他们推荐只访问主线程,并且甚至是绘图方法他们都没有明确地表示保证线程安全。因此在阅读文档的同时,去读读 iOS 版本更新说明会是一个很好的选择。
对于大多数情况来说,UIKit 类确实只应该用在应用的主线程中。这对于那些继承自 UIResponder 的类以及那些操作你的应用的用户界面的类来说,不管如何都是很正确的。
另一个在后台使用 UIKit 对象的的危险之处在于“内存回收问题”。Apple 在技术笔记 TN2109 中概述了这个问题,并提供了多种解决方案。这个问题其实是要求 UI 对象应该在主线程中被回收,因为在它们的 dealloc
方法被调用回收的时候,可能会去改变 view 的结构关系,而如我们所知,这种操作应该放在主线程来进行。
因为调用者被其他线程持有是非常常见的(不管是由于 operation 还是 block 所导致的),这也是很容易犯错并且难以被修正的问题。在 AFNetworking 中也一直长久存在这样的 bug,但是由于其自身的隐蔽性而鲜为人知,也很难重现其所造成的崩溃。在异步的 block 或者操作中一致使用 __weak
,并且不去直接访问局部变量会对避开这类问题有所帮助。
对于大多数上锁的需求来说,GCD 就足够好了。它简单迅速,并且基于 block 的 API 使得粗心大意造成非平衡锁操作的概率下降了不少。然后,GCD 中还是有不少陷阱,我们在这里探索一下其中的一些。
GCD 是一个对共享资源的访问进行串行化的队列。这个特性可以被当作锁来使用,但实际上它和 @synchronized
有很大区别。 GCD队列并非是可重入的,因为这将破坏队列的特性。很多有试图使用 dispatch_get_current_queue()
来绕开这个限制,但是这是一个糟糕的做法,Apple 在 iOS6 中将这个方法标记为废弃,自然也是有自己的理由。
// This is a bad idea.
inline void pst_dispatch_sync_reentrant(dispatch_queue_t queue,
dispatch_block_t block)
{
dispatch_get_current_queue() == queue ? block()
: dispatch_sync(queue, block);
}
对当前的队列进行测试也许在简单情况下可以行得通,但是一旦你的代码变得复杂一些,并且你可能有多个队列在同时被锁住的情况下,这种方法很快就悲剧了。一旦这种情况发生,几乎可以肯定的是你会遇到死锁。当然,你可以使用dispatch_get_specific()
,这将截断整个队列结构,从而对某个特定的队列进行测试。要这么做的话,你还得为了在队列中附加标志队列的元数据,而去写自定义的队列构造函数。嘛,最好别这么做。其实在实用中,使用 NSRecursiveLock
会是一个更好的选择。
在使用 UIKit 的时候遇到了一些时序上的麻烦?很多时候,这样进行“修正”看来非常完美:
dispatch_async(dispatch_get_main_queue(), ^{
// Some UIKit call that had timing issues but works fine
// in the next runloop.
[self updatePopoverSize];
});
千万别这么做!相信我,这种做法将会在之后你的 app 规模大一些的时候让你找不着北。这种代码非常难以调试,并且你很快就会陷入用更多的 dispatch 来修复所谓的莫名其妙的"时序问题"。审视你的代码,并且找到合适的地方来进行调用(比如在 viewWillAppear 里调用,而不是 viewDidLoad 之类的)才是解决这个问题的正确做法。我在自己的代码中也还留有一些这样的 hack,但是我为它们基本都做了正确的文档工作,并且对应的 issue 也被一一记录过。
记住这不是真正的 GCD 特性,而只是一个在 GCD 下很容易实现的常见反面模式。事实上你可以使用performSelector:afterDelay:
方法来实现同样的操作,其中 delay 是在对应时间后的 runloop。
这个问题我花了好久来研究。在 PSPDFKit 中有一个使用了 LRU(最久未使用)算法列表的缓存类来记录对图片的访问。当你在页面中滚动时,这个方法将被调用非常多次。最初的实现使用了 dispatch_sync
来进行实际有效的访问,使用 dispatch_async
来更新 LRU 列表的位置。这导致了帧数远低于原来的 60 帧的目标。
当你的 app 中的其他运行的代码阻挡了 GCD 线程的时候,dispatch manager 需要花时间去寻找能够执行 dispatch_async 代码的线程,这有时候会花费一点时间。在找到合适的执行线程之前,你的同步调用就会被 block 住了。其实在这个例子中,异步情况的执行顺序并不是很重要,但没有能将这件事情告诉 GCD 的好办法。读/写锁这里并不能起到什么作用,因为在异步操作中基本上一定会需要进行顺序写入,而在此过程中读操作将被阻塞住。如果误用了 dispatch_async
代价将会是非常惨重的。在将它用作锁的时候,一定要非常小心。
我们已经谈论了很多关于 NSOperations 的话题了,一般情况下,使用这个更高层级的 API 会是一个好主意。当你要处理一段内存敏感的操作的代码块时,这个优势尤为突出、
在 PSPDFKit 的老版本中,我用了 GCD 队列来将已缓存的 JPG 图片写到磁盘中。当 retina 的 iPad 问世之后,这个操作出现了问题。ß因为分辨率翻倍了,相比渲染这张图片,将它编码花费的时间要长得多。所以,操作堆积在了队列中,当系统繁忙时,甚至有可能因为内存耗尽而崩溃。
我们没有办法追踪有多少个操作在队列中等待运行(除非你手动添加了追踪这个的代码),我们也没有现成的方法来在接收到低内存通告的时候来取消操作、这时候,切换到 NSOperations 可以使代码变得容易调试得多,并且允许我们在不添加手动管理的代码的情况下,做到对操作的追踪和取消。
当然也有一些不好的地方,比如你不能在你的 NSOperationQueue
中设置目标队列(就像 DISPATCH_QUEUE_PRIORITY_BACKGROUND
之于 缓速 I/O 那样)。但这只是为了可调试性的一点小代价,而事实上这也帮助你避免遇到优先级反转的问题。我甚至不推荐直接使用已经包装好的 NSBlockOperation
的 API,而是建议使用一个 NSOperation 的真正的子类,包括实现其 description。诚然,这样做工作量会大一些,但是能输出所有运行中/准备运行的操作是及其有用的。