接上一章未完成的Objective-C线程技术,这一章我们主要内容是:线程同步和线程安全。
线程安全
在一个应用程序中存在多个线程,这就带来了关于从多个执行线程安全访问资源的潜在问题。修改相同资源的两个线程可能会以不希望的方式相互干扰。例如,一个线程可能覆盖另一个线程的更改,或者将应用程序置于未知且可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题比较容易跟踪和修复。但是,如果你运气不好,这种损坏可能会导致一些细微的错误,这些错误直到很久以后才会显现出来,或者这些错误可能需要对底层的编码假设进行重大的修改。
说到线程安全,好的设计是最好的保护。避免共享资源和最小化线程之间的交互可以减少线程之间的相互干扰。然而,完全无干扰的设计并不总是可能的。在你的线程必须交互的情况下,你需要使用同步工具来确保它们交互时是安全的。
OS X和iOS提供了大量的同步工具供你使用,从提供互斥访问的工具到在应用程序中正确排序事件的工具。以下部分将介绍这些工具以及如何在代码中使用它们来影响对程序资源的安全访问。
同步工具
为了防止不同的线程意外地更改数据,可以将应用程序设计为不存在同步问题,也可以使用同步工具。尽管完全避免同步问题更好,但这并不总是可能的。下面的部分描述了可供你使用的同步工具的基本类别。
原子操作
原子操作是对简单数据类型进行同步的一种简单形式。原子操作的优点是它们不会阻塞竞争线程。对于简单的操作,例如递增一个计数器变量,这可能比获取锁带来更好的性能。
OS X和iOS包含许多操作,用于对32位和64位值执行基本的数学和逻辑操作。这些操作中包括比较-交换、测试-设置和测试-清除操作的原子版本。有关受支持的原子操作的列表,请参阅/usr/include/libkern/OSAtomic.h头文件或原子手册页。
内存屏障和易失性变量
为了获得最佳的性能,编译器经常对程序集级的指令进行重新排序,以使处理器的指令管道尽可能地满。作为这种优化的一部分,当编译器认为访问主存不会产生错误数据时,它可能会重新排序访问主存的指令。不幸的是,编译器并不总是能够检测到所有与内存相关的操作。如果看似独立的变量实际上相互影响,那么编译器优化可能会以错误的顺序更新这些变量,从而产生可能不正确的结果。
内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。内存屏障的作用类似于一个屏障,迫使处理器在执行位于屏障之后的加载和存储操作之前完成位于屏障前面的所有加载和存储操作。内存屏障通常用于确保一个线程(但对另一个线程可见)的内存操作总是按照预期的顺序发生。在这种情况下,如果没有内存屏障,其他线程可能会看到看似不可能的结果。(例如,查看Wikipedia条目中的内存屏障。)要使用内存屏障,只需在代码中的适当位置调用OSMemoryBarrier函数。
易失性变量对单个变量应用另一种类型的内存约束。编译器通常通过将变量的值加载到寄存器来优化代码。对于局部变量,这通常不是问题。但是,如果变量在另一个线程中是可见的,这样的优化可能会阻止另一个线程注意到对它的任何更改。将volatile关键字应用于变量,将迫使编译器在每次使用变量时从内存中加载该变量。如果一个变量的值可以在任何时候被编译器无法检测到的外部源更改,则可以将其声明为volatile。
因为内存屏障和volatile变量都减少了编译器可以执行的优化的数量,所以应该尽量少使用它们,并且只在需要确保正确性的地方使用它们。有关使用内存屏障的信息,请参阅OSMemoryBarrier手册页。
锁
锁是最常用的同步工具之一。你可以使用锁来保护代码的关键部分,即每次只允许一个线程访问的代码段。例如,一个临界段可能操作一个特定的数据结构,或者使用某个资源一次最多支持一个客户机。通过在这个部分周围放置一个锁,可以排除其他线程进行可能影响代码正确性的更改。
表4-1列出了一些程序员经常使用的锁。OS X和iOS为大多数锁类型提供了实现,但不是所有锁类型。对于不支持的锁类型,description列解释了为什么这些锁不能直接在平台上实现的原因。
表4-1锁类型
Lock | Description |
---|---|
互斥锁 | 互斥锁(或互斥锁)充当资源周围的保护屏障。互斥量是一种信号量,一次只允许访问一个线程。如果一个互斥对象正在被使用,而另一个线程试图获取它,那么这个线程就会阻塞,直到互斥对象被它的原始持有者释放。如果多个线程竞争同一个互斥锁,一次只允许一个线程访问它。 |
递归锁 | 递归锁是互斥锁的变体。递归锁允许单个线程在释放锁之前多次获取锁。其他线程仍然被阻塞,直到锁的所有者释放锁的次数与获取锁的次数相同。递归锁主要用于递归迭代,但也可用于多个方法分别需要获取锁的情况。 |
读写锁 | 读写锁也称为共享独占锁。这种类型的锁通常用于更大规模的操作,如果频繁地读取受保护的数据结构并只偶尔修改,则可以显著提高性能。在正常操作期间,多个读取器可以同时访问数据结构。但是,当一个线程想要写入这个结构时,它就会阻塞,直到所有的读取器都释放这个锁,这时它就会获得这个锁并更新这个结构。当写线程等待锁时,新的读线程阻塞,直到写线程结束。系统只支持使用POSIX线程的读写锁。有关如何使用这些锁的更多信息,请参见pthread手册页。 |
分布式锁 | 分布式锁提供进程级的互斥访问。与真正的互斥锁不同,分布式锁不会阻塞进程或阻止进程运行。它只是报告锁何时处于繁忙状态,并让进程决定如何继续。 |
自旋锁 | 自旋锁反复轮询其锁条件,直到该条件变为真。自旋锁通常用于多处理器系统,其中锁的预期等待时间很短。在这些情况下,轮询通常比阻塞线程更有效,这涉及上下文切换和线程数据结构的更新。由于其轮询特性,系统不提供任何自旋锁的实现,但是你可以在特定的情况下轻松实现它们。有关在内核中实现自旋锁的信息,请参阅内核编程指南。 |
双重检查锁 | 双重检查锁是一种尝试,通过在获取锁之前测试锁定条件来减少获取锁的开销。因为双重检查的锁可能是不安全的,所以系统没有为它们提供明确的支持,并且不鼓励使用它们。 |
注意:大多数类型的锁还包含一个内存屏障,以确保在进入临界区之前完成任何加载和存储指令。
有关如何使用锁的信息,请参见使用锁。
条件
条件是另一种类型的信号量,它允许线程在某个条件为真时相互发送信号。条件通常用于指示资源的可用性,或确保以特定的顺序执行任务。当线程测试一个条件时,它会阻塞,除非该条件已经为真。它仍然被阻塞,直到其他线程显式地更改并发出条件信号。条件和互斥锁之间的区别是允许多个线程同时访问条件。这个条件更像是一个看门人,它允许不同的线程根据某些指定的条件通过这个门。
使用条件的一种方法是管理挂起事件池。当队列中有事件时,事件队列将使用一个条件变量来通知等待线程。如果一个事件到达,队列将适当地发出条件信号。如果一个线程已经在等待,它将被唤醒,然后从队列中提取事件并处理它。如果两个事件几乎同时进入队列,队列将两次向该条件发出信号以唤醒两个线程。
该系统为几种不同的技术提供条件支持。然而,条件的正确实现需要仔细的编码,所以在你自己的代码中使用条件之前,应该先看看使用条件的示例。
执行选择器例程
Cocoa应用程序有一种方便的方法,可以将消息以同步的方式传递到单个线程。NSObject类声明了在应用程序的一个活动线程上执行选择器的方法。这些方法允许线程异步传递消息,同时保证目标线程将同步执行这些消息。例如,可以使用执行选择器消息将分布式计算的结果传递给应用程序的主线程或指定的协调线程。每个执行选择器的请求都在目标线程的run循环中排队,然后按接收请求的顺序顺序处理请求。
有关执行选择器例程的摘要和有关如何使用它们的更多信息,请参阅Cocoa执行选择器源。
同步成本和性能
同步有助于确保代码的正确性,但这是以牺牲性能为代价的。同步工具的使用带来了延迟,即使在没有争用的情况下也是如此。锁和原子操作通常涉及使用内存屏障和内核级同步来确保代码得到适当的保护。如果存在锁争用,你的线程可能会阻塞并经历更大的延迟。
表4-2列出了在无争用情况下与互斥锁和原子操作相关的一些近似成本。这些测量数据代表了数千个样本的平均采样时间。但是,与线程创建时间一样,互斥锁获取时间(即使是在无争用的情况下)可能会根据处理器负载、计算机的速度以及可用系统和程序内存的数量而发生很大的变化。
表4-2互斥量和原子操作成本
Item | 估算成本 | 解释 |
---|---|---|
互斥对象收集时间 | 大约0.2微秒 | 这是无争用情况下的锁获取时间。如果锁由另一个线程持有,则获取时间可能要长得多。这些数字是通过分析在基于intel的iMac上使用2ghz双核处理器和运行OS X v10.5的1gb RAM时,在互斥量获取过程中产生的平均值和中值得出的。 |
原子比较 | 大约0.05微秒 | 这是无竞争情况下的比较和交换时间。这些数字是通过分析操作的平均值和中值得出的,并在一台基于intel的iMac上生成,该iMac拥有2ghz双核处理器和1gb RAM,运行OS X v10.5。 |
在设计并发任务时,正确性总是最重要的因素,但是你也应该考虑性能因素。在多个线程下正确执行的代码,但是比在单个线程上运行的相同代码要慢,这几乎算不上什么改进。
如果你正在对现有的单线程应用程序进行改造,则应该始终对关键任务的性能进行一组基线测量。在添加其他线程之后,你应该对这些相同的任务进行新的度量,并比较多线程情况和单线程情况的性能。如果在对代码进行调优之后,线程化并不能提高性能,那么你可能需要重新考虑特定的实现或线程的使用。
有关性能和收集指标的工具的信息,请参见性能概述。有关锁和原子操作成本的具体信息,请参阅线程成本。
线程安全和信号
当涉及到线程应用程序时,没有什么比处理信号的问题更令人恐惧或困惑的了。信号是一种低级的BSD机制,可用于向进程传递信息或以某种方式操纵它。有些程序使用信号来检测某些事件,比如子进程的死亡。该系统使用信号来终止失控的进程和通信其他类型的信息。
信号的问题不在于它们做什么,而在于它们在应用程序有多个线程时的行为。在单线程应用程序中,所有信号处理程序都在主线程上运行。在多线程应用程序中,与特定硬件错误(如非法指令)无关的信号将被传递给当时正在运行的任何线程。如果多个线程同时运行,则将信号传递给系统碰巧选择的那个线程。换句话说,信号可以传递到应用程序的任何线程。
在应用程序中实现信号处理程序的第一个规则是避免假设哪个线程正在处理信号。如果一个特定的线程想要处理一个给定的信号,那么你需要想办法在信号到达时通知这个线程。你不能仅仅假设从该线程安装信号处理程序将导致信号被传递到相同的线程。
有关信号和安装信号处理程序的更多信息,请参见信号和sigaction手册页。
线程安全设计的提示
同步工具是使代码线程安全的一种有用方法,但它们不是万能药。使用过多的锁和其他类型的同步原语实际上会降低应用程序的线程性能,而非线程性能。在安全和性能之间找到正确的平衡是一门需要经验的艺术。以下部分提供了一些技巧,帮助你为应用程序选择适当的同步级别。
完全避免同步
对于你所从事的任何新项目,甚至对于现有项目,设计代码和数据结构以避免同步是最好的解决方案。虽然锁和其他同步工具很有用,但它们确实会影响任何应用程序的性能。如果整体设计导致特定资源之间的高争用,则线程可能会等待更长的时间。
实现并发的最佳方法是减少并发任务之间的交互和相互依赖。如果每个任务都在自己的私有数据集上进行操作,则不需要使用锁来保护这些数据。即使在两个任务确实共享一个公共数据集的情况下,你也可以查看对该数据集进行分区的方法,或者为每个任务提供它自己的副本。当然,复制数据集也有成本,所以在做决定之前,必须将这些成本与同步的成本进行权衡。
了解同步的限制
同步工具只有在应用程序中的所有线程一致使用时才有效。如果你创建一个互斥锁来限制对特定资源的访问,那么所有线程在尝试操作资源之前必须获得相同的互斥锁。如果不这样做,就会破坏互斥锁提供的保护,这是程序员的错误。
注意对代码正确性的威胁
在使用锁和内存屏障时,应该始终仔细考虑它们在代码中的位置。即使是看起来位置很好的锁,实际上也会让你产生一种虚假的安全感。下面的一系列示例试图通过指出看似无害的代码中的缺陷来说明这个问题。基本前提是你有一个包含一组不可变对象的可变数组。假设你想调用数组中第一个对象的方法。你可以使用以下代码:
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];
[anObject doSomething];
因为数组是可变的,所以数组周围的锁会阻止其他线程修改数组,直到你获得所需的对象。由于检索的对象本身是不可变的,所以在调用doSomething方法时不需要锁。
不过,前面的示例有一个问题。如果你释放了锁,另一个线程进来并在你有机会执行doSomething方法之前从数组中移除所有对象,会发生什么?在没有垃圾收集的应用程序中,你的代码持有的对象可能被释放,使对象指向一个无效的内存地址。为了解决这个问题,你可以简单地重新排列你现有的代码,并在调用doSomething后释放锁,如下图所示:
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];
通过将doSomething调用移动到锁中,你的代码可以保证在调用方法时对象仍然有效。不幸的是,如果doSomething方法需要很长时间执行,这可能会导致代码长时间持有锁,从而造成性能瓶颈。
代码的问题不在于没有很好地定义关键区域,而在于没有理解实际的问题。真正的问题是内存管理问题,它只由其他线程的出现触发。因为它可以由另一个线程释放,所以更好的解决方案是在释放锁之前保留一个对象。这个解决方案解决了释放对象的实际问题,并且没有引入潜在的性能损失。
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject retain];
[arrayLock unlock];
[anObject doSomething];
[anObject release];
虽然前面的例子本质上很简单,但它们确实说明了一个非常重要的问题。说到正确性,你必须超越那些显而易见的问题。内存管理和设计的其他方面也可能受到多线程的影响,因此必须预先考虑这些问题。此外,你应该始终假设编译器在安全性方面会做最坏的事情。这种意识和警惕性可以帮助你避免潜在的问题,并确保代码的行为正确。
有关如何使程序线程安全的更多示例,请参见线程安全摘要。
小心死锁和活锁
当一个线程试图同时获取多个锁时,可能会发生死锁。当两个不同的线程持有另一个线程需要的锁,然后试图获取另一个线程持有的锁时,就会发生死锁。结果是每个线程永久阻塞,因为它永远无法获得另一个锁。
livelock类似于死锁,发生在两个线程竞争同一组资源时。在livelock情况下,一个线程放弃它的第一个锁,试图获得第二个锁。一旦获得第二个锁,它就会返回并再次尝试获得第一个锁。它锁定是因为它把所有时间都花在释放一个锁并试图获得另一个锁上,而不是做任何实际的工作。
避免死锁和动态锁的最佳方法是一次只使用一个锁。如果必须一次获取多个锁,则应确保其他线程不会尝试执行类似的操作。
正确使用Volatile变量
如果你已经使用互斥锁来保护一段代码,不要自动假设你需要使用volatile关键字来保护该段代码中的重要变量。互斥锁包含一个内存屏障,以确保加载和存储操作的正确顺序。向临界区内的变量添加volatile关键字将强制在每次访问变量时从内存加载该值。在特定的情况下,这两种同步技术的组合可能是必要的,但也会导致显著的性能损失。如果仅使用互斥锁就足以保护变量,则忽略volatile关键字。
同样重要的是,不要为了避免使用互斥对象而使用volatile变量。通常,与volatile变量相比,互斥锁和其他同步机制是保护数据结构完整性的更好方法。volatile关键字只确保变量是从内存加载的,而不是存储在寄存器中。它不能确保你的代码正确地访问变量。
使用原子操作
非阻塞同步是执行某些类型的操作并避免锁开销的一种方法。虽然锁是同步两个线程的有效方法,但是获取锁是一个相对昂贵的操作,即使在没有争用的情况下也是如此。相比之下,许多原子操作只需要几分之一的时间就可以完成,并且与锁一样有效。
原子操作允许你对32位或64位值执行简单的数学和逻辑操作。这些操作依赖于特殊的硬件指令(和一个可选的内存屏障),以确保在再次访问受影响的内存之前完成给定的操作。在多线程情况下,应该始终使用包含内存屏障的原子操作,以确保在线程之间正确地同步内存。
表4-3列出了可用的原子数学和逻辑操作以及相应的函数名。这些函数都在/usr/include/libkern/OSAtomic.h头文件中声明,你还可以在其中找到完整的语法。这些函数的64位版本仅在64位进程中可用。
表4-3原子数学和逻辑运算
操作 | 函数名 | 解释 |
---|---|---|
加法 | OSAtomicAdd32 OSAtomicAdd32Barrier OSAtomicAdd64 OSAtomicAdd64Barrier | 将两个整数值相加,并将结果存储在指定的变量之一中。 |
增加 | OSAtomicIncrement32 OSAtomicIncrement32Barrier OSAtomicIncrement64 OSAtomicIncrement64Barrier | 将指定的整数值增加1。 |
自减 | OSAtomicDecrement32 OSAtomicDecrement32Barrier OSAtomicDecrement64 OSAtomicDecrement64Barrier | 将指定的整数值减1。 |
逻辑或 | OSAtomicOr32 OSAtomicOr32Barrier | 执行指定的32位值和32位掩码之间的逻辑或值。 |
逻辑和 | OSAtomicAnd32 OSAtomicAnd32Barrier | 在指定的32位值和32位掩码之间执行逻辑运算。 |
逻辑异或 | OSAtomicXor32 OSAtomicXor32Barrier | 在指定的32位值和32位掩码之间执行逻辑XOR。 |
比较和交换 | OSAtomicCompareAndSwap32 OSAtomicCompareAndSwap32Barrier OSAtomicCompareAndSwap64 OSAtomicCompareAndSwap64Barrier OSAtomicCompareAndSwapPtr OSAtomicCompareAndSwapPtrBarrier OSAtomicCompareAndSwapInt OSAtomicCompareAndSwapIntBarrier OSAtomicCompareAndSwapLong OSAtomicCompareAndSwapLongBarrier | 将变量与指定的旧值进行比较。如果两个值相等,则该函数将指定的新值赋给变量;否则,它什么也不做。比较和赋值作为一个原子操作完成,函数返回一个布尔值,指示交换是否实际发生。 |
测试并设置 | OSAtomicTestAndSetOSAtomicTestAndSetBarrier | 测试指定变量中的某个位,将该位设置为1,然后将旧位的值作为布尔值返回。 根据字节((char *)address +(n >> 3))字节的公式(0x80 >>(n&7))测试位,其中n是位数,而address是指向变量的指针。 该公式有效地将变量分解为8位大小的块,并按相反的顺序对每个块中的位进行排序。 例如,要测试32位整数的最低位(位0),您实际上应将位数指定为7; 同样,要测试最高阶位(位32),请为位号指定24。 |
测试和清除 | OSAtomicTestAndClearOSAtomicTestAndClearBarrier | 测试指定变量中的某个位,将该位设置为0,然后将旧位的值作为布尔值返回。 根据字节((char *)address +(n >> 3))字节的公式(0x80 >>(n&7))测试位,其中n是位数,而address是指向变量的指针。 该公式有效地将变量分解为8位大小的块,并按相反的顺序对每个块中的位进行排序。 例如,要测试32位整数的最低位(位0),您实际上应将位数指定为7; 同样,要测试最高阶位(位32),请为位号指定24。 |
大多数原子函数的行为应该是相对简单的,并且符合你的预期。但是,清单4-1显示了原子测试-设置和比较-交换操作的行为,它们稍微复杂一些。前三个对OSAtomicTestAndSet函数的调用演示了如何对整数值使用位操作公式,其结果可能与你所期望的不同。最后两个调用显示了OSAtomicCompareAndSwap32函数的行为。在所有情况下,当没有其他线程操作这些值时,在无争用的情况下调用这些函数。
清单4-1执行原子操作
int32_t theValue = 0;
OSAtomicTestAndSet(0, &theValue);
// theValue is now 128.
theValue = 0;
OSAtomicTestAndSet(7, &theValue);
// theValue is now 1.
theValue = 0;
OSAtomicTestAndSet(15, &theValue)
// theValue is now 256.
OSAtomicCompareAndSwap32(256, 512, &theValue);
// theValue is now 512.
OSAtomicCompareAndSwap32(256, 1024, &theValue);
// theValue is still 512.
有关原子操作的信息,请参阅原子手册页和/usr/include/libkern/OSAtomic.h头文件。
使用锁
锁是线程编程的基本同步工具。锁使你能够轻松地保护大量代码,从而确保代码的正确性。OS X和iOS为所有应用程序类型提供了基本的互斥锁,Foundation框架为特殊情况定义了互斥锁的一些附加变体。下面几节将向你展示如何使用这些锁类型。
使用POSIX互斥锁
POSIX互斥锁在任何应用程序中都非常容易使用。要创建互斥锁,需要声明并初始化一个pthread_mutex_t结构。要锁定和解锁互斥锁,可以使用pthread_mutex_lock和pthread_mutex_unlock函数。清单4-2显示了初始化和使用POSIX线程互斥锁所需的基本代码。完成锁之后,只需调用pthread_mutex_destroy来释放锁数据结构。
清单4-2使用互斥锁
pthread_mutex_t mutex;
void MyInitFunction()
{
pthread_mutex_init(&mutex, NULL);
}
void MyLockingFunction()
{
pthread_mutex_lock(&mutex);
// Do work.
pthread_mutex_unlock(&mutex);
}
注意:上面的代码是一个简单的示例,目的是展示POSIX线程互斥函数的基本用法。你自己的代码应该检查这些函数返回的错误代码并适当地处理它们。
使用NSLock类
一个NSLock对象为Cocoa应用实现一个基本的互斥。所有锁(包括NSLock)的接口实际上是由NSLock协议定义的,它定义了锁和解锁方法。你使用这些方法来获取和释放锁,就像使用任何互斥锁一样。
除了标准的锁定行为外,NSLock类还添加了tryLock和lockBeforeDate:方法。tryLock方法尝试获取锁,但如果锁不可用,则不会阻塞;相反,该方法只返回NO。方法尝试获取锁,但是如果在指定的时间限制内没有获取锁,则解除线程阻塞(并返回NO)。
下面的示例展示了如何使用NSLock对象来协调可视化显示的更新,后者的数据由多个线程计算。如果线程不能立即获得锁,它将继续计算,直到获得锁并更新显示。
BOOL moreToDo = YES;
NSLock *theLock = [[NSLock alloc] init];
...
while (moreToDo) {
/* Do another increment of calculation */
/* until there’s no more to do. */
if ([theLock tryLock]) {
/* Update display used by all threads. */
[theLock unlock];
}
}
使用@synchronized指令
@synchronized指令是在Objective-C代码中动态创建互斥锁的一种方便的方法。@synchronized指令执行任何其他互斥锁都会执行的操作——防止不同的线程在同一时间获得相同的锁。但是,在这种情况下,你不必直接创建互斥对象或锁对象。相反,你只需使用任何Objective-C对象作为锁令牌,如下面的示例所示:
- (void)myMethod:(id)anObj
{
@synchronized(anObj)
{
// Everything between the braces is protected by the @synchronized directive.
}
}
传递给@synchronized指令的对象是一个唯一的标识符,用于区分受保护的块。如果你在两个不同的线程中执行上述方法,并在每个线程上传递anObj参数的不同对象,那么每个线程都会获得自己的锁并继续处理,而不会被另一个线程阻塞。但是,如果在这两种情况下传递相同的对象,其中一个线程将首先获得锁,另一个线程将阻塞,直到第一个线程完成临界区。
作为预防措施,@synchronized块隐式地向受保护的代码添加了一个异常处理程序。这个处理程序在抛出异常时自动释放互斥。这意味着为了使用@synchronized指令,你还必须在代码中启用Objective-C异常处理。如果不希望隐式异常处理程序造成额外的开销,则应该考虑使用锁类。
有关@synchronized指令的更多信息,请参见Objective-C编程语言。
使用其他Cocoa锁
下面几节描述使用其他几种类型的Cocoa锁的过程。
使用一个NSRecursiveLock对象
NSRecursiveLock类定义了一个锁,它可以被同一个线程多次获得,而不会导致线程死锁。递归锁跟踪它成功获得的次数。每次成功获取锁都必须通过相应的调用来解锁锁。只有当所有的锁和解锁调用被平衡时,锁才会被释放,以便其他线程可以获取它。
顾名思义,这种类型的锁通常用于递归函数中,以防止递归阻塞线程。你可以类似地在非递归的情况下使用它来调用语义要求它们也获取锁的函数。下面是一个通过递归获得锁的简单递归函数的示例。如果此代码没有使用NSRecursiveLock对象,则在再次调用该函数时线程将死锁。
NSRecursiveLock *theLock = [[NSRecursiveLock alloc] init];
void MyRecursiveFunction(int value)
{
[theLock lock];
if (value != 0)
{
--value;
MyRecursiveFunction(value);
}
[theLock unlock];
}
MyRecursiveFunction(5);
注意:因为在所有锁调用与解锁调用平衡之前不会释放递归锁,所以应该仔细权衡使用性能锁的决定与潜在的性能影响。长时间持有任何锁都可能导致其他线程阻塞,直到递归完成。如果你可以重写代码以消除递归或消除使用递归锁的需要,那么你可能会获得更好的性能。
使用NSConditionLock对象
一个NSConditionLock对象定义了一个互斥锁,它可以用特定的值来锁定和解除锁定。你不应该将这种类型的锁与条件混淆(参见条件)。该行为在某种程度上类似于条件,但实现方式非常不同。
通常,当线程需要以特定的顺序执行任务时,例如当一个线程生成另一个线程使用的数据时,你将使用NSConditionLock对象。在生产者执行时,消费者使用特定于程序的条件获取锁。(条件本身只是你定义的一个整数值。)当生产者完成时,它解锁锁并将锁条件设置为适当的整数值来唤醒消费者线程,然后消费者线程继续处理数据。
NSConditionLock对象响应的锁定和解锁方法可以在任何组合中使用。例如,可以将锁消息与unlockWithCondition:或lockWhenCondition: message与unlock配对。当然,后一种组合可以解锁锁,但可能不会释放任何等待特定条件值的线程。
下面的示例展示了如何使用条件锁处理生产者-消费者问题。假设应用程序包含一个数据队列。生产者线程向队列中添加数据,消费者线程从队列中提取数据。生产者不需要等待特定的条件,但是它必须等待锁可用,这样它才能安全地向队列添加数据。
id condLock = [[NSConditionLock alloc] initWithCondition:NO_DATA];
while(true)
{
[condLock lock];
/* Add data to the queue. */
[condLock unlockWithCondition:HAS_DATA];
}
因为锁的初始条件设置为NO_DATA,所以生产者线程在最初获取锁时应该没有问题。它用数据填充队列并将条件设置为HAS_DATA。在后续迭代期间,生产者线程可以在到达时添加新数据,而不管队列是空的还是仍然有一些数据。它唯一阻塞的时间是使用者线程从队列中提取数据的时候。
因为使用者线程必须处理数据,所以它使用特定条件在队列上等待。当生产者将数据放入队列时,消费者线程将唤醒并获取其锁。然后,它可以从队列中提取一些数据并更新队列状态。下面的示例显示了使用者线程的处理循环的基本结构。
while (true)
{
[condLock lockWhenCondition:HAS_DATA];
/* Remove data from the queue. */
[condLock unlockWithCondition:(isEmpty ? NO_DATA : HAS_DATA)];
// Process the data locally.
}
使用一个NSDistributedLock对象
多个主机上的多个应用程序可以使用NSDistributedLock类来限制对某些共享资源(如文件)的访问。锁本身实际上是使用文件系统项(如文件或目录)实现的互斥锁。要使NSDistributedLock对象可用,该锁必须可由使用它的所有应用程序写入。这通常意味着将它放在一个文件系统中,所有运行应用程序的计算机都可以访问这个文件系统。
与其他类型的锁不同,NSDistributedLock不符合nslock协议,因此没有锁方法。锁方法将阻塞线程的执行,并要求系统以预定的速率轮询锁。NSDistributedLock提供了一个tryLock方法,让你决定是否轮询,而不是对你的代码施加这种惩罚。
因为它是使用文件系统实现的,所以除非所有者显式地释放它,否则不会释放NSDistributedLock对象。如果你的应用程序在持有分布式锁时崩溃,其他客户端将无法访问受保护的资源。在这种情况下,你可以使用breakLock方法来打破现有的锁,以便获得它。不过,通常应该避免打破锁,除非你确定拥有锁的进程已经死亡,并且不能释放锁。
与其他类型的锁一样,当你使用NSDistributedLock对象时,通过调用unlock方法来释放它。
使用条件
条件是一种特殊类型的锁,你可以使用它来同步必须进行的操作的顺序。它们与互斥锁有一个微妙的区别。等待条件的线程将保持阻塞状态,直到另一个线程显式地发出该条件的信号。
由于实现操作系统所涉及的微妙之处,即使你的代码实际上并没有发出信号,也允许条件锁以虚假的成功返回。为了避免这些伪信号造成的问题,应该始终将谓词与条件锁一起使用。谓词是确定线程继续运行是否安全的更具体的方法。该条件只是让线程处于休眠状态,直到信号线程可以设置谓词为止。
下面几节将向你展示如何在代码中使用条件。
使用NSCondition类
NSCondition类提供与POSIX条件相同的语义,但将所需的锁和条件数据结构包装在一个对象中。结果是一个对象,你可以像锁定互斥对象那样锁定它,然后像等待条件那样等待它。
清单4-3显示了一个代码片段,演示了等待NSCondition对象的事件序列。cocoaCondition变量包含一个NSCondition对象,而timeToDoWork变量是一个整数,它是在发出条件信号之前从另一个线程开始递增的。
清单4-3使用Cocoa条件
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];
timeToDoWork--;
// Do real work here.
[cocoaCondition unlock];
清单4-4显示了用于发出Cocoa条件信号并增加谓词变量的代码。在发出信号之前,应该始终锁定状态。
清单4-4表示Cocoa条件
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];
使用POSIX条件
POSIX线程条件锁需要同时使用条件数据结构和互斥锁。虽然这两个锁结构是分开的,但是互斥锁在运行时与条件结构紧密地联系在一起。等待信号的线程应该总是同时使用同一个互斥锁和条件结构。更改配对可能会导致错误。
清单4-5展示了条件和谓词的基本初始化和用法。在初始化条件和互斥锁之后,等待的线程使用ready_to_go变量作为谓词进入while循环。只有在设置了谓词并且条件随后发出信号时,等待的线程才会唤醒并开始工作。
清单4-5使用POSIX条件
pthread_mutex_t mutex;
pthread_cond_t condition;
Boolean ready_to_go = true;
void MyCondInitFunction()
{
pthread_mutex_init(&mutex);
pthread_cond_init(&condition, NULL);
}
void MyWaitOnConditionFunction()
{
// Lock the mutex.
pthread_mutex_lock(&mutex);
// If the predicate is already set, then the while loop is bypassed;
// otherwise, the thread sleeps until the predicate is set.
while(ready_to_go == false)
{
pthread_cond_wait(&condition, &mutex);
}
// Do work. (The mutex should stay locked.)
// Reset the predicate and release the mutex.
ready_to_go = false;
pthread_mutex_unlock(&mutex);
}
信令线程负责设置谓词和将信号发送到条件锁。清单4-6显示了实现此行为的代码。在本例中,条件在互斥锁内部发出信号,以防止在等待条件的线程之间出现竞争条件。
清单4-6发出条件锁的信号
void SignalThreadUsingCondition()
{
// At this point, there should be work for the other thread to do.
pthread_mutex_lock(&mutex);
ready_to_go = true;
// Signal the other thread to begin work.
pthread_cond_signal(&condition);
pthread_mutex_unlock(&mutex);
}
注意:前面的代码是一个简化的示例,目的是展示POSIX线程条件函数的基本用法。你自己的代码应该检查这些函数返回的错误代码并适当地处理它们。
线程安全
本附录描述了OS X和iOS中一些关键框架的高级线程安全。本附录中的信息可能会有所更改。
Cocoa
从多个线程使用Cocoa的指导原则包括:
- 不可变对象通常是线程安全的。一旦创建了它们,就可以安全地将这些对象传递给线程或从线程传递给线程。另一方面,可变对象通常不是线程安全的。要在线程化应用程序中使用可变对象,应用程序必须适当地同步。有关更多信息,请参见易变与不可变。
- 许多被认为“线程不安全”的对象只有在多个线程中使用才不安全。这些对象中的许多对象都可以从任何线程中使用,只要它一次只使用一个线程。特定限制于应用程序主线程的对象被这样调用。
- 应用程序的主线程负责处理事件。尽管如果事件路径中涉及到其他线程,应用程序工具包将继续工作,但是操作可能会不按顺序发生。
- 如果你想使用一个线程来绘制视图,将所有的绘制代码括在NSView的lockFocusIfCanDraw和unlockFocus方法之间。
- 要将POSIX线程与Cocoa一起使用,你必须首先将Cocoa置于多线程模式。有关更多信息,请参见在Cocoa应用程序中使用POSIX线程。
Foundation框架和线程安全
有一种误解认为Foundation框架是线程安全的,而Application Kit框架不是。不幸的是,这是一个粗略的概括,有点误导。每个框架都有线程安全的区域和非线程安全的区域。下面几节描述基础框架的一般线程安全性。
线程安全的类和函数
以下类和函数通常被认为是线程安全的。你可以从多个线程中使用同一个实例,而无需首先获取锁。
- NSArray
- NSAssertionHandler
- NSAttributedString
- NSBundle
- NSCalendar
- NSCalendarDate
- NSCharacterSet
- NSConditionLock
- NSConnection
- NSData
- NSDate
- NSDateFormatter
- NSDecimal functions
- NSDecimalNumber
- NSDecimalNumberHandler
- NSDeserializer
- NSDictionary
- NSDistantObject
- NSDistributedLock
- NSDistributedNotificationCenter
- NSException
- NSFileManager
- NSFormatter
- NSHost
- NSJSONSerialization
- NSLock
- NSLog/NSLogv
- NSMethodSignature
- NSNotification
- NSNotificationCenter
- NSNumber
- NSNumberFormatter
- NSObject
- NSOrderedSet
- NSPortCoder
- NSPortMessage
- NSPortNameServer
- NSProgress
- NSProtocolChecker
- NSProxy
- NSRecursiveLock
- NSSet
- NSString
- NSThread
- NSTimer
- NSTimeZone
- NSUserDefaults
- NSValue
- NSXMLParser
- Object allocation and retain count functions
- Zone and memory functions
线程不安全的类
以下类和函数通常不是线程安全的。在大多数情况下,你可以在任何线程中使用这些类,只要你一次只在一个线程中使用它们。有关其他详细信息,请参阅类文档。
- NSArchiver
- NSAutoreleasePool
- NSCoder
- NSCountedSet
- NSEnumerator
- NSFileHandle
- NSHashTable functions
- NSInvocation
- NSMapTable functions
- NSMutableArray
- NSMutableAttributedString
- NSMutableCharacterSet
- NSMutableData
- NSMutableDictionary
- NSMutableOrderedSet
- NSMutableSet
- NSMutableString
- NSNotificationQueue
- NSPipe
- NSPort
- NSProcessInfo
- NSRunLoop
- NSScanner
- NSSerializer
- NSTask
- NSUnarchiver
- NSUndoManager
- User name and home directory functions
注意,虽然NSArchiver、NSCoder和NSEnumerator对象本身是线程安全的,但是在这里列出它们是因为在使用它们时更改它们包装的数据对象是不安全的。例如,在归档器的情况下,更改正在归档的对象图是不安全的。对于枚举器,任何线程更改枚举集合都是不安全的。
主线程类
以下类只能在应用程序的主线程中使用。
- NSAppleScript
可变和不可变的
不可变对象通常是线程安全的;一旦创建了它们,就可以安全地将这些对象传递给线程或从线程传递给线程。当然,在使用不可变对象时,仍然需要记住正确使用引用计数。如果不适当地释放没有保留的对象,可能会导致以后出现异常。
可变对象通常不是线程安全的。要在线程化应用程序中使用可变对象,应用程序必须使用锁同步对它们的访问。(有关更多信息,请参见原子操作)。通常,当涉及到突变时,集合类(例如NSMutableArray、NSMutableDictionary)不是线程安全的。也就是说,如果一个或多个线程正在更改相同的数组,可能会出现问题。必须锁定读和写发生的位置,以确保线程安全。
即使一个方法声称返回一个不可变的对象,你也不应该简单地假设返回的对象是不可变的。根据方法实现的不同,返回的对象可能是可变的或不可变的。例如,返回类型为NSString的方法实际上可能会返回一个NSMutableString,这是由于它的实现。如果你想保证你的对象是不可变的,你应该做一个不可变拷贝。
可重入性
只有在操作“调出”到同一对象或不同对象中的其他操作时,才可能实现重入。保留和释放对象就是这样一个有时被忽略的“呼叫”。
下表列出了基础框架中显式可重入的部分。所有其他类可能是可重入的,也可能不是,也可能在将来被重入。从来没有对可重入性进行过完整的分析,这个列表可能不是详尽的。
- Distributed Objects
- NSConditionLock
- NSDistributedLock
- NSLock
- NSLog/NSLogv
- NSNotificationCenter
- NSRecursiveLock
- NSRunLoop
- NSUserDefaults
类初始化
Objective-C运行时系统在类接收到任何其他消息之前向每个类对象发送初始化消息。这使类有机会在使用之前设置其运行时环境。在多线程应用程序中,运行时保证只有一个线程执行initialize方法,而这个线程恰好向类发送第一个消息。如果第二个线程试图在第一个线程仍然在initialize方法中时向该类发送消息,则第二个线程将阻塞,直到initialize方法完成执行为止。同时,第一个线程可以继续调用类上的其他方法。初始化方法不应该依赖于第二个线程调用类的方法;如果这样,两个线程就会陷入死锁。
由于OS X版本10.1的一个bug。在x和更早的时候,一个线程可以在另一个线程完成执行该类的initialize方法之前向该类发送消息。然后,线程可以访问尚未完全初始化的值,可能会导致应用程序崩溃。如果遇到这个问题,你需要引入锁来阻止对值的访问,直到它们被初始化之后,或者在成为多线程之前强制类初始化自身。
Autorelease池
每个线程维护自己的NSAutoreleasePool对象堆栈。Cocoa希望在当前线程的堆栈中总是有一个可用的自动释放池。如果池不可用,则不会释放对象,并且会泄漏内存。NSAutoreleasePool对象是在基于应用程序工具包的应用程序主线程中自动创建和销毁的,但是辅助线程(以及仅用于基础的应用程序)必须在使用Cocoa之前创建它们自己的对象。如果线程的生命周期很长,并且可能会生成很多自动释放对象,那么应该定期销毁并创建自动释放池(就像应用程序工具包在主线程上所做的那样);否则,自动释放的对象会累积,内存占用会增加。如果分离的线程不使用Cocoa,则不需要创建自动释放池。
循环运行
每个线程都有且只有一个运行循环。但是,每个运行循环(因此每个线程)都有自己的一组输入模式,它们决定在运行循环时侦听哪些输入源。在一个运行循环中定义的输入模式不会影响在另一个运行循环中定义的输入模式,即使它们可能具有相同的名称。
如果你的应用程序基于Application Kit,那么主线程的运行循环将自动运行,但是辅助线程(以及仅用于基础的应用程序)必须自己运行运行循环。如果分离的线程没有进入run循环,那么一旦分离的方法执行完毕,线程就会退出。
尽管有一些外观,NSRunLoop类并不是线程安全的。你应该仅从拥有该类的线程调用该类的实例方法。
Application Kit框架线程安全
下面几节描述Application Kit框架的一般线程安全性。
线程不安全的类
以下类和函数通常不是线程安全的。在大多数情况下,你可以在任何线程中使用这些类,只要你一次只在一个线程中使用它们。有关其他详细信息,请参阅类文档。
- NSGraphicsContext. For more information, see NSGraphicsContext Restrictions.
- NSImage. For more information, see NSImage Restrictions.
- NSResponder
- NSWindow and all of its descendants. For more information, see Window Restrictions.
主线程类
下列类只能在应用程序的主线程中使用。
- NSCell and all of its descendants
- NSView and all of its descendants. For more information, see NSView Restrictions.
窗口的限制
你可以在辅助线程上创建一个窗口。Application Kit确保将与窗口关联的数据结构释放到主线程上,以避免竞争条件。在同时处理大量窗口的应用程序中,窗口对象可能会泄漏。
可以在辅助线程上创建模式窗口。当主线程运行模态循环时,Application Kit阻塞调用的辅助线程。
事件处理的限制
应用程序的主线程负责处理事件。主线程是在NSApplication的run方法中阻塞的线程,通常在应用程序的主函数中调用。如果事件路径中涉及到其他线程,则Application Kit将继续工作,但操作可能会顺序发生。例如,如果两个不同的线程正在响应key事件,则可能会无序地接收key。通过让主线程处理事件,你可以获得更一致的用户体验。一旦接收到事件,如果需要,可以将事件分派给辅助线程进行进一步处理。
你可以从一个辅助线程调用postEvent:atStart:方法来发布一个事件到主线程的事件队列。但是,对于用户输入事件,不能保证顺序。应用程序的主线程仍然负责处理事件队列中的事件。
绘制的限制
当使用图形函数和类(包括NSBezierPath和NSString类)绘图时,Application Kit通常是线程安全的。使用特定类的详细信息将在以下部分中描述。有关绘图和线程的更多信息,请参阅Cocoa绘图指南。
NSView限制
NSView类通常不是线程安全的。你应该仅从应用程序的主线程中创建、销毁、调整大小、移动和对NSView对象执行其他操作。只要将绘图调用与对lockFocusIfCanDraw和unlockFocus的调用放在一起,从辅助线程进行绘图就是线程安全的。
如果应用程序的辅助线程想要在主线程上重新绘制视图的一部分,那么它不能使用display、setNeedsDisplay:、setNeedsDisplayInRect:或setViewsNeedDisplay:这样的方法。相反,它应该向主线程发送一条消息,或者使用performSelectorOnMainThread:withObject:waitUntilDone:方法调用那些方法。
视图系统的图形状态(gstates)是每个线程的。使用图形状态曾经是在单线程应用程序上实现更好的绘图性能的一种方法,但是现在不再是这样了。不正确地使用图形状态实际上会导致绘图代码的效率低于在主线程中绘图。
NSGraphicsContext限制
NSGraphicsContext类表示底层图形系统提供的绘图上下文。每个NSGraphicsContext实例都拥有自己独立的图形状态:坐标系统、裁剪、当前字体等等。在主线程上为每个NSWindow实例自动创建类的一个实例。如果你从一个辅助线程中进行任何绘图,NSGraphicsContext的一个新实例会专门为该线程创建。
如果从辅助线程进行任何绘图,则必须手动刷新绘图调用。Cocoa不会自动更新从辅助线程中提取的内容的视图,所以当你完成你的绘图时,你需要调用NSGraphicsContext的flushGraphics方法。如果应用程序仅从主线程中提取内容,则不需要刷新绘图调用。
NSImage限制
一个线程可以创建一个NSImage对象,绘制到图像缓冲区,然后将其传递给主线程进行绘制。底层映像缓存在所有线程之间共享。有关图像和缓存如何工作的更多信息,请参见Cocoa绘图指南。
Core Data框架
Core Data框架通常支持线程化,但也有一些用法上的注意事项。有关这些警告的信息,请参阅Core Data编程指南中的Core Data并发性。
Core Foundation
Core Foundation具有足够的线程安全性,如果你小心编程,就不会遇到与竞争线程相关的任何问题。它在常见情况下是线程安全的,例如在查询、保留、释放和传递不可变对象时。甚至可能从多个线程查询的中心共享对象也是可靠的线程安全的。
与Cocoa一样,当涉及到对象或其内容的变化时,Core Foundation不是线程安全的。例如,修改可变数据或可变数组对象并不是线程安全的,但修改不可变数组中的对象也不是线程安全的。其中一个原因是性能,这在这些情况下是至关重要的。而且,在这个级别通常不可能实现绝对线程安全。例如,你不能排除由于保留从集合中获得的对象而导致的不确定行为。在调用保留所包含的对象之前,可以释放集合本身。
在那些要从多个线程访问Core Foundation对象并发生变化的情况下,你的代码应该通过使用访问点上的锁来防止并发访问。例如,枚举Core Foundation数组对象的代码应该在枚举块周围使用适当的锁定调用,以防止其他人更改数组。
评论前必须登录!
注册