线程同步

2018-04-12  本文已影响26人  渐z

对于从多个执行线程安全访问资源,应用程序中多个线程的存在会导致潜在的问题。修改相同资源的两个线程可能会以出乎意料的方式相互干扰。例如,一个线程可能会覆盖另一个线程的更改或者将应用程序置于未知和可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或者崩溃,这些问题相对容易追踪和修复。然而,也可能会导致过了很久之后才会出现的微妙问题,或者可能需要对底层代码进行重大修改的错误。

当涉及到线程安全时,一个优良的设计是最好的保护。避免共享资源和尽量减少线程之间的交互,使得这些线程不太可能相互干扰。然而,完全无干扰的设计并不总是可行的。在线程必须交互的情况下,需要使用同步工具来确保线程在交互时,它们能够安全地执行。

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

同步工具

为防止不同的线程出乎意料地更改数据,可以设计应用程序以避免同步问题,也可以使用同步工具。虽然完全避免同步问题是更可取的,但并非总是可行。以下各节介绍可供使用的同步工具的基本类别。

原子操作

原子操作是一种简单的同步形式,适用于简单的数据类型。原子操作的优点是它们不会阻塞竞争线程。对于简单的操作,例如递增一个计数器变量,相对于加锁,这样做可能会带来更好的性能。

OS X和iOS包含大量的操作来对32位和64位值执行基本的数学和逻辑运算。这些操作中包括compare-and-swaptest-and-settest-and-clear操作的原子版本。

内存屏障和Volatile变量

为了实现最佳性能,编译器通常会对汇编级指令重新排序来尽可能充分地维持处理器的指令流水线。作为这种优化的一部分,当编译器认为对访问主内存的指令重新排序不会产生错误的数据时,它可能会这样做。不幸的是,编译器并不总是能够检测到所有依赖于内存的操作。如果看似单独的变量实际上相互影响,编译器优化可能会以错误的顺序更新这些变量,从而产生潜在的错误结果。

内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序执行。内存屏障就像栅栏一样,强制处理器完成位于栅栏前面的任何加载和存储操作,然后才允许其执行位于栅栏后面的加载和存储操作。内存屏障通常用于确保一个线程(但对另一个线程可见)的内存操作始终按照预期的顺序进行。在缺少内存屏障的情况下,可能会让其他线程看到看似不可能的结果。要使用内存屏障,只需要在代码中的适当位置调用OSMemoryBarrier函数即可。

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

由于内存屏障和volatile变量都会减少编译器可以执行的优化次数,所以应该谨慎使用它们,并且仅在需要确保正确性时才使用它们。

锁是最常用的同步工具之一。可以使用锁来保护代码的关键部分,这段关键代码一次只允许一个线程访问。例如,关键部分可能会操作特定的数据结构或者使用一次最多支持一个客户端的资源。通过在该部分放置一个锁,可以拒绝其他线程执行可能影响代码正确性的更改。

下表列出了一些我们常用的锁。OS X和iOS为这些锁类型的大多数提供了实现,但不是全部。对于不受支持的锁类型,描述列解释了为什么这些锁没有直接在平台上实现的原因。

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

注意:大多数类型的锁和内存屏障用来确保在进入临界区之前,任何之前的加载和存储指令已被完成。

条件

条件是另一种类型的信号量,它允许线程在特定条件为真时互相发送信号。条件通常用于指示资源的可用性或者确保任务按照特定顺序执行。当线程验证一个条件时,它会阻塞,除非该条件已成立。它会一直阻塞,直到其他线程明确更改条件并向条件发出信号。条件和互斥锁之间的区别在于多个线程可以同时访问条件。条件更像是看门人,其依靠一些特定的标准来允许不同的线程通过门。

可以使用条件来管理待定事件池。当事件队列中有事件时,事件队列将使用条件变量来发送信号给正在等待的线程。如果有事件到达,队列会相应地发送信号给条件。如果线程已经处于等待状态,它会被唤醒,然后它会从队列中取出事件并处理该事件。如果两个事件大致同时进入队列,则队列将两次发送信号给条件以唤醒两个线程。

