原文链接

应用程序中多个线程的存在可能导致安全访问来自多个执行线程的资源的问题。修改相同资源的两个线程可能会以意想不到的方式互相干扰。例如,一个线程可能覆盖另一个线程的更改,或者将应用置于未知且可能无效的状态。幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题容易跟踪和修复。但是,如果你运气不好,这种损坏可能会导致一些细微的错误,这些错误直到很久以后才会显示出来,或者这些错误可能需要对底层编码进行大量的检查。

谈到线程安全,一个好的设计是你拥有的最佳保护。避免资源共享并最小化线程之间的交互,可以减少这些线程相互干扰的可能性。然而,完全无干扰的设计并不总是可能的。在你的线程必须交互的情况下,你需要使用同步工具来确保他们交互时是安全的。

OS X 和 iOS 提供了许多同步工具供您使用,从提供互斥访问的工具到正确排列应用中事件顺序的工具。下面的部分将描述这些工具,以及如何在代码中使用它们来影响对程序资源的安全访问。

同步工具

为了防止不同的线程意外地更改数据,可以将应用设计为不存在同步问题,也可以使用同步工具。尽管完全避免同步问题的可取的,但这并不总是可能的。下面几节描述可供你使用的同步工具的基本类别。

原子操作

原子操作是对简单数据类型进行同步的一种简单形式。原子操作的有点是他们不会阻塞竞争线程。对于简单的操作,例如递增计数器变量,这可以比获取锁带来更好的性能。

OS X 和 iOS 包含许多操作,可以对 32 位和 64 位值执行基本的数学和逻辑操作。这些操作中包括 compare-and-swap, test-and-set, and test-and-clear 操作的原子版本。有关支持的原子操作列表,参见 /usr/include/libkern/OSAtomic.h 头文件或原子手册页。

内存屏障和 Volatile 变量

为了达到最佳性能,编译器经常对程序 assembly-level 指令进行重新排序,以使处理器的指令通道尽可能地满。作为优化的一部分,当编译器认为访问主内存不会生成不正确的数据时,它可能会重新排序访问主内存的指令。不幸的是,编译器并不总是能够检测到所有与内存相关的操作。如果看似独立的变量实际上相互影响,编译器优化可能会以错误的顺序更新这些变量,从而产生可能不正确的结果。

内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。内存屏障的作用类似于一个栅栏,强制处理器先执行完屏障前面的所有存取操作,再执行屏障后的。内存屏障通常用于确保一个线程(但对另一线程可见)的内存操作按照预期顺序进行。在这种情况下,缺少内存屏障可能会使其他线程看到看似不可能的结果。要使用内存屏障,只需在代码中适当的位置调用 OSMemoryBarrier 函数。

易失性变量对单个变量应用另一种类型的内存约束。编译器通常将变量的值加载到寄存器来优化代码。对于局部变量,这通常不是问题。但是,如果变量在另一线程中可见,这样的优化可能会阻止另一线程注意到对它的任何修改。将 volatile 关键字应用于变量迫使编译器在每次使用该变量时从内存中加载该变量。如果一个变量的值可以在任何时候被编译器无法检测到的外部源更改,则可以将变量声明为 volatile。

因为内存屏障和易失性变量都减少了编译器可以执行的优化代码数量,所以应该谨慎使用它们,并且只在需要确保正确性的地方使用。有关使用内存屏障的信息,参见 《OSMemoryBarrier》 手册页。

锁是最常用的同步工具之一。你可以使用锁来保护代码的一个关键部分,即每次只允许一个线程访问的代码段。例如,一个临界区可能操作一个特定的数据结构,或者使用某个资源一次最多支持一个客户机。通过再次部分周围放置锁,可以排除其他线程进行可能影响代码正确定的更改。

Table 4-1 列出了程序员常用的一些锁。OS X 和 iOS 为大多数锁类型提供了实现,但不是所有锁类型。对于不支持的锁类型,描述列解释了为什么这些所不能在平台上直接实现。

Table 4-1 Lock types

