Threading Programming Guide

Threading Programming Guide

同步化

应用程序中存在多个线程带来了与从多个执行线程安全访问资源有关的潜在问题。修改同一资源的两个线程可能会以意想不到的方式相互干扰。例如,一个线程可能会覆写另一个线程的更改,或者将应用程序置于未知且可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题相对容易跟踪和修复。但是,如果您不走运,损坏可能会导致细微的错误,直到很久以后才会显现出来,或者这些错误可能需要对基础的编码假设进行重大检查。

在线程安全方面,好的设计是您拥有的最佳保护。避免共享资源并最小化线程之间的交互,使这些线程相互干扰的可能性降低。但是,并非总是可以实现完全无干扰的设计。在线程必须交互的情况下,您需要使用同步工具来确保它们交互时安全地进行。

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

同步工具

为了防止不同的线程意外更改数据,您可以将应用程序设计为不存在同步问题,也可以使用同步工具。尽管最好完全避免同步问题,但这并不总是可能的。以下各节介绍了可供您使用的同步工具的基本类别。

原子操作

原子操作是一种简单的同步形式,适用于简单的数据类型。原子操作的优点是它们不会阻塞竞争线程。对于简单的操作(例如增加计数器变量),这可以比锁获得更好的性能。

OS X 和 iOS 包含许多操作,可以对 32 位和 64 位值执行基本的数学和逻辑运算。这些操作包括比较和交换,测试和设置以及测试和清除操作的原子版本。有关受支持的原子操作的列表,请参见 /usr/include/libkern/OSAtomic.h 头文件或 atomic 手册页。

Memory Barriers and Volatile Variables

为了获得最佳性能,编译器经常对汇编级指令进行重新排序,以使处理器的指令管线尽可能完整。作为此优化的一部分,当编译器认为这样做不会产生不正确的数据时,可以对访问主内存的指令进行重新排序。不幸的是,编译器并非总是能够检测到所有与内存相关的操作。如果看似独立的变量实际上相互影响,则编译器优化可能会以错误的顺序更新这些变量,从而产生可能不正确的结果。

内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。内存屏障的作用类似于围栏,迫使处理器在允许执行位于屏障之后的加载和存储操作之前,完成位于屏障前面的所有加载和存储操作。内存屏障通常用于确保一个线程(但对另一线程可见)的内存操作始终按预期顺序发生。在这种情况下,缺少内存屏障可能会使其他线程看到看似不可能的结果。(例如,请参阅 Wikipedia 条目以了解 memory barriers。)要使用内存屏障,只需在代码中的适当位置调用 OSMemoryBarrier 函数即可。

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

由于 memory barriers and volatile variables 都减少了编译器可执行的优化次数,因此应谨慎使用它们,并且仅在需要确保正确性的地方使用它们。有关使用内存屏障的信息,请参见 OSMemoryBarrier 手册页。

Locks

锁是最常用的同步工具之一。您可以使用锁来保护代码的关键部分,这是一段代码,一次只能一个线程访问。例如,关键部分可能会操纵特定的数据结构或使用一次最多支持一个客户端的某些资源。通过在此部分周围加锁,可以排除其他线程进行可能影响代码正确性的更改。

表4-1 列出了程序员常用的一些锁。OS X 和 iOS 提供了大多数此类锁类型的实现,但并非全部。对于不受支持的锁类型,描述列说明了未在平台上直接实现这些锁的原因。

Table 4-1 Lock types

