先从苹果的framework说起
首先,让我们看一下苹果的框架。一般来说,除非另有声明,大多数类在默认情况下都不是线程安全的。对有些人来说,这是意料之中的事;对另一些人来说,这就很有意思了。
即使是有经验的iOS/Mac开发者,最常见的错误之一就是在后台线程上访问UIKit/AppKit的部分内容。在后台线程中设置图片等属性是非常容易犯的错误,因为无论如何它们的内容都是在后台从网络中被请求的。苹果的代码是性能优化的,如果你从不同的线程改变属性,它不会警告你。
在图像的情况下,一个常见的症状是,你的改变会被延迟接收。但如果两个线程同时设置图像,你的应用程序很可能会直接崩溃,因为当前设置的图像可能被释放两次。由于这与时间有关,通常会在你的客户使用时崩溃,而不是在开发期间。
没有官方的工具可以找到这样的错误,但是有一些小技巧可以很好地完成工作。UIKit Main Thread Grard 是一个小的源文件,它将修补对UIView的setNeedsLayout和setNeedsDisplay的任何调用,并在转发调用之前检查是否在主线程上执行。由于这两个方法是为很多UIKit设置器(包括图像)调用的,这将捕捉到很多与线程有关的错误。虽然这个技巧没有使用私有API,但我们不建议在生产应用中使用它--虽然在开发过程中它很好。
不让UIKit实现线程安全,是苹果方面有意识的设计决定。让它成为线程安全的并不能为你带来多少性能;事实上,它将使许多东西变得更慢。而UIKit与主线程绑定的事实使得编写并发程序和使用UIKit变得非常容易。你所要做的就是确保对UIKit的调用总是在主线程上进行的
为什么UIKit不是线程安全的?
确保像UIKit这样的大框架的线程安全将是一项重大的任务,并且要付出巨大的代价。将非原子属性改为原子属性只是所需变化中的一小部分。通常情况下,你想一次改变几个属性,然后才看到改变后的结果。为此,苹果将不得不公开一个类似于CoreData的performBlock:和performBlockAndWait:的方法来同步改变。如果你考虑到大多数对UIKit类的调用都是关于配置的,那么让它们成为线程安全的就更没有意义了。
然而,即使是那些不涉及配置的调用也会共享内部状态,因此不是线程安全的。如果你已经在iOS3.2和之前的黑暗时代写过应用程序,你肯定会在使用NSString的drawInRect:withFont:准备背景图片时遇到随机崩溃。值得庆幸的是,在iOS4中,苹果让大多数绘图方法和类(如UIColor和UIFont)可以在背景线程上使用。
不幸的是,苹果的文档在线程安全问题上有所欠缺。他们建议只在主线程上访问,即使是绘图方法,他们也没有明确保证线程安全--所以阅读iOS发行说明总是一个好主意。
在大多数情况下,UIKit类应该只在应用程序的主线程中使用。这对于派生自UIResponder的类或那些涉及以任何方式操纵你的应用程序的用户界面的类来说尤其如此。
The Deallocation Problem
在后台使用UIKit对象的另一个危险被称为 "The Deallocation Problem"。苹果在TN2109中概述了这个问题并提出了各种解决方案。这个问题是,UI对象应该在主线程上进行去分配,因为其中一些对象可能会在dealloc中对视图层次结构进行更改。正如我们所知,对UIKit的这种调用需要在主线程上发生。
由于二级线程、操作或块保留调用者是很常见的,所以这很容易出错,而且相当难以发现/修复。这也是AFNetworking中一个长期存在的错误issure,仅仅是因为没有很多人知道这个问题,而且--像往常一样--它表现为罕见的、难以产生的崩溃。坚持使用__weak和不在异步块/操作中访问ivars有帮助。
采集类(NSArray NSMutableArray)
苹果为iOS和Mac提供了一份很好的概述文件,列出了最常见的基础类的线程安全。一般来说,像NSArray这样的不可变类是线程安全的,而像NSMutableArray这样的可变类则不是。事实上,在不同的线程中使用它们是没有问题的,只要在一个队列中进行访问序列化。记住,方法可能会返回一个集合对象的可变体,即使它们声明其返回类型是不可变的。好的做法是写一些类似于return [array copy]的东西来确保返回的对象实际上是不可变的。
与Java等语言不同,Foundation框架没有提供线程安全的集合类。这实际上是非常合理的,因为在大多数情况下,你想在更高的位置应用你的锁,以避免过多的锁操作。一个值得注意的例外是缓存,一个易变的字典可能保存着不可变的数据--在这里,苹果在iOS4中添加了NSCache,它不仅可以锁定访问,还可以在低内存情况下清除其内容。
也就是说,在你的应用程序中,可能有一些有效的情况,线程安全的、可变的字典会很方便。多亏了类群的方法,很容易就能写出一个。
原子属性(Atomic Properties)
有没有想过,苹果是如何处理属性的原子设置/获取的?现在你可能已经听说过自旋锁、信号锁、锁、@synchronized--那么苹果在使用什么呢?值得庆幸的是,Objective-C的运行时间是公开的,所以我们可以看一下幕后的情况。 一个非原子的属性设置器可能看起来像这样。
//set方法
- (void)setUserName:(NSString *)userName {
//不同于
if(userName != _userName) {
//保留新值
[userName retain];
//释放旧值
[_userName release];
//重新赋值
_userName = userName。
}
}
这是手动保留/释放的变体;但是,ARC生成的代码看起来很相似。当我们看这段代码时,很明显,当setUserName:被同时调用时,这意味着麻烦。我们最终可能会释放_userName两次,这可能会破坏内存并导致难以发现的错误。
对于任何没有手动实现的属性,内部发生的情况是,编译器产生了对objc_setProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy)的调用。在我们的例子中,调用参数将看起来像这样。
objc_setProperty_non_gc(self, _cmd, (ptrdiff_t)(&_userName) - (ptrdiff_t)(self), userName, NO, NO);`
ptrdiff_t对你来说可能看起来很奇怪,但最终它只是简单的指针运算,因为一个Objective-C类只是另一个C结构。
objc_setProperty调用以下方法。
static inline void reallySetProperty(id self, SEL _cmd, id newValue,
ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
id oldValue;
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:NULL];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:NULL];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
_spin_lock(slotlock);
oldValue = *slot;
*slot = newValue;
_spin_unlock(slotlock);
}
objc_release(oldValue);
}
除了这个相当有趣的名字之外,这个方法实际上是相当直接的,它使用了PropertyLocks中128个可用的自旋锁之一。这是一个务实和快速的方法 -- 最坏的情况是,由于哈希碰撞,一个设置者可能不得不等待一个不相关的设置者完成。
虽然这些方法没有在任何公共头文件中声明,但有可能手动调用它们。我并不是说这是个好主意,但是知道这个很有意思,如果你想要原子属性并同时实现setter的话,可能会相当有用。
// Manually declare runtime methods.
extern void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset,
id newValue, BOOL atomic, BOOL shouldCopy);
extern id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset,
BOOL atomic);
#define PSTAtomicRetainedSet(dest, src) objc_setProperty(self, _cmd,
(ptrdiff_t)(&dest) - (ptrdiff_t)(self), src, YES, NO)
#define PSTAtomicAutoreleasedGet(src) objc_getProperty(self, _cmd,
(ptrdiff_t)(&src) - (ptrdiff_t)(self), YES)
请参考这个gist的完整片段,包括处理结构的代码。但请记住,我们不建议使用这个。
那么@synchronized呢?
你可能很好奇,为什么苹果不使用@synchronized(self)进行属性锁定,这是一个已经存在的运行时特性。一旦你看了源代码,你会发现还有很多事情要做。苹果正在使用多达三个锁定/解锁序列,部分原因是他们还添加了异常解锁。与快得多的自旋锁方法相比,这将是一个慢动作。因为设置属性通常是相当快的,所以自旋锁非常适合这项工作。@synchonized(self) 在你需要确保异常可以被抛出而不被代码死锁的时候是很好的。
你自己的类
仅仅使用原子属性并不能使你的类成为线程安全的。它只能保护你免受设置器中的竞赛条件的影响,但不能保护你的应用逻辑。考虑一下下面的片段。
if (self.contents) {
CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL,
(__bridge CFStringRef)self.contents, NULL);
// draw string
}
我在PSPDFKit的早期就犯了这个错误。当内容属性在检查后被设置为nil时,应用程序不时地以EXC_BAD_ACCESS崩溃。对这个问题的一个简单修复方法是捕获变量。
NSString *contents = self.contents;
if (contents) {
CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL,
(__bridge CFStringRef)contents, NULL);
// draw string
}
这将解决这里的问题,但在大多数情况下,它不是那么简单。想象一下,我们也有一个textColor属性,并且我们在一个线程上改变这两个属性。那么我们的渲染线程可能最终会使用新的内容和旧的颜色值,我们会得到一个奇怪的组合。这就是为什么Core Data将模型对象绑定到一个线程或队列的原因之一。
对于这个问题,没有一个放之四海而皆准的解决方案。使用不可变的模型是一个解决方案,但它也有自己的问题。另一种方法是将对现有对象的更改限制在主线程或特定队列中,并在工人线程上使用之前生成副本。我推荐Jonathan Sterling的关于Objective-C中的轻量级不变性的文章,以获得更多解决这个问题的想法。
简单的解决方案是使用@synchronize。其他的都很有可能让你陷入麻烦。更聪明的人在这样做时一次又一次地失败。
实用的线程安全设计
在试图使某些东西成为线程安全的之前,请认真思考是否有必要。确保它不是过早的优化。如果它像一个配置类,那么考虑线程安全就没有意义了。一个更好的方法是抛出一些断言来确保它被正确使用。
void PSPDFAssertIfNotMainThread(void) {
NSAssert(NSThread.isMainThread,
@"Error: Method needs to be called on the main thread. %@",
[NSThread callStackSymbols]);
}
现在有一些代码绝对应该是线程安全的,一个很好的例子是一个缓存类。一个好的方法是使用一个并发的dispatch_queue作为读/写锁,以最大限度地提高性能,并尽量只锁定真正需要的区域。一旦你开始使用多个队列来锁定不同的部分,事情就会变得非常棘手。
有时你也可以重写你的代码,这样就不需要特殊的锁了。考虑一下这个片段,它是一个多播委托的形式。(在许多情况下,使用NSNotifications会更好,但多播委托也有有效的用例===)
// header
@property (nonatomic, strong) NSMutableSet *delegates;
// in init
_delegateQueue = dispatch_queue_create("com.PSPDFKit.cacheDelegateQueue",
DISPATCH_QUEUE_CONCURRENT);
- (void)addDelegate:(id<PSPDFCacheDelegate>)delegate {
dispatch_barrier_async(_delegateQueue, ^{
[self.delegates addObject:delegate];
});
}
- (void)removeAllDelegates {
dispatch_barrier_async(_delegateQueue, ^{
self.delegates removeAllObjects];
});
}
- (void)callDelegateForX {
dispatch_sync(_delegateQueue, ^{
[self.delegates enumerateObjectsUsingBlock:^(id<PSPDFCacheDelegate> delegate, NSUInteger idx, BOOL *stop) {
// Call delegate
}];
});
除非addDelegate:或removeDelegate:每秒钟被调用上千次,否则更简单、更干净的方法是以下。
// header
@property (atomic, copy) NSSet *delegates;
- (void)addDelegate:(id<PSPDFCacheDelegate>)delegate {
@synchronized(self) {
self.delegates = [self.delegates setByAddingObject:delegate];
}
}
- (void)removeAllDelegates {
@synchronized(self) {
self.delegates = nil;
}
}
- (void)callDelegateForX {
[self.delegates enumerateObjectsUsingBlock:^(id<PSPDFCacheDelegate> delegate, NSUInteger idx, BOOL *stop) {
// Call delegate
}];
}
当然,这个例子有点结构化,人们可以简单地将变化限制在主线程中。但对于许多数据结构来说,在修改方法中创建不可变的副本可能是值得的,这样一般的应用逻辑就不必处理过多的锁。
GCD的陷阱
对于你的大多数锁需求,GCD是完美的。它很简单,很快速,而且它基于块的API使它更难意外地做不平衡的锁。然而,它也有一些缺陷,我们将在这里探讨其中的一些缺陷。
将GCD作为一个递归锁使用
GCD是一个队列,用于序列化对共享资源的访问。这可以用于锁定,但它与@synchronized 有很大不同。GCD队列不是可重入的--这将破坏队列的特性。许多人试图用dispatch_get_current_queue()来解决这个问题,这是个坏主意,苹果在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 是更好的解决方案。
用dispatch_async修复时间问题
在UIKit中遇到一些时间问题?大多数情况下,这将是一个完美的 "修复":"
dispatch_async(dispatch_get_main_queue(), ^{
// Some UIKit call that had timing issues but works fine
// in the next runloop.
//某个UIKit调用出现了时间问题,但在下一个运行循环中工作正常
[self updatePopoverSize];
});
不要这样做,相信我。当你的应用程序变大时,这将困扰着你。这是很难调试的,而且当你因为 "时间问题 "而需要调度越来越多时,事情很快就会崩溃。查看你的代码,找到调用的正确位置(例如viewWillAppear而不是viewDidLoad)。我的代码库中仍有一些这样的黑客,但大多数都有适当的文档,并提出了一个问题。
请记住,这并不是真正针对GCD的,而是一种常见的反模式,只是用GCD很容易做到。你可以对performSelector:afterDelay运用同样的智慧。,其中延迟是下一个运行循环的0.f。
在性能关键代码中混合使用dispatch_sync和dispatch_async
这个问题花了我不少时间才弄明白。在PSPDFKit中,有一个缓存类,它使用LRU列表来跟踪图像访问。当你滚动浏览页面时,这个类会被大量调用。最初的实现是使用dispatch_sync来进行可用性访问,并使用dispatch_async来更新LRU位置。这导致了帧率与60 FPS的目标相差甚远。
当你的应用程序中运行的其他代码阻塞了GCD的线程时,可能需要一段时间直到调度管理器找到一个线程来执行dispatch_async代码--在此之前,你的同步调用将被阻塞。即使像这个例子一样,异步情况下的执行顺序并不重要,也没有简单的方法来告诉GCD。读/写锁对你没有帮助,因为异步进程肯定需要进行屏障写入,而在这期间你的所有读者都会被锁定。教训:dispatch_async如果被误用会很昂贵。在使用它进行锁定时要小心。
使用dispatch_async来调度内存密集型操作
我们已经谈了很多关于NSOperations的内容,而且通常使用更高级别的API是个好主意。如果你处理的是进行内存密集型操作的工作块,这一点尤其正确。
在PSPDFKit的一个旧版本中,我使用一个GCD队列来调度将缓存的JPG图片写入磁盘。当iPad的视网膜出现时,这就开始造成了麻烦。分辨率增加了一倍,对图像数据进行编码的时间比渲染的时间要长得多。因此,操作堆积在队列中,当系统繁忙时,它可能因内存耗尽而崩溃。
没有办法看到有多少操作在排队(除非你手动添加代码来跟踪),也没有内置的方法来取消低内存通知下的操作。切换到NSOperations使代码更容易调试,并允许所有这些都不需要编写手动管理代码。
当然也有一些注意事项;例如,你不能在你的NSOperationQueue上设置一个目标队列(像DISPATCH_QUEUE_PRIORITY_BACKGROUND,用于节流的I/O)。但这对调试性来说是个小代价,而且它还能防止你遇到像优先级倒置这样的问题。我甚至建议不要使用漂亮的NSBlockOperation API,建议使用NSOperation的真正子类,包括描述的实现。这是更多的工作,但在以后,有一种方法可以打印所有正在运行/正在进行的操作是非常有用的。
补充
本文章翻译下面链接地址