Lock Description
互斥锁 互斥锁充当资源周围的保护屏障。互斥量是一种信号量,每次只允许访问一个线程。如果正在使用互斥锁,而另一个线程试图获取互斥锁,则该线程将阻塞,直到互斥锁被其原始持有者释放。如果多个线程争用同一个互斥对象,每次只允许一个线程访问它。
递归锁 递归锁是互斥锁的变体。递归锁允许一个线程在释放锁之前多次获取锁。其他线程仍然被阻塞,直到锁的所有者释放锁的次数与获取锁的次数相同。递归锁主要用于递归迭代期间,但也可以用于多个方法需要分别获取锁的情况。
读写锁 读写锁也成为共享独占锁。这种类型的锁通常用于更大规模的操作,如果频繁读取受保护的数据结构只偶尔修改,啧可以显著提高性能。在正常运行中,多个阅读器可以同时访问数据结构。但是,当线程想要写入结构时,它会阻塞,直到所有的读取器释放锁,此时它获得锁并可以更新结构。当写线程等待锁时,新的读线程阻塞,直到写线程完成。系统只支持使用 POSIX 线程的读写锁,有关如何使用这些锁的更多信息,参见 《pthread》
分布式锁 分布式锁在进程级提供互斥访问。与真正的互斥锁不同,分布式锁不会阻塞进程或阻止进程运行。它只报告锁合适处于忙碌状态,并让进程决定如何继续。
自旋锁 自旋锁反复轮询其锁条件,直到该条件变为真。自旋锁通常用于多处理器系统,其中锁的预期等待时间较小。在这种情况下,轮询通常比阻塞线程更有效,这设计上下文的切换和线程数据结构的更新。由于自旋锁的轮询特性,系统不提供任何自旋锁的实现,但是你可以在特定的情况下轻松实现它们。有关在内核中实现自旋锁的信息,参见 《Kernel Programming Guide》
双重检查锁 双重检查锁是一种尝试,通过在获取锁之前测试锁定条件来减少获取锁的开销。由于双重检查锁可能是不安全的,所以系统不提供对它们的显式支持,并且不鼓励使用它们。

Note: 大多数类型的锁还包含一个内存屏障,以确保在进入临界区之前完成之前的任何加载存储指令。

更多关于使用锁的信息,参见 《Using Locks》

条件

条件是另一种类型的信号量,它允许线程在某个条件为真时互相发送信号。条件通常用于指示资源的可用性,或确保以特定的顺序执行任务。当线程测试一个条件时,它会阻塞,除非该条件已经为真。它仍然被阻塞,直到其他线程显式地更改并发出条件信号。条件和互斥锁的区别在于,可以允许多个线程同时访问条件。这个条件更像一个看门人,它允许不同的线程根据某些特定的条件通过这个门。

使用条件的一种方法是管理挂起事件池。当队列中有事件时,事件队列将使用一个条件变量来通知等待线程。如果一个事件到达,队列将适当地发出条件信号。如果一个线程已经在等待,它将被唤醒,然后从队列中取出事件并处理它。如果两个事件几乎同时进入队列,队列将两次向该条件发出信号,以唤醒两个线程。

该系统支持集中不同技术中的条件。然而,条件的正确实现需要仔细编码,所以在将条件用于自己的代码之前,应该先查看使用条件的示例。

执行选择器例程

Cocoa 应用程序以一种方便的方式将消息以同步的方式传递到一个线程。NSObject 类声明了在应用程序的一个活动线程上执行选择器的方法。这些方法允许线程异步传递消息,并保证目标线程将同步执行这些消息。例如,可以使用执行选择器消息将分布式计算的结果传递给应用的主线程或指定的协调器进程。执行选择器的每个请求都在目标线程的 RunLoop 中排队,然后按接收请求的顺序处理请求。

有关执行选择器例程的摘要和有关如何使用它们的更多信息,参见 《Cocoa Perform Selector Sources》

同步开销和性能

同步有助于确保代码的正确性,但这是以性能为代价的。使用同步工具会导致延迟,即使在没有争用的情况下也是如此。锁和原子操作通常涉及使用内存屏障和内核级同步,以确保代码得到适当的保护。如果存在锁争用,你的线程可能会阻塞并经历更大的延迟。

Table 4-2 列出了无争用情况下互斥锁和原子操作相关的一些近似成本。这些测量代表了超过几千个样本的平均时间。但是,与线程创建时间一样,互斥锁获取时间(即使在没有争用的情况下)也可能会根据处理器负载、计算机的速度以及可用系统和程序内存的数量发生很大的变化。

Table 4-2 Mutex and atomic operation costs

Item Approximate cost Notes
互斥锁获取时间 约 0.2ms 这是无争用情况下的锁获取时间。如果锁由另一个线程持有,则获取时间可能要长得多。
原子比较和交换 约 0.05ms 这是无争用情况下的比较和交互时间。