Lock Description
Mutex 互斥(或 mutex)锁充当资源周围的保护屏障。互斥锁是一种信号量,它一次只能授予对一个线程的访问权限。如果正在使用互斥锁,而另一个线程试图获取该互斥锁,则该线程将阻塞,直到该互斥锁被其原始持有者释放为止。如果多个线程竞争同一个互斥锁,则一次只能允许一个访问它。
Recursive lock 递归锁是互斥锁的一种变体。递归锁允许单个线程在释放它之前多次获取该锁。其他线程将保持阻塞状态,直到锁的所有者以获取锁的相同次数释放锁。递归锁主要在递归迭代期间使用,但也可以在多个方法各自需要分别获取锁的情况下使用。
Read-write lock 读写锁也称为共享独占锁。这种类型的锁通常用于较大规模的操作,如果经常读取受保护的数据结构并且仅偶尔进行修改,则可以显着提高性能。在正常操作期间,多个读取器可以同时访问数据结构。但是,当线程要写入结构时,它将阻塞,直到所有读取器都释放锁为止,此时,它获取了锁并可以更新结构。当写入线程正在等待锁定时,新的读取线程将阻塞,直到写入线程完成。系统仅支持使用 POSIX 线程的读写锁。有关如何使用这些锁的更多信息,请参见 pthread 手册页。
Distributed lock 分布式锁在进程级别提供互斥访问。与真正的互斥锁不同,分布式锁不会阻止进程或阻止其运行。它仅报告锁何时繁忙,并让进程决定如何进行
Spin lock 自旋锁反复轮询其锁定条件,直到该条件变为 true。自旋锁最常用于锁的预期等待时间较短的多处理器系统上。在这些情况下,轮询通常比阻塞线程更有效,这涉及到上下文切换和线程数据结构的更新。由于其轮询性质,该系统不提供自旋锁的任何实现,但是您可以在特定情况下轻松实现它们。有关在内核中实现自旋锁的信息,请参见 Kernel Programming Guide
Double-checked lock 双重检查锁是通过在获取锁之前测试锁定条件来减少获取锁的开销的尝试。由于双重检查的锁可能不安全,因此系统不会为它们提供明确的支持,因此不鼓励使用它们。

注意:大多数类型的锁还包含一个内存屏障,以确保在进入关键部分之前完成所有先前的装载和存储指令。

有关如何使用锁的信息,请参见使用 Using Locks

Conditions

条件是信号量的另一种类型,当某个条件为真时,它允许线程彼此发信号。条件通常用于指示资源的可用性或确保任务以特定顺序执行。当线程测试条件时,除非该条件已经为真,否则它将阻塞。它保持阻塞状态,直到其他线程显式更改并发出条件信号为止。条件和互斥锁之间的区别在于,可以允许多个线程同时访问该条件。条件更多是看门人,它根据某些指定的标准让不同的线程通过门。

您可以使用条件的一种方式是管理未决事件池。当事件队列中有事件时,事件队列将使用条件变量来通知等待线程。如果一个事件到达,则队列将适当地发出条件信号。如果线程已经在等待,它将被唤醒,随后它将把事件从队列中拉出并进行处理。如果两个事件大致同时进入队列,队列将发出两次信号通知状态以唤醒两个线程。

该系统为几种不同技术的条件提供支持。正确执行条件需要仔细编码,因此在您自己的代码中使用示例之前,应先查看 Using Conditions 中的示例。

Perform Selector Routines

Cocoa 应用程序具有一种以同步方式将消息传递到单个线程的便捷方法。 NSObject 类声明用于在应用程序的活动线程之一上执行 selector 的方法。这些方法使您的线程可以异步传递消息,并确保它们将由目标线程同步执行。例如,您可以使用执行 selector 消息将结果从分布式计算传递到应用程序的主线程或指定的协调器线程。每个执行 selector 的请求都在目标线程的运行循环中排队,然后按接收顺序对请求进行顺序处理。

有关执行 selector 例程的摘要以及有关如何使用它们的更多信息,请参见 Cocoa Perform Selector Sources

同步成本和性能

同步有助于确保代码的正确性,但这样做会牺牲性能。即使在无争议的情况下,使用同步工具也会带来延迟。锁和原子操作通常涉及使用内存屏障和内核级同步,以确保适当地保护代码。 如果存在争用锁的情况,您的线程可能会阻塞并经历更大的延迟。

表4-2 列出了在无争议的情况下与互斥锁和原子操作相关的一些近似成本。这些测量代表了数千个样本的平均时间。但是,与线程创建时间一样,互斥体获取时间(即使在无争议的情况下)也可能因处理器负载,计算机速度以及可用系统和程序内存量的不同而有很大差异。

表4-2 互斥和原子操作成本

