第四章 线程同步
应用程序里面多个线程的存在引发了多个执行线程安全访问资源的潜在问题。两个线程同时修改同一资源有可能以意想不到的方式互相干扰。比如,一个线程可能覆盖其他线程改动的地方,或让应用程序进入一个未知的潜在无效状态。如果你幸运的话,受损的资源可能会导致明显的性能问题或崩溃,这样比较容易跟踪并修复它。然而如果你不走运,资源受损可能导致微妙的错误,这些错误不会立即显现出来,而是很久之后才出现,或者导致其他可能需要一个底层的编码来显著修复的错误。
但涉及到线程安全时,一个好的设计是最好的保护。避免共享资源,并尽量减少线程间的相互作用,这样可以让它们减少互相的干扰。但是一个完全无干扰的设计是不可能的。在线程必须交互的情况下,你需要使用同步工具,来确保当它们交互的时候是安全的。
Mac OS X和iOS提供了你可以使用的多个同步工具,从提供互斥访问你程序的有序的事件的工具等。以下个部分介绍了这些工具和如何在代码中使用他们来影响安全的访问程序的资源。
1.1 同步工具
为了防止不同线程意外修改数据,你可以设计你的程序没有同步问题,或你也可以使用同步工具。尽管完全避免出现同步问题相对更好一点,但是几乎总是无法实现。以下个部分介绍了你可以使用的同步工具的基本类别。
1.1.1 原子操作
原子操作是同步的一个简单的形式,它处理简单的数据类型。原子操作的优势是它们不妨碍竞争的线程。对于简单的操作,比如递增一个计数器,原子操作比使用锁具有更高的性能优势。
Mac OS X和iOS包含了许多在32位和64位执行基本的数学和逻辑运算的操作。这些操作都使用了原子版本来操作比较和交换,测试和设置,测试和清理等。查看支持原子操作的列表,参阅/user/include/libkern/OSAtomic.h头文件和参见atomic主页。
1.1.2 内存屏障和 Volatile 变量
为了达到最佳性能,编译器通常会对汇编基本的指令进行重新排序来尽可能保持处理器的指令流水线。作为优化的一部分,编译器有可能对访问主内存的指令,如果它认为这有可能产生不正确的数据时,将会对指令进行重新排序。不幸的是,靠编译器检测到所有可能内存依赖的操作几乎总是不太可能的。如果看似独立的变量实际上是相互影响,那么编译器优化有可能把这些变量更新位错误的顺序,导致潜在不不正确结果。
内存屏障(memory barrier)是一个使用来确保内存操作按照正确的顺序工作的非阻塞的同步工具。内存屏障的作用就像一个栅栏,迫使处理器来完成位于障碍前面的任何加载和存储操作,才允许它执行位于屏障之后的加载和存储操作。内存屏障同样使用来确保一个线程(但对另外一个线程可见)的内存操作总是按照预定的顺序完成。如果在这些地方缺少内存屏障有可能让其他线程看到看似不可能的结果(比如,内存屏障的维基百科条目)。为了使用一个内存屏障,你只要在你代码里面需要的地方简单的调用OSMemoryBarrier函数。
Volatile 变量适用于独立变量的另一个内存限制类型。编译器优化代码通过加载这些变量的值进入寄存器。对于本地变量,这通常不会有什么问题。但是如果一个变量对另外一个线程可见,那么这种优化可能会阻止其他线程发现变量的任何变化。在变量之前加上关键字volatile可以强制编译器每次使用变量的时候都从内存里面加载。如果一个变量的值随时可能给编译器无法检测的外部源更改,那么你可以把该变量声明为volatile变量。
因为内存屏障和volatile变量降低了编译器可执行的优化,因此你应该谨慎使用它们,只在有需要的地方时候,以确保正确性。关于更多使用内存屏障的信息,参阅OSMemoryBarrier主页。
1.1.3 锁
锁是最常用的同步工具。你可以是使用锁来保护临界区(critical section),这些代码段在同一个时间只能允许被一个线程访问。比如,一个临界区可能会操作一个特定的数据结构,或使用了每次只能一个客户端访问的资源。
表4-1列出了程序最常使用的锁。Mac OS X和iOS提供了这些锁里面大部分类型的实现,但是并不是全部实现。对于不支持的锁类型,说明列解析了为什么这些锁不能直接在平台上面实现的原因。
Table 4-1 Lock types
Lock |
Description |
Mutex [互斥锁] |
A mutually exclusive (or mutex) lock acts as a protective barrier around a resource. A mutex is a type of semaphore that grants access to only one thread at a time. If a mutex is in use and another thread tries to acquire it, that thread blocks until the mutex is released by its original holder. If multiple threads compete for the same mutex, only one at a time is allowed access to it. |
Recursive lock [递归锁] |
A recursive lock is a variant on the mutex lock. A recursive lock allows a single thread to acquire the lock multiple times before releasing it. Other threads remain blocked until the owner of the lock releases the lock the same number of times it acquired it. Recursive locks are used during recursive iterations primarily but may also be used in cases where multiple methods each need to acquire the lock separately. |
Read-write lock |
A read-write lock is also referred to as a shared-exclusive lock. This type of lock is typically used in larger-scale operations and can significantly improve performance if the protected data structure is read frequently and modified only occasionally. During normal operation, multiple readers can access the data structure simultaneously. When a thread wants to write to the structure, though, it blocks until all readers release the lock, at which point it acquires the lock and can update the structure. While a writing thread is waiting for the lock, new reader threads block until the writing thread is finished. The system supports read-write locks using POSIX threads only. For more information on how to use these locks, see the pthread man page. |
Distributed lock |
A distributed lock provides mutually exclusive access at the process level. Unlike a true mutex, a distributed lock does not block a process or prevent it from running. It simply reports when the lock is busy and lets the process decide how to proceed. |
Spin lock |
A spin lock polls its lock condition repeatedly until that condition becomes true. Spin locks are most often used on multiprocessor systems where the expected wait time for a lock is small. In these situations, it is often more efficient to poll than to block the thread, which involves a context switch and the updating of thread data structures. The system does not provide any implementations of spin locks because of their polling nature, but you can easily implement them in specific situations. For information on implementing spin locks in the kernel, see Kernel Programming Guide. |
Double-checked lock |
A double-checked lock is an attempt to reduce the overhead of taking a lock by testing the locking criteria prior to taking the lock. Because double-checked locks are potentially unsafe, the system does not provide explicit support for them and their use is discouraged.[注意系统不显式支持该锁类型] |
注意:大部分锁类型都合并了内存屏障来确保在进入临界区之前它前面的加载和存储指令都已经完成。
关于如何使用锁的信息,参阅”使用锁”部分。
1.1.4 条件
条件是信号量的另外一个形式,它允许在条件为真的时候线程间互相发送信号。条件通常被使用来说明资源可用性,或用来确保任务以特定的顺序执行。当一个线程测试一个条件时,它会被阻塞直到条件为真。它会一直阻塞直到其他线程显式的修改信号量的状态。条件和互斥锁(mutex lock)的区别在于多个线程被允许同时访问一个条件。条件更多是允许不同线程根据一些指定的标准通过的守门人。
一个方式是你使用条件来管理挂起事件的池。事件队列可能使用条件变量来给等待线程发送信号,此时它们在事件队列中的时候。如果一个事件到达时,队列将给条件发送合适信号。如果一个线程已经处于等待,它会被唤醒,届时它将会取出事件并处理它。如果两个事件到达队列的时间大致相同,队列将会发送两次信号唤醒两个线程。
系统通过几个不同的技术来支持条件。然而正确实现条件需要仔细编写代码,因此你应该在你自己代码中使用条件之前查看”使用条件”部分的例子。
1.1.5 执行Selector例程
Cocoa程序包含了一个在一个线程以同步的方式传递消息的方便方法。NSObject类声明方法来在应用的一个活动线程上面执行selector的方法。这些方法允许你的线程以异步的方式来传递消息,以确保它们在同一个线程上面执行是同步的。比如,你可以通过执行selector消息来把一个从你分布计算的结果传递给你的应用的主线程或其他目标线程。每个执行selector的请求都会被放入一个目标线程的run loop的队列里面,然后请求会按照它们到达的顺序被目标线程有序的处理。
关于执行selector例程的总结和更多关于如何使用它们的信息,参阅Cocoa执行Selector源。
1.2 同步的成本和性能
同步帮助确保你代码的正确性,但同时将会牺牲部分性能。甚至在无争议的情况下,同步工具的使用将在后面介绍。锁和原子操作通常包含了内存屏障和内核级别同步的使用来确保代码正确被保护。如果,发生锁的争夺,你的线程有可能进入阻塞,在体验上会产生更大的迟延。
表4-2列出了在无争议情况下使用互斥锁和原子操作的近似的相关成本。这些测试的平均值是使用了上千的样本分析出的结果。随着线程创建时间的推移,互斥采集时间(即使在无争议情况下)可能相差也很大,这依赖于进程的加载,计算机的处理速度和系统和程序现有可用的内存。
Table 4-2 Mutex and atomic operation costs
Item |
Approximate cost |
Notes |
Mutex acquisition time |
Approximately 0.2 microseconds |
This is the lock acquisition time in an uncontested case. If the lock is held by another thread, the acquisition time can be much greater. The figures were determined by analyzing the mean and median values generated during mutex acquisition on an Intel-based iMac with a 2 GHz Core Duo processor and 1 GB of RAM running Mac OS X v10.5. |
Atomic compare-and-swap |
Approximately 0.05 microseconds |
This is the compare-and-swap time in an uncontested case. The figures were determined by analyzing the mean and median values for the operation and were generated on an Intel-based iMac with a 2 GHz Core Duo processor and 1 GB of RAM running Mac OS X v10.5. |
当设计你的并发任务时,正确性是最重要的因素,但是也要考虑性能因素。代码在多个线程下面正确执行,但比相同代码在当线程执行慢,这是难以改善的。如果你是改造已有的单线程应用,你应该始终给关键任务的性能设置测量基线。当增加额外线程后,对相同的任务你应该采取新的测量方法并比较多线程和单线程情况下的性能状况。在改变代码之后,线程并没有提高性能,你应该需要重新考虑具体的实现或同时使用线程。
关于性能的信息和收集指标的工具,参阅Performance Overview。关于锁原子成本的特定信息,参阅”线程成本”部分。
1.3 线程安全和信号量
当涉及到多线程应用程序时,没有什么比处理信号量更令人恐惧和困惑的了。信号量是底层BSD机制,它可以用来传递信息给进程或以某种方式操纵它。一些应用程序使用信号量来检测特定事件,比如子进程的消亡。系统使用信号量来终止失控进程,和作为其他类型的通信消息。
使用信号量的问题并不是你要做什么,而是当你程序是多线程的时候它们的行为。在当线程应用程序里面,所有的信号量处理都在主线程进行。在多线程应用程序里面,信号量被传递到恰好运行的线程,而不依赖于特定的硬件错误(比如非法指令)。如果多个线程同时运行,信号量被传递到任何一个系统挑选的线程。换而言之,信号量可以传递给你应用的任何线程。
在你应用程序里面实现信号量处理的第一条规则是避免假设任一线程处理信号量。如果一个指定的线程想要处理给定的信号,你需要通过某些方法来通知该线程信号何时到达。你不能只是假设该线程的一个信号处理例程的安装会导致信号被传递到同一线程里面。
关于更多信号量的信息和信号量处理例程的安装信息,参见signal和sigaction主页。
1.4 线程安全设计的技巧
同步工具是让你代码安全的有用方法,但是它们并非灵丹妙药。使用太多锁和其他同步的类型原语和非多线程相比明显会降低你应用的线程性能。在性能和安全之间寻找平衡是一门需要经验的艺术。以下各部分提供帮助你为你应用选择合适的同步级别的技巧。
1.4.1 完全避免同步
对于你新的项目,甚至已有项目,设计你的代码和数据结构来避免使用同步是一个很好的解决办法。虽然锁和其他类型同步工具很有用,但是它们会影响任何应用的性能。而且如果整体设计导致特定资源的高竞争,你的线程可能需要等待更长时间。
实现并发最好的方法是减少你并发任务之间的交互和相互依赖。如果每个任务在它自己的数据集上面操作,那它不需要使用锁来保护这些数据。甚至如果两个任务共享一个普通数据集,你可以查看分区方法,它们设置或提供拷贝每一项任务的方法。当然,拷贝数据集本身也需要成本,所以在你做出决定前,你需要权衡这些成本和使用同步工具造成的成本那个更可以接受。
1.4.2 了解同步的限制
同步工具只有当它们被用在应用程序中的所有线程是一致时才是有效的。如果你创建了互斥锁来限制特定资源的访问,你所有线程都必须在试图操纵资源前获得同一互斥锁。如果不这样做导致破坏一个互斥锁提供的保护,这是编程的错误。
1.4.3 注意对代码正确性的威胁
当你使用锁和内存屏障时,你应该总是小心的把它们放在你代码正确的地方。即使有条件的锁(似乎很好放置)也可能会让你产生一个虚假的安全感。以下一系列例子试图通过指出看似无害的代码的漏洞来举例说明该问题。其基本前提是你有一个可变的数组,它包含一组不可变的对象集。假设你想要调用数组中第一个对象的方法。你可能会做类似下面那样的代码:
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方法前其他线程到来并从数组中删除所有对象,那会发生什么呢?对于没有使用垃圾回收的应用程序,你代码用户的对象可能已经释放了,让anObject对象指向一个非法的内存地址。了修正该问题,你可能决定简单的重新安排你的代码,让它在调用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方法需要耗费很长的时间,这有可能导致你的代码保持拥有该锁很长时间,这会产生一个性能瓶颈。
该代码的问题不是关键区域定义不清,而是实际问题是不可理解的。真正的问题是由其他线程引发的内存管理的问题。因为它可以被其他线程释放,最好的解决办法是在释放锁之前retain anObject。该解决方案涉及对象被释放,并没有引发一个强制的性能损失。
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 ]; |
尽管前面的例子非常简单,它们说明了非常重要的一点。当它涉及到正确性时,你需要考虑不仅仅是问题的表面。内存管理和其他影响你设计的因子都有可能因为出现多个线程而受到影响,所以你必须考虑从上到下考虑这些问题。此外,你应该在涉及安全的时候假设编译器总是出现最坏的情况。这种意识和警惕性,可以帮你避免潜在的问题,并确保你的代码运行正确。
关于更多介绍如何让你应用程序安全的额外例子,参阅Technical Note TN2059:”Using Collection Classes Safely in Multithreaded Application”。
1.4.4 当心死锁(Deadlocks)和活锁(Livelocks)
任何时候线程试图同时获得多于一个锁,都有可能引发潜在的死锁。当两个不同的线程分别保持一个锁(而该锁是另外一个线程需要的)又试图获得另外线程保持的锁时就会发生死锁。结果是每个线程都会进入持久性阻塞状态,因为它永远不可能获得另外那个锁。
一个活锁和死锁类似,当两个线程竞争同一个资源的时候就可能发生活锁。在发生活锁的情况里,一个线程放弃它的第一个锁并试图获得第二个锁。一旦它获得第二个锁,它返回并试图再次获得一个锁。线程就会被锁起来,因为它花费所有的时间来释放一个锁,并试图获取其他锁,而不做实际的工作。
避免死锁和活锁的最好方法是同一个时间只拥有一个锁。如果你必须在同一时间获取多于一个锁,你应该确保其他线程没有做类似的事情。
1.4.5 正确使用Volatile变量
如果你已经使用了一个互斥锁来保护一个代码段,不要自动假设你需要使用关键词volatile来保护该代码段的重要的变量。一个互斥锁包含了内存屏障来确保加载和存储操作是按照正确顺序的。在一个临界区添加关键字volatile到变量上面会强制每次访问该变量的时候都要从内存里面从加载。这两种同步技巧的组合使用在一些特定区域是必须的,但是同样会导致显著的性能损失。如果单独使用互斥锁已经可以保护变量,那么忽略关键字volatile。
为了避免使用互斥锁而不使用volatile变量同样很重要。通常情况下,互斥锁和其他同步机制是比volatile变量更好的方式来保护数据结构的完整性。关键字volatile只是确保从内存加载变量而不是使用寄存器里面的变量。它不保证你代码访问变量是正确的。
1.5 使用原子操作
非阻塞同步的方式是用来执行某些类型的操作而避免扩展使用锁。尽管锁是同步两个线程的很好方式,获取一个锁是一个很昂贵的操作,即使在无竞争的状态下。相比,许多原子操作花费很少的时间来完成操作也可以达到和锁一样的效果。
原子操作可以让你在32位或64位的处理器上面执行简单的数学和逻辑的运算操作。这些操作依赖于特定的硬件设施(和可选的内存屏障)来保证给定的操作在影响内存再次访问的时候已经完成。在多线程情况下,你应该总是使用原子操作,它和内存屏障组合使用来保证多个线程间正确的同步内存。
表4-3列出了可用的原子运算和本地操作和相应的函数名。这些函数声明在/usr/include/libkern/OSAtomic.h头文件里面,在那里你也可以找到完整的语法。这些函数的64-位版本只能在64位的进程里面使用。
Table 4-3 Atomic math and logic operations
Operation |
Function name |
Description |
Add |
OSAtomicAdd32 |
Adds two integer values together and stores the result in one of the specified variables. |
Increment |
OSAtomicIncrement32 |
Increments the specified integer value by 1. |
Decrement |
OSAtomicDecrement32 |
Decrements the specified integer value by 1. |
Logical OR |
Performs a logical OR between the specified 32-bit value and a 32-bit mask. |
|
Logical AND |
Performs a logical AND between the specified 32-bit value and a 32-bit mask. |
|
Logical XOR |
Performs a logical XOR between the specified 32-bit value and a 32-bit mask. |
|
Compare and swap |
OSAtomicCompareAndSwap32 |
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 |
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 |
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 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. |
关于原子操作的更多信息,参见atomic的主页和/usr/include/libkern/OSAtomic.h头文件。
1.6 使用锁
锁是线程编程同步工具的基础。锁可以让你很容易保护代码中一大块区域以便你可以确保代码的正确性。Mac OS X和iOS都位所有类型的应用程序提供了互斥锁,而Foundation框架定义一些特殊情况下互斥锁的额外变种。以下个部分显式了如何使用这些锁的类型。
1.6.1 使用POSIX互斥锁
POSIX互斥锁在很多程序里面很容易使用。为了新建一个互斥锁,你声明并初始化一个pthread_mutex_t的结构。为了锁住和解锁一个互斥锁,你可以使用pthread_mutex_lock和pthread_mutex_unlock函数。列表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); } |
注意:上面的代码只是简单的显式了使用一个POSIX线程互斥锁的步骤。你自己的代码应该检查这些函数返回的错误码,并适当的处理它们。
1.6.2 使用NSLock类
在Cocoa程序中NSLock中实现了一个简单的互斥锁。所有锁(包括NSLock)的接口实际上都是通过NSLocking协议定义的,它定义了lock和unlock方法。你使用这些方法来获取和释放该锁。
除了标准的锁行为,NSLock类还增加了tryLock和lockBeforeDate:方法。方法tryLock试图获取一个锁,但是如果锁不可用的时候,它不会阻塞线程。相反,它只是返回NO。而lockBeforeDate:方法试图获取一个锁,但是如果锁没有在规定的时间内被获得,它会让线程从阻塞状态变为非阻塞状态(或者返回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 ]; } } |
1.6.3 使用@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指令,你必须在你的代码中启用异常处理。了如果你不想让隐式的异常处理例程带来额外的开销,你应该考虑使用锁的类。
关于更多@synchronized指令的信息,参阅The Objective-C Programming Language。
1.6.4 使用其他Cocoa锁
以下个部分描述了使用Cocoa其他类型的锁。
使用NSRecursiveLock对象
NSRecursiveLock类定义的锁可以在同一线程多次获得,而不会造成死锁。一个递归锁会跟踪它被多少次成功获得了。每次成功的获得该锁都必须平衡调用锁住和解锁的操作。只有所有的锁住和解锁操作都平衡的时候,锁才真正被释放给其他线程获得。
正如它名字所言,这种类型的锁通常被用在一个递归函数里面来防止递归造成阻塞线程。你可以类似的在非递归的情况下使用他来调用函数,这些函数的语义要求它们使用锁。以下是一个简单递归函数,它在递归中获取锁。如果你不在该代码里使用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 ); |
注意:因为一个递归锁不会被释放直到所有锁的调用平衡使用了解锁操作,所以你必须仔细权衡是否决定使用锁对性能的潜在影响。长时间持有一个锁将会导致其他线程阻塞直到递归完成。如果你可以重写你的代码来消除递归或消除使用一个递归锁,你可能会获得更好的性能。
使用NSConditionLock对象
NSConditionLock对象定义了一个互斥锁,可以使用特定值来锁住和解锁。不要把该类型的锁和条件(参见“条件”部分)混淆了。它的行为和条件有点类似,但是它们的实现非常不同。
通常,当多线程需要以特定的顺序来执行任务的时候,你可以使用一个NSConditionLock对象,比如当一个线程生产数据,而另外一个线程消费数据。生产者执行时,消费者使用由你程序指定的条件来获取锁(条件本身是一个你定义的整形值)。当生产者完成时,它会解锁该锁并设置锁的条件为合适的整形值来唤醒消费者线程,之后消费线程继续处理数据。
NSConditionLock的锁住和解锁方法可以任意组合使用。比如,你可以使用unlockWithCondition:和lock消息,或使用lockWhenCondition:和unlock消息。当然,后面的组合可以解锁一个锁但是可能没有释放任何等待某特定条件值的线程。
下面的例子显示了生产者-消费者问题如何使用条件锁来处理。想象一个应用程序包含一个数据的队列。一个生产者线程把数据添加到队列,而消费者线程从队列中取出数据。生产者不需要等待特定的条件,但是它必须等待锁可用以便它可以安全的把数据添加到队列。
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方法。一个lock方法将会阻塞线程的执行,并要求系统以预定的速度轮询锁。以其在你的代码中实现这种约束,NSDistributedLock提供了一个tryLock方法,并让你决定是否轮询。
因为它使用文件系统来实现,一个NSDistributedLock对象不会被释放除非它的拥有者显式的释放它。如果你的程序在用户一个分布锁的时候崩溃了,其他客户端简无法访问该受保护的资源。在这种情况下,你可以使用breadLock方法来打破现存的锁以便你可以获取它。但是通常应该避免打破锁,除非你确定拥有进程已经死亡并不可能再释放该锁。
和其他类型的锁一样,当你使用NSDistributedLock对象时,你可以通过调用unlock方法来释放它。
1.7 使用条件
条件是一个特殊类型的锁,你可以使用它来同步操作必须处理的顺序。它们和互斥锁有微妙的不同。一个线程等待条件会一直处于阻塞状态直到条件获得其他线程显式发出的信号。
由于微妙之处包含在操作系统实现上,条件锁被允许返回伪成功,即使实际上它们并没有被你的代码告知。为了避免这些伪信号操作的问题,你应该总是在你的条件锁里面使用一个断言。该断言是一个更好的方法来确定是否安全让你的线程处理。条件简单的让你的线程保持休眠直到断言被发送信号的线程设置了。
以下部分介绍了如何在你的代码中使用条件。
1.7.1 使用NSCondition类
NSCondition类提供了和POSIX条件相同的语义,但是它把锁和条件数据结构封装在一个单一对象里面。结果是一个你可以像互斥锁那样使用的对象,然后等待特定条件。
列表4-3显示了一个代码片段,它展示了为等待一个NSCondition对象的事件序列。cocaoCondition变量包含了一个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 ]; |
列表4-4显示了用于给Cocoa条件发送信号的代码,并递增他断言变量。你应该在给它发送信号前锁住条件。
Listing 4-4 Signaling a Cocoa condition
1
2
3
4
|
[cocoaCondition lock ]; timeToDoWork++; [cocoaCondition signal ]; [cocoaCondition unlock ]; |
1.7.2 使用POSIX条件
POSIX线程条件锁要求同时使用条件数据结构和一个互斥锁。经管两个锁结构是分开的,互斥锁在运行的时候和条件结构紧密联系在一起。多线程等待某一信号应该总是一起使用相同的互斥锁和条件结构。修改该成双结构将会导致错误。
列表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); } |
信号线程负责设置断言和发送信号给条件锁。列表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); } |
注意:上述代码是显示使用POSIX线程条件函数的简单例子。你自己的代码应该检测这些函数返回错误码并恰当的处理它们。