在设计并发任务时,正确性始终是最重要的因素,但是你还应该考虑性能因素。在多个线程下正确执行的代码,但是比在单个线程下运行相同代码要慢,这几乎算不上什么改进。

如果你正在对现有的单线程应用进行改造,你应该始终对关键任务的性能进行一组基线测量。在添加额外的线程之后,你应该对这些相同的任务进行新的度量,并比较多线程用例和单线程用例的性能。如果在优化代码之后,线程并不能提高性能,那么你可能需要重新考虑特定的实现和线程的使用。

有关性能和用于收集指标的工具的信息,参见 《Performance Overview》。有关锁和原子操作成本的具体信息,参见 《Thread Costs》

线程安全与信号

涉及到线程应用时,没有什么比处理信号的问题更令人恐惧或困惑了。信号是一种低级的 BSD 机制,可用于向进程传递信息活以某种方式操纵它。有些程序使用信号来检测某些事件,比如子进程的死亡。系统使用信号来终止失控的进程,并与其他类型的信息通信。

信号的问题不是他们做什么,而是当应用有多个线程时他们的行为。在单线程应用中,所有信号处理程序都在主线程上运行。在多线程应用中,没有绑定到特定硬件错误(例如非法指令)的信号被传递到正在运行的任何线程。如果多个线程同时运行,则将信号传递给系统碰巧选择的那个进程。换句话说,信号可以传递到应用的任何线程。

在应用中实现信号处理的第一条规则是避免假设哪个线程正在处理信号。如果一个特定的线程想要处理一个给定的信号,你需要想办法在信号到达时通知该线程。你不能仅仅假设从该线程安装信号处理程序导致信号被传递到同一个线程。

有关信号和安装信号处理程序的更多信息,参见 《signal》《sigaction》 手册页。

线程安全设计的技巧

同步工具是使代码线程安全的一种有用办法,但他们不是万能药。使用过多的锁和其他类型的同步原语实际上会降低应用的线程性能,而非非线程性能。在安全和性能之间找到正确的平衡是一门需要经验的艺术。一下部分提供一些技巧,帮助你为应用选择适当的同步级别。

完全避免同步

对于你所从事的任何新项目,甚至对于现有的项目,设计代码和数据结构以避免同步是最好的解决方案。虽然锁和同步工具很有用,但他们确实会影响任何应用的性能。如果总体设计导致特定资源之间的高争用,线程可能会等待更长的时间。

实现并发的最佳方法是减少并发任务之间的交互和相互依赖。如果每个任务都在自己的私有数据集上操作,啧不需要使用锁来保护该数据。即使在两个任务确实共享一个公共数据集的情况下,你也可以查看对该数据集进行分区的办法,或者每个任务提供它自己的副本。当然,复制数据集也有其成本,所以在做出决定之前,你必须将这些成本与同步成本进行权衡。

了解同步的限制

只有当应用中所有线程一致地使用同步工具时,它们才是有效的。如果你创建一个互斥锁来限制对特定资源的访问,那么所有线程在尝试操作该资源之前必须获得该资源之前获得相同的互斥锁。不这样做会破坏互斥锁提供的保护,这是程序员的错误。

注意代码正确性的威胁

使用锁和内存屏障时,应该始终仔细考虑他们在代码中的位置。即使是看起来位置很好的锁,实际上也会让你产生一种虚假的安全感。下面一系列示例试图通过指出看似无害的代码中的缺陷来说明这个问题。基本前提是有一个包含一组不可变对象的可变数组。假设你想调用数组中第一个对象的方法。你可以使用以下代码:

1
2
3
4
5
6
7
8
9
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];

[anObject doSomething];

由于数组是可变的,所以数组周围的锁会阻止其他线程修改数组,直到得到所需的对象。由于检索的对象本身是不可变的,所以在调用 doSomething 方法时不需要使用锁。

不过上面的例子就有一个问题。如果在有机会执行 doSomething 方法之前释放锁,另一个线程进来并从数组中删除所有对象,会发生什么?在没有垃圾收集的应用程序中,你的代码所持有的对象可能被释放,留下一个指向无效内存地址的对象。为了解决这个问题,你可能需要重新排列现有的代码,并调用 doSomething 之后释放锁,如下所示:

1
2
3
4
5
6
7
8
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];