Item Approximate cost Notes
Mutex acquisition time 约0.2微秒 这是无争议情况下的锁获取时间。如果锁是由另一个线程持有的,则获取时间可能会更长。这些数字是通过分析在基于Intel的iMac(具有2 GHz Core Duo处理器和1 GB运行OS X v10.5的RAM)上的互斥锁获取期间生成的平均值和中值确定的。
Atomic compare-and-swap 约0.05微秒 这是无争议情况下的比较和交换时间。这些数字是通过分析操作的平均值和中值确定的,是在基于Intel的iMac上生成的,该iMac具有2 GHz Core Duo处理器和1 GB运行OS X v10.5的RAM。

在设计并发任务时,正确性始终是最重要的因素,但是您也应该考虑性能因素。在多个线程下可以正确执行的代码,但是比在单个线程上运行的相同代码要慢的代码,几乎没有改进。

如果要改造现有的单线程应用程序,则应始终对关键任务的性能进行一组基准测量。添加其他线程后,您应该对这些相同的任务进行新的测量,并将多线程案例与单线程案例的性能进行比较。如果在调整代码后,线程处理无法提高性能,则您可能需要重新考虑特定的实现或完全使用线程。

有关性能和收集指标的工具的信息,请参阅 Performance Overview。有关锁和原子操作成本的特定信息,请参阅 Thread Costs

线程安全和信号

对于线程化应用程序,没有什么比处理信号问题引起更多的恐惧或困惑了。信号是一种低级 BSD 机制,可用于将信息传递给进程或以某种方式操纵它。一些程序使用信号来检测某些事件,例如子进程的死亡。系统使用信号终止失控过程并传达其他类型的信息。

信号的问题不是它们的作用,而是应用程序具有多个线程时的行为。在单线程应用程序中,所有信号处理程序都在主线程上运行。在多线程应用程序中,与特定硬件错误(例如非法指令)无关的信号将传递到当时正在运行的任何线程。如果多个线程同时运行,则将信号传递给系统碰巧的任何一个。换句话说,信号可以传递到应用程序的任何线程。

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

有关信号和安装信号处理程序的更多信息,请参见 signalsigaction 手册页。

线程安全设计的提示

同步工具是使代码线程安全的一种有用方法,但不是万能药。与非线程性能相比,使用过多的锁和其他类型的同步原语实际上会降低应用程序的线程性能。在安全和性能之间找到合适的平衡是一门需要经验的艺术。以下各节提供了一些技巧,以帮助您为应用程序选择适当的同步级别。

完全避免同步

对于您正在从事的任何新项目,甚至对于现有项目,设计代码和数据结构来避免同步是最佳的解决方案。尽管锁和其他同步工具很有用,但它们确实会影响任何应用程序的性能。而且,如果总体设计导致特定资源之间的争用较高,则您的线程可能会等待更长的时间。

实施并发的最佳方法是减少并发任务之间的交互和相互依赖性。如果每个任务都在其自己的私有数据集上运行,则无需使用锁来保护该数据。即使在两个任务确实共享一个公共数据集的情况下,您也可以查看对该集进行分区的方式或为每个任务提供自己的副本。当然,复制数据集也有其成本,因此在做出决定之前,您必须权衡这些成本和同步成本。

了解同步的局限性

同步工具仅在应用程序中的所有线程一致使用时才有效。如果创建互斥锁以限制对特定资源的访问,则所有线程在尝试操纵该资源之前必须获取相同的互斥锁。否则会破坏互斥锁提供的保护,这是程序员的错误。

注意代码正确性的威胁

使用锁和内存屏障时,应始终仔细考虑它们在代码中的位置。即使是看似位置正确的锁,实际上也会使您陷入一种错误的安全感。以下一系列示例试图通过指出看似无害的代码中的缺陷来说明这个问题。基本前提是您拥有一个包含一组不可变对象的可变数组。 假设您要调用数组中第一个对象的方法。 您可以使用以下代码进行操作:

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];

尽管前面的示例本质上非常简单,但是它们确实说明了非常重要的一点。当涉及到正确性时,您必须超越明显的问题进行思考。内存管理和设计的其他方面也会受到多个线程的影响,因此您必须预先考虑这些问题。另外,您应该始终假设编译器在安全方面会做最坏的事情。这种了解和警惕应有助于您避免潜在的问题,并确保您的代码正确运行。