系统为使用几种不同技术的条件提供了支持。但是,正确的条件的实现需要仔细的编写代码,在代码中使用条件之前,请参看使用条件

执行选择器例程

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

开销与性能

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

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

Item Approximate cost Notes
Mutex acquisition time 大约0.2微秒 这是在无争议的情况下的锁获取时间。如果锁由另一个线程保存,则获取时间可能会更长。这些数据是通过分析在基于intel的使用2 GHz Core Duo处理器和运行OS X v10.5 的RAM为1 GB的iMac上锁获取期间生成的平均值和中位值确定的。
Atomic compare-and-swap 大约0.05微秒 这是在无争议的情况下的compare-and-swap时间。这些数据是通过分析在基于intel的使用2 GHz Core Duo处理器和运行OS X v10.5 的RAM为1 GB的iMac上锁获取期间生成的平均值和中位值确定的。

在设计并发任务时,正确性始终是最重要的因素,但也要考虑性能因素。如果在多线程下正确执行的代码与在单个线程上运行的相同代码相比,运行速度要更慢,那么并发执行就没有任何益处。

如果正在改进现有的单线程应用程序,则应该始终对关键任务的性能进行一系列基础测量。在添加额外的线程后,应该对这些相同的任务进行新的测量,并将多线程案例的性能与单线程案例进行比较。如果在调整代码之后,线程并不能提高性能,则可能需要重新考虑我们的特定实现或者线程的使用。

线程安全和信号

当涉及到线程应用程序时,没有什么比处理信号更能引起恐惧和困惑。信号是一种低级的BSD机制,可用于向进程传递信息或者以某种方式操作进程。一些程序使用信号来检测某些事件,例如子进程的死亡。系统使用信号来终止失控的进程并传达其他类型的信息。

信号的问题不是它们做了什么,而是当应用程序具有多个线程时它们的行为。在单线程应用程序中,所有信号处理程序都在主线程上运行。在多线程应用程序中,与特定硬件错误(例如非法指令)无关的信号将被传递到当前正在运行的任何线程上。如果多个线程同时运行,则信号会被传递到系统碰巧挑选的任何一个线程。

在应用程序中实现信号处理程序的首要规则是避免假设哪个线程正在处理信号。如果某个特定的线程想要处理给定的信号,则需要在信号到达时通过某种方式通知该线程。不能仅仅假设在某个线程安装信号处理程序后信号就会被传递到该线程。

线程安全设计提示

同步工具是使代码线程安全的有效方法,但它们不是万能的。使用太多的锁和其他类型的同步原函数实际上会降低应用程序的线程性能,找到安全和性能之间的正确平衡是一门需要经验的艺术。以下部分提供的提示可以帮助我们为应用程序选择合适的同步级别。

避免完全同步

对于任何新的项目,甚至是现有的项目,设计代码和数据结构来避免同步的需求是最好的解决方案。虽然锁和其他同步工具非常有用,但它们的确会影响任何应用程序的性能。如果整体设计造成特定资源的频繁争用,则线程可能会等待更长时间。

实现并发的最好方式是减少并发任务之间的交互和相互依赖。如果每个任务都操作自己的专用数据结构,则不需要使用锁保护该数据。即使在两个任务共享一个通用数据集的情况下,也可以通过注意数据集分区的方式或者为每个任务提供自己的副本来保护数据。当然,复制数据集也会带来成本,因此必须在做出决定之前将这些成本与同步成本进行权衡。

了解同步的限制

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

注意代码正确性的威胁

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

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

虽然上面的例子非常简单,但它们确实说明了一个非常重要的问题。当谈到正确性时,必须考虑这些不那么显而易见的问题。内存管理和设计的其他方面也可能会受到多线程的影响,所以必须事先考虑这些问题。另外,我们应该总是假设编译器会在安全方面做出最糟糕的事情。这种意识和警惕性可以帮助我们避免潜在的问题,并确保我们的代码正确运行。

注意死锁和活锁

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