通过在锁中移动 doSomething 调用,代码可以确保在调用方法时对象仍然有效。不幸的是,如果 doSomething 需要很长时间执行,这可能会导致代码长时间持有锁,从而造成性能瓶颈。

代码的问题不是关键区域定义得不好,而是没有理解实际的问题。真正的问题是内存管理问题,它只由其他线程的存在触发。因为它可以由另一个线程释放,所以更好的解决方案是在释放锁之前保留一个对象。这个解决方案解决了释放对象的实际问题,并且没有引入潜在的性能损失。

1
2
3
4
5
6
7
8
9
10
11
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject retain];
[arrayLock unlock];

[anObject doSomething];
[anObject release];

虽然前面的例子本质上很简单,但他们确实说明了一个非常重要的问题。说到正确性,你必须超越那些显而易见的问题。内存管理和设计的其他方面也可能受到多线程的影响,所以你必须预先考虑这些问题。此外,你应该始终假设编译器在安全性方面会做最坏的事情。这种意识和警惕性应该帮助你避免潜在的问题,并确保你的代码行为正确。

有关如何使程序线程安全的更多示例,参见 《Thread Safety Summary》

注意死锁和活锁

当一个线程试图同时获取多个锁时,可能会发生死锁。当两个不同的线程持有另一个线程需要的锁,然后试图获取另一个线程持有的锁时,就会发生死锁。结果是,每个进程都永久阻塞,因为他们永远无法获得另一个锁。

活锁类似于死锁,当两个线程竞争同一组资源时发生死锁。在活锁的情况下,线程放弃第一个锁,试图获得第二个锁,它将返回并在此尝试获得第一个锁。它锁定是因为它把所有的时间都花在释放一个锁并试图获得另一个锁上,而不是做任何实际的工作。

避免死锁和活锁的最佳方案是一次只使用一个锁。如果必须一次获得多个锁,则应确保其他线程不会做类似的事情。

正确使用 Volatile 变量

如果你已经使用互斥锁来保护一段代码,不要自动假设你需要使用 volatile 关键字来保护该部分中的重要变量。互斥锁包含一个内存屏障,以确保负载和存储操作的正确顺序。将 volatile 关键字添加到临界段中的变量中,将强制在每次访问该值时从内存中添加该值。在特定的情况下,这两种同步技术的组合是必要的,但也会导致显著的性能损失。如果仅互斥量就足以保护变量。则忽略 volatile 关键字。

同样重要的是,不要为了避免使用互斥对象而使用 volatile 变量。通常,互斥锁和其他同步机制是比 volatile 变量更好的保护数据结构完整性的方法。volatile 关键字只确保从内存中加载变量,而不是存储在寄存器中。它不能确保你的代码正确地访问变量。

使用原子操作

非阻塞同步是执行某些类型的操作并避免锁开销的一种方法。虽然锁是同步两个线程的有效方法,但是获取锁是一个相对昂贵的操作,即使在没有争用的情况下也是如此。相比之下,许多原子操作只需要一部分时间就可以完成,而且与锁一样有效。

原子操作允许你对 32 位或 64 位值执行简单的数学和逻辑操作。这些操作依赖于特殊的硬件指令(和一个可选的内存屏障),以确保在再次访问受影响的内存之前完成给定的操作。在多线程情况下,应该始终使用包含内存屏障的原子操作,以确保在线程之间正确地同步内存。

Table 4-3 列出了可用的原子数学和逻辑操作以及相应的函数名。这些函数都在 /usr/include/libkern/OSAtomic.h 头文件中声明,你还可以在其中找到完整的语法。这些函数的 64 位版本只能在 64 位进程中可用。

Table 4-3 Atomic math and logic operations