有关如何使程序具有线程安全性的其他示例,请参见 Thread Safety Summary

当心死锁和活锁

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

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

避免出现死锁和活锁情况的最佳方法是一次只进行一次锁定。如果一次必须获取多个锁,则应确保其他线程不要尝试执行类似的操作。

正确使用 Volatile Variables

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

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

使用原子操作

无阻塞同步是一种执行某些类型的操作并避免锁定费用的方式。尽管锁是同步两个线程的有效方法,但是即使在无争议的情况下,获取锁也是相对昂贵的操作。相比之下,许多原子操作仅需花费一小部分时间即可完成,并且与锁一样有效。

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

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

Table 4-3 原子数学和逻辑运算

操作 函数名 描述
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.

大多数原子功能的行为应相对简单明了,并具有您所期望的。但是,清单 4-1 展示了原子 测试设置 和 比较交换 操作的行为,这些操作稍微复杂一些。对 OSAtomicTestAndSet 函数的前三个调用说明了对整数值使用的位操作公式及其结果可能与您期望的有所不同。最后两个调用显示OSAtomicCompareAndSwap32 函数的行为。在所有情况下,当没有其他线程在操纵这些值时,将在无争议的情况下调用这些函数。

Listing 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.

有关原子操作的信息,请参见 atomic 手册页和 /usr/include/libkern/OSAtomic.h 头文件。

Using Locks

锁是用于线程编程的基本同步工具。锁使您可以轻松保护大部分代码,从而可以确保该代码的正确性。OS X 和 iOS 为所有应用程序类型提供基本互斥锁,并且 Foundation 框架为特殊情况定义了互斥锁的一些其他变体。以下各节说明如何使用这些锁类型中的几种。

Using a POSIX Mutex Lock

POSIX 互斥锁非常易于在任何应用程序中使用。要创建互斥锁,请声明并初始化 pthread_mutex_t 结构。要锁定和解锁互斥锁,请使用 pthread_mutex_lock 和 pthread_mutex_unlock 函数。清单4-2 显示了初始化和使用 POSIX 线程互斥锁所需的基本代码。完成锁定后,只需调用 pthread_mutex_destroy 即可释放锁定数据结构。

Listing 4-2 Using a mutex lock

pthread_mutex_t mutex;
void MyInitFunction() {
  pthread_mutex_init(&mutex, NULL);
}

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

注意:上面的代码是一个简化的示例,旨在显示 POSIX 线程互斥函数的基本用法。您自己的代码应检查这些函数返回的错误代码并进行适当处理。

Using the NSLock Class

NSLock 对象为 Cocoa 应用程序实现了基本互斥量。实际上,所有锁(包括NSLock)的接口都是由 NSLocking 协议定义的,该协议定义了锁和解锁方法。您可以像使用任何互斥锁一样使用这些方法来获取和释放锁。

除了标准的锁定行为外,NSLock 类还添加了 tryLocklockBeforeDate: 方法。tryLock 方法尝试获取锁,但如果锁不可用则不会阻塞; 相反,该方法仅返回 NO。lockBeforeDate: 方法尝试获取锁,但如果在指定的时间限制内未获取锁,则取消阻塞线程(并返回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];
  }
}
Using the @synchronized Directive

@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 指令的更多信息,请参见 The Objective-C Programming Language.

Using Other Cocoa Locks

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

Using an NSRecursiveLock Object

NSRecursiveLock 类定义了一个锁,同一线程可以多次获取该锁,而不会导致线程死锁。递归锁跟踪成功获取了多少次。每次成功获取锁都必须通过相应的调用来平衡,以解锁该锁。仅当所有锁定和解锁调用均达到平衡时,才实际释放该锁定,以便其他线程可以获取它。

顾名思义,这种类型的锁通常在递归函数内部使用,以防止递归阻塞线程。在非递归情况下,您可以类似地使用它来调用函数,这些函数的语义要求它们也必须具有锁。这是一个简单的递归函数示例,该函数通过递归获取锁。如果您没有为此代码使用 NSRecursiveLock 对象,则当再次调用该函数时线程将死锁。

NSRecursiveLock *theLock = [[NSRecursiveLock alloc] init];

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