活锁类似于死锁,并且发生在两个线程竞争同一组资源时。在活锁情况下,一个线程在试图获取第二个锁时放弃它的第一个锁。一旦它获得第二个锁,它就会返回并尝试再次获取第一个锁。由于该线程花费所有时间释放一个锁并试图获得另一个锁而不是做任何真正的工作,它就被锁定了。

避免死锁和活锁情况的最好方式是一次只取一个锁。如果一次必须获得一个以上的锁,则应该确保其他线程不会尝试做类似的事情。

正确使用Volatile变量

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

不要尝试通过使用volatile变量来避免互斥锁的使用也是非常重要的。通常情况下,相对于volatile变量,互斥锁和其他同步机制是保护数据结构完整性的一种更好的方式。volatile关键字只是确保变量从内存中加载,它不能确保代码正确访问该变量。

使用原子操作

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

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

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

Operation Function Description
Add OSAtomicAdd32
OSAtomicAdd32Barrier
OSAtomicAdd64
OSAtomicAdd64Barrier
将两个整数值相加并将结果存储在指定变量中。
Increment OSAtomicIncrement32
OSAtomicIncrement32Barrier
OSAtomicIncrement64
OSAtomicIncrement64Barrier
将指定的整数值递增1。
Decrement OSAtomicDecrement32
OSAtomicDecrement32Barrier
OSAtomicDecrement64
OSAtomicDecrement64Barrier
将指定的整数值递减1。
Logical OR OSAtomicOr32
OSAtomicOr32Barrier
在指定的32位值和32位掩码之间执行逻辑或。
Logical AND OSAtomicAnd32
OSAtomicAnd32Barrier
在指定的32位值和32位掩码之间执行逻辑于。
Logical XOR OSAtomicXor32
OSAtomicXor32Barrier
在指定的32位值和32位掩码之间执行逻辑异或。
Compare and swap OSAtomicCompareAndSwap32
OSAtomicCompareAndSwap32Barrier
OSAtomicCompareAndSwap64
OSAtomicCompareAndSwap64Barrier
OSAtomicCompareAndSwapPtr
OSAtomicCompareAndSwapPtrBarrier
OSAtomicCompareAndSwapInt
OSAtomicCompareAndSwapIntBarrier
OSAtomicCompareAndSwapLong
OSAtomicCompareAndSwapLongBarrier
将变量与指定的旧值进行比较。如果两个值相等,则该函数将指定的新值赋给变量;否则,它什么都不做。比较和赋值是作为一个原子操作完成的,并且该函数返回一个布尔值来指示交换实际是否发生。
Test and set OSAtomicTestAndSet
OSAtomicTestAndSetBarrier
测试指定变量中的一个位(bit),将该位设置为1,并将旧位的值作为布尔值返回。根据字节((char*)地址 + (n >> 3))的公式(0x80 >> (n&7))对位进行测试,其中n是位编号,地址是指向变量的指针。该公式有效地将变量分解为8位大小的块,并将每个块中的位反向排序。例如,要测试一个32位整数的最低位(位0),实际上应该指定位编号为7;类似地,为了测试最高位(位32),应该指定位编号为24。
Test and clear OSAtomicTestAndClear
OSAtomicTestAndClearBarrier
测试指定变量中的一个位(bit),将该位设置为0,并将旧位的值作为布尔值返回。根据字节((char*)地址 + (n >> 3))的公式(0x80 >> (n&7))对位进行测试,其中n是位编号,地址是指向变量的指针。该公式有效地将变量分解为8位大小的块,并将每个块中的位反向排序。例如,要测试一个32位整数的最低位(位0),实际上应该指定位编号为7;类似地,为了测试最高位(位32),应该指定位编号为24。

大多数原子函数的行为相对简单直接,但是上表中显示的原子test-and-setcompare-and-swap操作的行为稍微复杂一点。前三个OSAtomicTestAndSet函数调用演示了如何在整数值上使用位操作公式,其结果可能与我们所期望的不同。最后两个调用显示了OSAtomicCompareAndSwap32函数的行为。在所有情况下,当没有其他线程正在操作这些值时,这些函数在无竞争的情况下被调用。

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.