Operation Function name Description
Add OSAtomicAdd32 OSAtomicAdd32Barrier OSAtomicAdd64 OSAtomicAdd64Barrier Adds two integer values together and stores the result in one of the specified variables.
Increment OSAtomicIncrement32 OSAtomicIncrement32Barrier OSAtomicIncrement64 OSAtomicIncrement64Barrier Increments the specified integer value by 1.
Decrement OSAtomicDecrement32 OSAtomicDecrement32Barrier OSAtomicDecrement64 OSAtomicDecrement64Barrier Decrements the specified integer value by 1.
Logical OR OSAtomicOr32 OSAtomicOr32Barrier Performs a logical OR between the specified 32-bit value and a 32-bit mask.
Logical AND OSAtomicAnd32 OSAtomicAnd32Barrier Performs a logical AND between the specified 32-bit value and a 32-bit mask.
Logical XOR OSAtomicXor32 OSAtomicXor32Barrier Performs a logical XOR between the specified 32-bit value and a 32-bit mask.
Compare and swap OSAtomicCompareAndSwap32 OSAtomicCompareAndSwap32Barrier OSAtomicCompareAndSwap64 OSAtomicCompareAndSwap64Barrier OSAtomicCompareAndSwapPtr OSAtomicCompareAndSwapPtrBarrier OSAtomicCompareAndSwapInt OSAtomicCompareAndSwapIntBarrier OSAtomicCompareAndSwapLong OSAtomicCompareAndSwapLongBarrier Compares a variable against the specified old value. If the two values are equal, this function assigns the specified new value to the variable; otherwise, it does nothing. The comparison and assignment are done as one atomic operation and the function returns a Boolean value indicating whether the swap actually occurred.
Test and set OSAtomicTestAndSet OSAtomicTestAndSetBarrier Tests a bit in the specified variable, sets that bit to 1, and returns the value of the old bit as a Boolean value. Bits are tested according to the formula (0x80 >> (n & 7)) of byte ((char*)address + (n >> 3)) where n is the bit number and address is a pointer to the variable. This formula effectively breaks up the variable into 8-bit sized chunks and orders the bits in each chunk in reverse. For example, to test the lowest-order bit (bit 0) of a 32-bit integer, you would actually specify 7 for the bit number; similarly, to test the highest order bit (bit 32), you would specify 24 for the bit number.
Test and clear OSAtomicTestAndClear OSAtomicTestAndClearBarrier Tests a bit in the specified variable, sets that bit to 0, and returns the value of the old bit as a Boolean value. Bits are tested according to the formula (0x80 >> (n & 7)) of byte ((char*)address + (n >> 3)) where n is the bit number and address is a pointer to the variable. This formula effectively breaks up the variable into 8-bit sized chunks and orders the bits in each chunk in reverse. For example, to test the lowest-order bit (bit 0) of a 32-bit integer, you would actually specify 7 for the bit number; similarly, to test the highest order bit (bit 32), you would specify 24 for the bit number.

大多数原子函数的行为应该相对简单,并且符合你的期望。然而,Listing 4-1 显示了原子测试和设置以及比较和交换操作的行为,这些操作稍微复杂一些。对 OSAtomicTestAndSet 函数的前三个调用演示了如何对整数值使用位的操作公式,其结果可能与你的期望不同。最后两个调用显示 OSAtomicCompareAndSwap32 函数的行为。在所有情况下,当没有其他线程操作这些值时,在无争用的情况下调用这些函数。

Listing 4-1 Performing atomic operations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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.

更多关于原子操作的信息,见 《automic》 手册页和 /usr/include/libkern/OSAtomic.h 头文件。

使用锁

锁是线程编程的基本同步工具。锁使你能够轻松地保护大量代码,从而确保代码的正确性。OS X 和 iOS 为所有应用类型提供了基本的互斥锁,基础框架为特殊情况定义了互斥锁的一些附加变体。下面几节将向你展示如何使用这些锁类型中的几种。

使用 POSIX 互斥锁

POSIX 互斥锁在任何应用中都非常容易使用。要创建互斥锁,需要声明并初始化 pthread_mutex_t 结构。要锁定和解锁互斥锁,可以使用 pthread_mutex_lockpthread_mutex_unlock 函数。Listing 4-2 显示了初始化和使用 POSIX 线程互斥锁所需的基本代码。完成锁定之后,只需调用 pthread_mutex_destroy 来释放锁定数据结构。

Listing 4-2 Using a mutex lock

1
2
3
4
5
6
7
8
9
10
11
12
pthread_mutex_t mutex;
void MyInitFunction()
{
pthread_mutex_init(&mutex, NULL);
}

void MyLockingFunction()
{
pthread_mutex_lock(&mutex);
// Do work.
pthread_mutex_unlock(&mutex);
}

Note: 上面的代码是一个简化的示例,目的是展示 POSIX 线程互斥函数的基本用法。你自己的代码应该检查这些函数返回的错误代码,并适当地处理它们。

使用 NSLock 类