MyRecursiveFunction(5);

注意:由于在所有锁调用与解锁调用平衡之前不会释放递归锁,因此您应仔细权衡使用性能锁的决定与潜在的性能隐患。长时间持有任何锁都可能导致其他线程阻塞,直到递归完成为止。如果您可以重写代码以消除递归或不需要使用递归锁,则可能会获得更好的性能。

Using an NSConditionLock Object

NSConditionLock 对象定义一个互斥锁,该互斥锁可以使用特定值进行锁定和解锁。您不应将这种类型的锁与条件混淆(请参见 Conditions)该行为在某种程度上类似于条件,但实现方式却大不相同。

通常,当线程需要以特定顺序执行任务时(例如当一个线程产生另一个消耗的数据时),您可以使用 NSConditionLock 对象。生产者执行时,消费者使用特定于您的程序的条件来获取锁。(条件本身只是您定义的整数值。)生产者完成时,它将解锁锁,并将锁定条件设置为适当的整数值以唤醒使用者线程,然后消费者线程继续处理数据。

NSConditionLock 对象响应的锁定和解锁方法可以任意组合使用。例如,您可以将锁定消息与 unlockWithCondition: 配对,或者将 lockWhenCondition: 消息与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.
}
Using an NSDistributedLock Object

NSDistributedLock 类可被多个主机上的多个应用程序用来限制对某些共享资源(例如文件)的访问。该锁本身实际上是一个互斥锁,它是使用文件系统项(例如文件或目录)实现的。为了使 NSDistributedLock 对象可用,使用该锁的所有应用程序都必须可写该锁。这通常意味着将其放置在运行该应用程序的所有计算机都可以访问的文件系统上。

与其他类型的锁不同,NSDistributedLock 不符合 NSLocking 协议,因此没有锁定方法。锁方法会阻止线程执行,并要求系统以预定速率轮询锁。NSDistributedLock 不会向您的代码强加这种惩罚,而是提供一种 tryLock 方法,让您决定是否轮询。

由于它是使用文件系统实现的,因此除非所有者明确释放,否则不会释放 NSDistributedLock 对象。如果您的应用程序在持有分布式锁的同时崩溃,则其他客户端将无法访问受保护的资源。在这种情况下,您可以使用 breakLock 方法来破坏现有锁,以便获取它。但是,通常应该避免破坏锁,除非您确定拥有进程已死并且无法释放锁。

与其他类型的锁一样,当您使用 NSDistributedLock 对象完成操作时,您可以通过调用解锁方法来释放它。

Using Conditions

条件是一种特殊类型的锁,可用于同步操作必须进行的顺序。它们与互斥锁有一个微妙的区别。等待条件的线程保持阻塞状态,直到该条件被另一个线程显式发出信号为止。

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

以下各节说明如何在代码中使用条件。

Using the NSCondition Class

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

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

Listing 4-3 Using a Cocoa condition

[cocoaCondition lock];
while (timeToDoWork <= 0)
  [cocoaCondition wait];

timeToDoWork--;

// Do real work here.

[cocoaCondition unlock];

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

Listing 4-4 Signaling a Cocoa condition

[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];
Using POSIX Conditions

POSIX 线程条件锁要求同时使用条件数据结构和互斥锁。尽管两个锁结构是分开的,但互斥锁在运行时与条件结构密切相关。等待信号的线程应始终一起使用相同的互斥锁和条件结构。更改配对会导致错误。

清单4-5显示了 condition and predicate 的基本初始化和用法。在初始化条件和互斥锁之后,等待线程使用 ready_to_go 变量作为谓词进入 while 循环。仅当谓词已设置且随后发出条件通知时,等待线程才会唤醒并开始执行其工作。

Listing 4-5 Using a POSIX condition

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);
}

信号线程既负责设置 predicate,也负责将信号发送到条件锁。清单4-6显示了实现此行为的代码。在此示例中,条件在互斥锁内部发出信号,以防止在等待条件的线程之间发生竞态条件。

Listing 4-6 Signaling a condition lock

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 线程条件函数的基本用法。您自己的代码应检查这些函数返回的错误代码并进行适当处理。

推荐阅读更多精彩内容