使用锁

锁是线程编程的基本同步工具。锁可让我们轻松保护大量代码,以确保代码的正确性。OS X和iOS为所有应用程序类型提供了基本的互斥锁,Foundation框架为特殊情况定义了一些另外的互斥锁的变体。以下部分展示如何使用其中几种锁类型。

使用POSIX互斥锁

POSIX互斥锁非常易于在任何应用程序中使用。要创建互斥锁,可以声明并初始化一个pthread_mutex_t结构。要锁定和解锁互斥锁,可以使用pthread_mutex_lockpthread_mutex_unlock函数。当不需要再使用锁时,只需要调用pthread_mutex_destroy函数释放锁数据结构即可。如下所示:

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)实际上都由NSLocking协议定义,该协议定义了lockunlock方法。可以像使用互斥锁一样使用这些方法来获取和释放锁。

除了标准的锁定行为,NSLock类还添加了tryLocklockBeforeDate:方法。tryLock方法试图获取锁时,如果锁不可用,它不会阻塞当前线程。相反,该方法只是返回NOlockBeforeDate:方法试图获取锁时,如果在指定时间限制内未获取到锁,则会取消阻塞线程(并返回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指令执行任何其他互斥锁都会执行的操作,它能防止不同线程同时获取同一个锁。在这种情况下,不必直接创建互斥锁或者锁对象。

- (void)myMethod:(id)anObj
{
    @synchronized(anObj)
    {
        // Everything between the braces is protected by the @synchronized directive.
    }
}

传递给@synchronized指令的对象是用于区别受保护块的唯一标识符。如果在两个不同的线程执行myMethod:方法并在每个线程上为anObj参数传递不同的对象,则每个线程都会加锁并继续处理而不被另一个线程阻塞。但是,如果同样情况下传递的是相同的对象,其中一个线程将首先获取锁,另一个线程会被阻塞,直到第一个线程退出临界区。

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

使用其他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:消息与解锁配对使用。当然,后一种组合解锁了锁,但可能不会释放等待特定条件值的任何线程。

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

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不遵循NSLocking协议,因此没有lock方法。lock方法会阻塞线程的执行并要求系统以预定速率轮询锁。NSDistributedLock提供了一个tryLock方法,并且由使用者自己决定是否轮询。

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

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

使用条件

条件是一种特殊类型的锁,可以使用它来同步操作的执行顺序。等待条件的线程将一直处于阻塞状态,直到另一个线程显式地发送信号给该条件。

由于涉及到实现操作系统的微妙之处,即使我们的代码没有发送信号到条件锁,条件锁也被允许返回虚假的成功。为了避免由这些虚假信号引起的问题,应该始终将谓词和条件锁结合起来使用。谓词是确定线程继续执行是否安全的更具体的方法。条件使线程保持休眠状态,直到谓词可以由信号线程设置。

使用NSCondition类

NSCondition类提供与POSIX条件相同的语义,但将所需的锁和条件数据结构封装在单个对象中。以下代码显示了在NSCondition对象上等待的事件序列。cocoaCondition变量包含了一个NSCondition对象,timeToDoWork变量是一个整数,从另一个线程发送信号给条件之前,其会递增。

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

timeToDoWork--;

// Do real work here.

[cocoaCondition unlock];
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];

使用POSIX条件

POSIX线程条件锁需要同时使用条件数据结构和互斥锁。虽然两个锁结果是分开的,但互斥锁在运行时与条件结构紧密相连。等待信号的线程应始终使用相同的互斥锁和条件结构。更改配对可能会导致错误。

以下代码显示了条件和谓词的基本初始化和用法。初始化条件和互斥锁后,等待线程使用ready_to_go变量作为谓词进入while循环。只有当谓词被设置并且条件随后发出信号时,等待线程才会醒来并开始工作。

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

信号线程负责设置谓词并将信号发送到条件锁。以下代码中,该条件在互斥锁内部发出信号,以防止在等待条件的线程之间发生条件竞争。

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);
}
上一篇下一篇

猜你喜欢

热点阅读