NSLock 对象为 Cocoa 应用实现一个基本互斥。所有锁(包括 NSLock)的接口实际上是由 NSLock 协议定义的,它定义了锁和解锁方法。你使用这些方法来获取和释放锁,就像使用任何互斥锁一样。

除了标准的锁定行为,NSLock 类还添加了 tryLocklockBeforeDate: 方法。tryLock 方法尝试获取锁,但如果锁不可用,则不会阻塞;相反,该方法只返回 NO。方法尝试获取锁,但是如果在指定的时间限制内没有获取锁,则会解锁线程(并返回 NO)。

下面的示例展示了如何使用 NSLock 对象来协调可视化显示的更新,可视化显示的数据由多个线程计算。如果线程不能立即获取锁,它只需要继续计算,直到能够获取锁并更新显示。

1
2
3
4
5
6
7
8
9
10
11
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 对象作为锁令牌,如下面的示例所示:

1
2
3
4
5
6
7
- (void)myMethod:(id)anObj
{
@synchronized(anObj)
{
// Everything between the braces is protected by the @synchronized directive.
}
}

传递给 @synchronized 指令的对象是一个唯一标识符,用于区分受保护的块。如果你在两个不同的线程中执行上述方法,并在每个线程上传递 anObj 参数的不同对象,那么每个线程都会获得自己的锁并继续处理,而不会被另一个线程阻塞。那么,如果在这两种情况下传递相同的对象,其中一个线程将首先获得锁,另一个线程将阻塞,直到第一个线程完成临界区。

作为预防措施,@synchronized 块隐式地将异常处理程序添加到受保护的代码中。这个处理程序在抛出异常时自动释放互斥体。这意味着为了使用 @synchronized 指令,你还必须在代码中启用 Objective-C 异常处理。如果不希望隐式异常处理程序造成额外的开销,则应该考虑使用锁类。

关于 @synchronized 指令的更多信息,参见 《The Objective-C Programming Language》

使用其他 Cocoa 锁

下面几节描述使用其他几种类型的 Cocoa 锁的过程。

使用 NSRecursiveLock 对象

NSRecursiveLock 类定义了一个锁,该锁可以由一个线程多次获得,而不会导致线程死锁。递归锁记录成功获取它的次数。每次成功获取锁都必须通过对应的调用来平衡解锁。只有当所有的 lock 和 unlock 调用都平衡时,锁才会被释放,以便其他线程可以获得它。

顾名思义,这种类型的锁通常用于递归函数中,以防递归阻塞线程。你可以在非递归的情况下类似使用它来调用语义要求他们也接受锁的函数。下面是一个通过递归获得锁的简单递归函数的例子。如果这段代码没有使用 NSRecursiveLock 对象,那么当再次调用该函数时,线程将死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
NSRecursiveLock *theLock = [[NSRecursiveLock alloc] init];

void MyRecursiveFunction(int value)
{
[theLock lock];
if (value != 0)
{
--value;
MyRecursiveFunction(value);
}
[theLock unlock];
}

MyRecursiveFunction(5);

Note: 因为在所有 lock 调用都与 unlock 调用平衡之前不会释放递归锁,所以应该仔细权衡使用性能锁的决定和潜在的性能影响。长时间持有任何锁搜可能导致其他线程阻塞,直到递归完成。如果你可以重用代码到消除递归或消除递归锁的需要,那么你可能会获得更好的性能。

使用 NSConditionLock 对象

NSConditionLock 对象定义了一个互斥锁,它可以用特定的值锁定和解锁。你不应该将这种类型的锁与条件混淆。该行为某种程度上类似于条件,但实现方式非常不同。

典型地,当线程需要以特定的顺序执行任务时,例如当一个线程生成另一个线程使用的数据时,使用 NSConditionLock 对象。在生产者执行时,它解锁并将锁条件设置为适当的整数值来唤醒消费者线程,然后消费者线程继续处理数据。

NSConditionLock 对象响应和解锁方法可以在任何组合中使用。例如,你可以将 lock 消息与 unlock 消息进行配对(unlockWithCondition:lockWhenCondition: )。当然,后一种组合可以解锁,但不会释放任何等待特定条件值的线程。

下面的示例展示了如何使用条件锁处理生产者-消费者问题。假设应用包含一个数据队列。生产者线程向队列添加数据,消费者线程从队列提取数据。生产者不需要等待特定的条件,但它必须等待锁可用,以便可以安全地将数据添加到队列中。

