文档链接:developer.apple.com/library/arc…
应用程序中存在多个线程带来了潜在问题:多个执行线程如何安全地访问资源。修改同一资源的两个线程可能会以意想不到的方式相互干扰。例如,一个线程可能会覆盖另一个线程的更改,或者将应用程序置于未知且可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题相对容易跟踪和修复。相反,损坏可能会导致细微的错误,直到很久以后才会显现出来,或者这些错误可能需要对基础的编码假设进行重大检查。
在线程安全方面,好的设计是最佳保护。避免共享资源并最小化线程之间的交互,使这些线程相互干扰的可能性降低。但是,并非总是可以实现完全无干扰的设计。如果线程必须进行交互,则需要使用同步工具来确保它们在交互时的安全。
OS X 和 iOS 提供了许多同步工具供你使用,从提供互斥访问的工具到在应用程序中正确排序事件的工具。以下各节介绍了这些工具,以及如何在代码中使用它们对程序资源进行安全访问。
同步工具
为了防止不同的线程意外更改数据,可以将应用程序设计为不存在同步问题,也可以使用同步工具。尽管最好完全避免同步问题,但有时可能做不到。以下各节介绍了可供你使用的同步工具的基本类别。
原子操作
原子操作是一种简单的同步形式,适用于简单的数据类型。原子操作的优点是它们不会阻塞竞争线程。对于简单的操作(例如增加计数器变量),这比使用锁可以带来更好的性能。
OS X和 iOS 包含许多操作,可以对 32 位和 64 位值执行基本的数学和逻辑运算。这些操作包括比较和交换(compare-and-swap),测试和设置(test-and-set)以及测试和清除(test-and-clear)操作的原子版本。有关受支持的原子操作的列表,请参见/usr/include/libkern/OSAtomic.h头文件或原子手册页。
Memory Barrier 和 volatile 变量
为了获得最佳性能,编译器经常对汇编级指令进行重新排序,以使处理器的指令流水线尽可能完整。作为此优化的一部分,当编译器认为这样做不会产生不正确的数据时,可能会对访问主内存的指令进行重新排序。不幸的是,编译器并非总是能够检测到所有与内存相关的操作。如果看似独立的变量实际上相互影响,则编译器优化可能会以错误的顺序更新这些变量,从而产生可能不正确的结果。
memory barrier 是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。memory barrier 的作用类似于围栏,迫使处理器先完成位于 barrier 前面的所有加载和存储操作,再执行位于 barrier 之后的加载和存储操作。memory barrier 常用于确保一个线程(但另一线程可见)的内存操作始终按预期的顺序发生。在这种情况下缺少 memory barrier 可能会使其他线程看到看似不可能的结果。 (有关示例,请参阅Wikipedia条目以了解。)要使用memory barrier ,只需在代码中的适当位置调用OSMemoryBarrier函数。
volatile 变量将另一种类型的内存约束应用于单个变量。编译器通常通过将变量的值加载到寄存器中来优化代码。对于局部变量,这通常不是问题。但是,如果从另一个线程可见该变量,则这种优化可能会阻止另一个线程注意到对该变量的任何更改。将volatile关键字应用于变量会强制编译器每次使用时从内存中加载该变量。如果可能有编译器无法检测到的外部源随时更改变量值,则可以将变量声明为volatile。
由于 memory barrier 和 volatile 变量都会减少编译器可执行的优化次数,因此应谨慎使用它们,并且仅在需要确保正确性的地方使用它们。有关使用 memory barrier 的信息,请参见 OSMemoryBarrier 手册页。
锁
锁是最常用的同步工具之一。你可以使用锁来保护代码的关键部分,即一段只能同时允许一个线程访问的代码。例如,操纵特定的数据结构或同时最多支持一个客户端使用的某些资源。通过在这段代码外面加锁,可以排除其他线程进行可能影响代码正确性的更改。
锁的类型
| 类型 | 描述 |
|---|---|
| 互斥锁 | 互斥(mutex)锁充当资源周围的保护性屏障。互斥锁是一种信号量,它一次只能授予对一个线程的访问权限。如果正在使用互斥锁,而另一个线程试图获取该互斥锁,则该线程将阻塞,直到该互斥锁被其原始持有者释放为止。如果多个线程竞争同一个互斥锁,则一次只能访问一个。 |
| 递归锁 | 递归(recursive)锁是互斥锁的一种变体。递归锁允许单个线程在释放它之前多次获取该锁。其他线程将保持阻塞状态,直到锁的所有者释放锁次数等于获取锁次数时。递归锁主要在递归迭代期间使用,但也可以在多个方法各自需要分别获取锁的情况下使用。 |
| 读写锁 | 读写锁也称为共享独占锁。这种类型的锁通常用于较大规模的操作,如果经常读取受保护的数据结构并仅偶尔进行修改,则可以显着提高性能。在正常操作期间,多个读取器可以同时访问数据结构。但是,当线程要写入结构时,它将阻塞,直到所有读取器都释放锁为止,此时,它获取了锁并可以更新结构。当写入线程正在等待锁定时,新的读取器线程将阻塞,直到写入线程完成。系统仅支持使用 POSIX 线程的读写锁。有关如何使用这些锁的更多信息,请参见pthread手册页。 |
| 分布式锁 | 分布式锁在进程级别提供互斥访问。与真正的互斥锁不同,分布式锁不会阻止进程或阻止其运行。它仅报告锁何时繁忙,并让进程决定如何进行。 |
| 自旋锁 | 自旋(spin)锁反复轮询其锁定条件,直到该条件变为true。自旋锁最常用于多处理器系统,其中锁的预期等待时间很小。在这些情况下,轮询通常比阻塞线程更有效,阻塞需要上下文切换和线程数据结构的更新。由于它们具有轮询性质,因此系统不提供自旋锁的任何实现,但是你可以在特定情况下轻松地实现自旋锁。有关在内核中实现自旋锁的信息,请参见内核编程指南。 |
| 双重检查锁 | 双重检查锁是通过在获取锁之前测试锁定条件来减少获取锁的开销的尝试。由于双重检查的锁可能不安全,因此系统不会为它们提供明确的支持,因此不建议使用它们。 |
注意:大多数类型的锁还包含一个 memory barrier,以确保在进入关键部分之前完成所有先前的加载和存储指令。
有关如何使用锁的信息,请参见使用锁。
条件(Condition)
条件是信号量的另一种类型,当某个条件为真时,它允许线程彼此发信号。条件通常用于指示资源的可用性或确保任务以特定顺序执行。当线程测试条件时,除非该条件已经为真,否则它将阻塞。它保持阻塞状态,直到其他线程显式更改条件并发出信号为止。条件和互斥锁之间的区别在于,可以允许多个线程同时访问该条件。条件更多是看门人,它根据某些指定的标准让不同的线程通过门。
使用条件的一种方法是管理未决事件池。当事件队列中有事件时,事件队列将使用条件变量来通知等待线程。如果一个事件到达,则队列将适当地发出条件信号。如果一个线程已经在等待,它将被唤醒,随后它将把事件从队列中拉出并进行处理。如果两个事件几乎同时进入队列,则队列将两次发出信号通知状态以唤醒两个线程。
系统利用几种不同技术对条件提供支持。条件的正确实现需要仔细的编码,因此,在将其用于自己的代码之前,应先查看使用条件中的示例。
执行选择器例程(Selector Routine)
Cocoa 应用程序有一种以同步方式将消息传递到单个线程的便捷方法。 NSObject类声明用于在应用程序的活动线程之一上执行选择器的方法。这些方法让你的线程可以异步传递消息,并确保它们将由目标线程同步执行。例如,可以使用执行选择器消息将结果从分布式计算传递到应用程序的主线程或指定的协调器(coordinator)线程(?)。每个执行选择器的请求都在目标线程的 run loop 中排队,然后按照接收顺序对请求进行顺序处理。
有关执行选择器例程的摘要以及有关如何使用它们的更多信息,请参见Cocoa Perform Selector Sources。
【译者注】 这四个 API 定义在 NSThread.h 中,但是其实是 NSObject 的分类方法:
@interface NSObject (NSThreadPerformAdditions)
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
// equivalent to the first method with kCFRunLoopCommonModes
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
// equivalent to the first method with kCFRunLoopCommonModes
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
@end
简单的例子:
- (void)doSomething {
NSLog(@"3...");
// some task...
NSLog(@"4...");
}
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"1...");
[self performSelectorOnMainThread:@selector(doSomething) withObject:nil waitUntilDone:YES];
NSLog(@"2...");
}
如果传YES,打印 1342;NO打印 1234。
一个使用的实例是 YYKit:
// NSNotificationCenter+YYAdd.m
- (void)postNotificationOnMainThread:(NSNotification *)notification waitUntilDone:(BOOL)wait {
if (pthread_main_np()) return [self postNotification:notification];
[[self class] performSelectorOnMainThread:@selector(_yy_postNotification:) withObject:notification waitUntilDone:wait];
}
+ (void)_yy_postNotification:(NSNotification *)notification {
[[self defaultCenter] postNotification:notification];
}
同样,这几个 API 定义在 NSRunLoop.h 中但同样是 NSObject 的分类方法
@interface NSObject (NSDelayedPerforming)
- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay inModes:(NSArray<NSRunLoopMode> *)modes;
- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;
+ (void)cancelPreviousPerformRequestsWithTarget:(id)aTarget selector:(SEL)aSelector object:(nullable id)anArgument;
+ (void)cancelPreviousPerformRequestsWithTarget:(id)aTarget;
@end
同步开销与性能
同步有助于确保代码的正确性,但这样做会牺牲性能。即使在无争议的情况下,使用同步工具也会带来延迟。锁和原子操作通常涉及 memory barrier 和内核级同步的使用,以确保代码得到适当的保护。如果存在争用锁的情况,你的线程可能会阻塞并经历更大的延迟。
在设计并发任务时,正确性始终是最重要的因素,但是你也应该考虑性能因素。在多个线程下可以正确执行的代码,但是相比放在单个线程上运行还要慢,这几乎算不上是改进。
如果要改进现有的单线程应用程序,则应始终对关键任务的性能进行一组基准测量。添加其他线程后,你应该对那些相同的任务进行新的测量,并将多线程案例与单线程案例的性能进行比较。如果在调整代码后,线程无法改善性能,那你可能就得重新考虑特定的实现或线程的使用了。
有关性能和用于收集指标的工具的信息,请参阅性能概述。有关锁和原子操作成本的特定信息,请参阅线程成本。
线程安全和信号
对于线程化应用程序,没有什么比处理信号问题引起更多的恐惧或困惑了。信号是一种低级 BSD 机制,可用于将信息传递给流程或以某种方式操纵它。一些程序使用信号来检测某些事件,例如子进程的死亡。系统使用信号终止失控过程并传达其他类型的信息。
信号的问题不是它们的作用,而是应用程序具有多个线程时的行为。在单线程应用程序中,所有信号处理程序都在主线程上运行。在多线程应用程序中,与特定硬件错误(例如非法指令)无关的信号将传递到当时正在运行的任何线程。如果同时运行多个线程,则将信号传递给系统碰巧的任何一个。换句话说,信号可以传递到应用程序的任何线程。
在应用程序中实现信号处理程序的第一条规则是避免假设哪个线程正在处理信号。如果特定线程要处理给定的信号,则需要制定某种方法在信号到达时通知该线程。你不能仅仅假设从该线程安装信号处理程序将导致信号传递到同一线程。
有关信号和安装信号处理程序的更多信息,请参见信号和迁移手册页。 【未校对】
线程安全设计的建议
同步工具是使代码线程安全的一种有用方法,但不是万能药。与非线程性能相比,使用过多的锁和其他类型的同步原语实际上会降低应用程序的线程性能。在安全和性能之间找到合适的平衡是一门需要经验的艺术。以下各节提供了一些技巧,可以帮助你为应用程序选择适当的同步级别。
完全避免同步
对于你正在做的任何新项目,甚至对于现有项目,设计代码和数据结构来避免同步需要都是最佳的解决方案。尽管锁和其他同步工具很有用,但它们确实会影响任何应用程序的性能。而且,如果总体设计导致特定资源之间的争用较高,则您的线程可能会等待更长的时间。
实施并发的最好方法是减少并发任务之间的交互和内部依赖。如果每个任务都在其自己的私有数据集上运行,则无需使用锁来保护该数据。即使在两个任务确实共享一个公共数据集的情况下,你也可以考虑如何对该数据集进行分区或为每个任务提供自己的副本。当然,复制数据集也有成本,因此在做出决定之前,你必须权衡这些成本和同步成本。
了解同步的局限性
同步工具仅在应用程序中的所有线程一致使用时才有效。如果创建互斥量以限制对特定资源的访问,则所有线程在尝试操作该资源之前必须获取相同的互斥量。否则会破坏互斥锁提供的保护,这是程序员的错误。
小心对代码正确性的威胁
使用锁和 memory barrier 时,应始终仔细考虑它们在代码中的位置。即使是看似位置正确的锁,实际上也会使你陷入一种错误的安全感。以下一系列示例试图通过指出看似无害的代码中的缺陷来说明这个问题。基本前提是你有一个包含一组不可变对象的可变数组。假设你要调用数组中第一个对象的方法,可以使用以下代码进行操作:
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];
[anObject doSomething];
因为数组是可变的,在数组上加锁可以防止其他线程修改,直到你得到所需的对象。因为你检索的对象本身是不变的,不需要在调用doSomething的方法上加锁。
但这个示例其实有问题。如果在执行doSomething方法之前释放锁,同时另一个线程进入并从数组中删除所有对象,将会发生什么?在没有垃圾回收的应用程序中,可以释放代码所持有的对象,而使anObject指向无效的内存地址。要解决此问题,你可能决定简单地重新排列现有代码并在调用doSomething之后释放锁,如下所示:
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];
通过将doSomething调用移入锁内,代码可确保在调用该方法时该对象仍然有效。不幸的是,如果doSomething方法需要花费很长时间执行,这可能会导致你的代码长时间保持锁定,从而可能会导致性能瓶颈。
代码的问题不是关键区域定义不正确,而是没有理解实际问题所在。真正的问题是仅由其他线程的存在触发的内存管理问题。因为它可以由另一个线程释放,所以更好的解决方案是在释放锁之前保留anObject。该解决方案解决了对象被释放的实际问题,并且这样做不会造成潜在的性能损失。
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject retain];
[arrayLock unlock];
[anObject doSomething];
[anObject release];
【译者注】
这个文档应该是很老的了,所以还停留在 MRC 的年代。对于 ARC 实际上已经不需要retain /release了,第一段代码本身就没有问题。
尽管前面的示例本质上非常简单,但是它们确实说明了非常重要的一点。当涉及到正确性时,你必须在显然的问题上进行更进一步思考。内存管理和设计的其他方面也可能会受到多个线程的影响,因此你必须预先考虑这些问题。另外,你应该始终假设编译器在安全方面会做最坏的事情。这种了解和警惕应有助于避免潜在的问题,并确保你的代码正常运行。
有关如何使程序具有线程安全性的其他示例,请参见线程安全性摘要。
当心死锁和活锁
每当线程试图同时获取多个锁时,都有可能发生死锁。当两个不同的线程持有另一个线程需要的锁,然后尝试获取另一个线程持有的锁时,就会发生死锁。结果是每个线程都永久阻塞,因为它永远无法获取另一个锁。
活锁类似于死锁,当两个线程竞争同一组资源时发生。在活锁情况下,线程放弃其第一把锁,以尝试获取其第二把锁。一旦获得第二个锁,它将返回并尝试再次获取第一个锁。它之所以锁定,是因为它花费了所有时间释放一个锁并试图获取另一个锁,而不是进行任何实际工作。
避免出现死锁和活锁情况的最佳方法是一次只锁定一个。如果一次必须获取多个锁,则应确保其他线程不要尝试执行类似的操作。
正确使用 valotile 变量
如果您已经在使用互斥锁来保护代码部分,则不要自动假定您需要使用volatile关键字来保护该部分中的重要变量。互斥锁包括一个内存屏障,以确保正确地排序装入和存储操作。将volatile关键字添加到关键部分内的变量后,每次访问该值时都会强制将其从内存中加载。两种同步技术的组合在特定情况下可能是必需的,但也会导致明显的性能损失。如果仅互斥量足以保护变量,请省略volatile关键字。
同样重要的是,不要使用易失性变量来避免使用互斥体。通常,互斥锁和其他同步机制是比易失性变量更好的方法来保护数据结构的完整性。 volatile关键字仅确保从内存中加载变量,而不是将其存储在寄存器中。它不能确保您的代码正确访问该变量。
【未校对】
使用原子操作
非阻塞同步是一种执行某些类型的操作并避免锁定开销的方式。尽管锁是同步两个线程的有效方法,但是即使在无争议的情况下,获取锁也是相对昂贵的操作。相比之下,许多原子操作仅需花费一小部分时间即可完成,并且与锁一样有效。
原子运算让你可以对 32 位或 64 位值执行简单的数学和逻辑运算。这些操作依靠特殊的硬件指令(和可选的 memory barrier)来确保给定的操作完成后才能再次访问受影响的内存。在多线程情况下,应始终使用包含 memory barrier 的原子操作来确保内存在线程之间正确同步。
表格列出了可用的原子数学和逻辑运算以及相应的函数名称。这些函数都在/usr/include/libkern/OSAtomic.h头文件中声明,在这里您还可以找到完整的语法。这些功能的64位版本仅在64位进程中可用。