1
2
3
4
5
6
7
8
id condLock = [[NSConditionLock alloc] initWithCondition:NO_DATA];

while(true)
{
[condLock lock];
/* Add data to the queue. */
[condLock unlockWithCondition:HAS_DATA];
}

因为锁的初始条件被设置为 NO_DATA,所以生产者线程在最初获取锁时应该没有问题。它用数据填充队列并将条件设置为 HAS_DATA。在随后的迭代中,无论队列是空的还是仍然有一些数据,生产者线程都可以在到达时添加新数据。它阻塞的唯一时间是从队列中提取数据的使用者线程。

因为使用者线程必须处理数据,所以它使用特定的条件在队列中等待。在生产者将数据放入队列时,消费者线程将唤醒并获取其锁。然后,它可以从队列中提取一些数据并更新队列状态。下面的示例显示了使用者线程处理循环的基本结构。

1
2
3
4
5
6
7
8
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 不符合 NSLocking 协议,因此没有 lock 方法。锁方法将阻塞线程的执行,并要求系统已预定的速率轮询锁。NSDistributedLock 提供了一个 tryLock 方法,让你决定是否轮询,而不是对你的代码施加这种惩罚。

因为它是使用文件系统实现的,除非所有者显式地释放它,否则不会释放 NSDistributedLock 对象,如果你的应用在持有分布式锁时崩溃,其他客户机将无法访问受保护的资源。在这种情况下,可以使用 breakLock 方法打破现有的锁,以便获得它。不过,一般应该避免打破锁,除非你确定拥有的锁的进程已经死亡,并且无法释放锁。

与其他类型的锁一样,当你使用 NSDistributedLock 对象时,你可以通过调用 unlock 方法来释放它。

使用条件

条件是一种特殊类型的锁,你可以使用它来同步操作必须进行的顺序。他们与互斥锁有着微妙的区别。等待条件的线程将被阻塞,直到另一个线程显式地发出该条件的信号。

由于实现操作系统所涉及的微妙差别,即使你的代码实际上没有发出条件锁的信号,也允许条件锁以虚假的成功返回。为了避免这些伪信号引起的问题,你应该始终将谓词与条件锁一起使用。谓词是确定线程继续运行是否安全的更具体的方法。该条件只是让线程处于休眠状态,直到信号线程可以设置谓词为止。

下面几节将向你展示如何在代码中使用条件。

使用 NSCondition 类

NSCondition 类提供与 POSIX 条件相同的语义,但将所需的锁和条件数据结构封装在一个对象中。结果是一个对象,你可以像锁定互斥对象一样锁定它,然后像等待条件一样等待它。

Listing 4-3 显示了一个代码片段,演示了等待 NSCondition 对象的事件序列。cocoaCondition 变量包含一个 NSCondition 对象,而 timeToDoWork 变量是一个整数,在发出条件信号之前从另一个线程开始递增。

Listing 4-3 Using a Cocoa condition

1
2
3
4
5
6
7
8
9
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];

timeToDoWork--;

// Do real work here.

[cocoaCondition unlock];

Listing 4-4 显示了用于发出 Cocoa 条件信号并增加谓词变量的代码。在发出信号之前,你应该始终锁定状态。

Listing 4-4 Signaling a Cocoa condition

1
2
3
4
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];

使用 POSIX 条件

POSIX 线程条件锁需要同时使用条件数据结构和互斥锁。虽然这两个锁结构是分开的,但是互斥锁在运行时与条件结构紧密地联系在一起。等待信号的线程应该总是同时使用相同的互斥锁和条件结构。更改该配置可能会导致错误。

Listing 4-5 显示了条件和谓词的初始化和用法。初始化条件和互斥锁之后,等待和线程使用 ready_to_go 变量作为谓词进入 while 循环。只有在设置了谓词并随后发出条件信号时,等待线程才会醒来并开始工作。

Listing 4-5 Using a POSIX condition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
}

信令线程负责设置谓词和将信号发送到条件锁。Listing 4-6 显示了实现此行为的代码。在本例中,条件在互斥对象内部发出信号,以防止在等待条件的线程之间发生竞争条件。

Listing 4-6 Signaling a condition lock

1
2
3
4
5
6
7
8
9
10
11
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);
}

Note: 上面的代码是一个简化的示例,目的是展示 POSIX 线程条件函数的基本用法。你自己的代码应该检查这些函数返回的错误代码,并适当地处理它们。