这一组 API 较少使用。在 YYKit 中同样有用到。其中有这样一个哨兵类(YYSentinel):
/**
YYSentinel is a thread safe incrementing counter.
It may be used in some multi-threaded situation.
*/
@interface YYSentinel : NSObject
/// Returns the current value of the counter.
@property (readonly) int32_t value;
/// Increase the value atomically.
/// @return The new value.
- (int32_t)increase;
@end
@implementation YYSentinel {
int32_t _value;
}
- (int32_t)value {
return _value;
}
- (int32_t)increase {
return OSAtomicIncrement32(&_value);
}
@end
这个类被用在 YYAsyncLayer 当中。
另外在 YYDispatchQueuePool 中有这样的用法
typedef struct {
const char *name;
void **queues;
uint32_t queueCount;
int32_t counter;
} YYDispatchContext;
static dispatch_queue_t YYDispatchContextGetQueue(YYDispatchContext *context) {
uint32_t counter = (uint32_t)OSAtomicIncrement32(&context->counter);
void *queue = context->queues[counter % context->queueCount];
return (__bridge dispatch_queue_t)(queue);
}
//外部调用
dispatch_queue_t YYDispatchQueueGetForQOS(NSQualityOfService qos) {
return YYDispatchContextGetQueue(YYDispatchContextGetForQOS(qos));
}