同步
应用程序中存在多个线程会导致潜在的问题,这些问题可能会导致从多个执行线程安全访问资源。修改相同资源的两个线程可能会以非预期的方式相互干扰。例如,一个线程可能会覆盖另一个线程的更改,或者将应用程序置于未知且可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题相对容易追踪和修复。然而,如果你不幸运,腐败可能会导致微妙的错误,直到很久以后才会出现,或者错误可能需要对基础编码假设进行重大改革。
说到线程安全性,一个好的设计就是最好的保护。避免共享资源并尽量减少线程之间的交互使得线程不太可能互相干扰。然而,完全无干扰的设计并不总是可行的。在你的线程必须交互的情况下,你需要使用同步工具来确保当他们交互时,他们可以安全地执行。
OS X和iOS提供了许多同步工具供您使用,包括提供互斥访问的工具,以及在应用程序中正确排序事件的工具。
同步工具
为防止不同线程意外更改数据,可以设计应用程序以避免同步问题,也可以使用同步工具。尽管完全避免同步问题是可取的,但并非总是可行。
原子操作
原子操作是一种简单的同步形式,适用于简单的数据类型。原子操作的优点是它们不会阻塞竞争线程。对于简单的操作,例如增加一个计数器变量,这可能会导致比锁定更好的性能。
OS X和iOS包含许多操作,以便对32位和64位值执行基本的数学和逻辑运算。这些操作包括比较和交换,测试和设置以及测试和清除操作的原子版本。有关受支持的原子操作的列表,请参阅 /usr/include/libkern/OSAtomic.h 头文件或查看原子手册页。
内存障碍和挥发性变量
为了达到最佳性能,编译器通常会对汇编级指令进行重新排序,以尽可能保持处理器的指令流水线。作为这种优化的一部分,编译器可能会重新排序访问主内存的指令,因为它认为这样做不会产生不正确的数据。不幸的是,编译器并不总是能够检测到所有依赖于内存的操作。如果看似单独的变量实际上相互影响,编译器优化可能会以错误的顺序更新这些变量,从而产生潜在的错误结果。
内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序进行。内存屏障就像栅栏一样,强制处理器完成位于栅栏前的任何加载和存储操作,然后才允许其执行位于栅栏后的加载和存储操作。内存屏障通常用于确保一个线程(但对另一个线程可见)的内存操作始终按预期顺序进行。在这种情况下缺乏内存屏障可能会让其他线程看到看似不可能的结果。
易变变量将另一种类型的记忆约束应用于单个变量。编译器通常通过将变量的值加载到寄存器中来优化代码。对于局部变量,这通常不是问题。如果变量从另一个线程可见,但是这样的优化可能会阻止其他线程注意到它的任何更改。将volatile
关键字应用于变量会强制编译器在每次使用时从内存加载该变量。如果可以随时通过编译器可能无法检测到的外部源更改其值,则可以将变量声明为volatile
。
因为内存屏障和volatile
变量都会减少编译器可以执行的优化次数,所以应该谨慎使用它们,并且只在需要时才能保证正确性。
锁
锁是最常用的同步工具之一。您可以使用锁来保护代码的关键部分,这是一段代码,一次只允许一个线程访问。例如,关键部分可能会操纵特定的数据结构或使用一次最多支持一个客户端的资源。通过在本节中放置一个锁,可以排除其他线程进行可能影响代码正确性的更改。
锁类型:
- 互斥锁(Mutex)
互斥(或互斥锁)作为资源周围的保护屏障。互斥锁是一种信号量,一次只允许访问一个线程。如果一个互斥体正在使用,而另一个线程试图获取它,则该线程将阻塞,直到互斥体被其原始持有者释放。如果多个线程竞争相同的互斥量,则一次只允许一个线程访问它。
- 递归锁(Recursive lock)
递归锁是互斥锁的变体。递归锁允许单个线程在释放之前多次获取锁。其他线程会一直处于阻塞状态,直到锁的所有者释放该锁的次数与获取它的次数相同。递归锁主要在递归迭代期间使用,但也可能在多个方法需要分别获取锁的情况下使用。
- 读写锁(Read-write lock)
读写锁也被称为共享排他锁。这种类型的锁通常用于较大规模的操作,并且如果经常读取受保护的数据结构并仅偶尔进行修改,则可显着提高性能。在正常操作期间,多个阅读器可以同时访问数据结构。然而,当一个线程想要写入该结构时,它会阻塞,直到所有的读取器释放该锁,此时它获得锁并可以更新该结构。写入线程正在等待锁定时,新的读取器线程将阻塞,直到写入线程完成。系统仅支持使用POSIX线程的读写锁定。
- 分布式锁(Distributed lock)
分布式锁在进程级别提供互斥访问。与真正的互斥锁不同,分布式锁不会阻止进程或阻止进程运行。它只是报告锁何时忙,并让流程决定如何继续。
- 旋转锁(Spin lock)
自旋锁反复轮询其锁定条件,直到该条件成立。自旋锁最常用于预计等待锁定时间较短的多处理器系统。在这些情况下,轮询通常比阻塞线程更有效,这涉及上下文切换和线程数据结构的更新。由于轮询性质,系统不提供自旋锁的任何实现,但是您可以在特定情况下轻松实现它们。
- 双重检查锁(Double-checked lock)
双重检查锁试图通过在锁定之前测试锁定标准来降低获取锁的开销。由于双重检查的锁可能不安全,系统不提供对它们的明确支持,因此不鼓励使用它们。
注意:大多数类型的锁还包含内存屏障,以确保在进入临界区之前完成任何前面的加载和存储指令。
条件
条件是另一种类型的信号量,它允许线程在特定条件为真时互相发信号。条件通常用于指示资源的可用性或确保任务按特定顺序执行。当一个线程测试一个条件时,它会阻塞,除非该条件已经成立。直到其他线程明确更改并发出信号,它才会被阻塞。条件和互斥锁之间的区别在于可能允许多个线程同时访问条件。这种情况更多的是一个看门人,它根据一些特定的标准让不同的线程通过门。
您可能会使用某种条件的一种方法是管理一系列未决事件。当队列中有事件时,事件队列将使用条件变量来通知等待线程。如果有一个事件到达,队列会适当地发出信号。如果一个线程已经在等待,它会被唤醒,然后它将把队列从队列中拉出来并处理它。如果两个事件大致同时进入队列,则队列将两次通知状况以唤醒两个线程。
执行选择器例程(Routine)
Cocoa 应用程序有一种方便的方式将消息以同步方式传递给单个线程。NSObject类声明了在应用程序的一个活动线程上执行选择器的方法。这些方法允许线程异步传递消息,并保证它们将由目标线程同步执行。例如,可以使用执行选择器消息将分布式计算的结果传递到应用程序的主线程或指定的协调器线程。执行选择器的每个请求都在目标线程的运行循环中排队,然后按接收到的顺序按顺序处理请求。
同步开销和性能
同步有助于确保代码的正确性,但这样做会牺牲性能。同步工具的使用会引入延迟,即使在无可争议的情况下也是如此。锁和原子操作通常涉及使用内存屏障和内核级同步来确保代码得到适当的保护。而且如果有锁争用,你的线程可能会阻止并经历更大的延迟。
线程安全和信号
线程安全设计提示
同步工具是使代码线程安全的有用方法,但它们不是万能的。与非线程性能相比,使用太多的锁和其他类型的同步原语实际上会降低应用程序的线程性能。找到安全和性能之间的正确平衡是一门需要经验的艺术。
完全避免同步
实现并发的最好方法是减少并发任务之间的交互和相互依赖。如果每个任务都在自己的专用数据集上运行,则不需要使用锁保护该数据。即使在两个任务共享一个通用数据集的情况下,您也可以查看分区的方式或为每个任务提供自己的副本。当然,复制数据集也会带来成本,因此您必须在做出决定之前将这些成本与同步成本进行权衡。
了解同步的限制
同步工具只有在应用程序中的所有线程一致使用它们时才有效。如果您创建一个互斥体来限制对特定资源的访问,则所有线程都必须在尝试操作资源之前获取相同的互斥体。果不这样做会破坏互斥体提供的保护,并且是程序员错误。
注意代码正确性的威胁
在使用锁和内存屏障时,您应该始终仔细考虑它们在代码中的位置。即使看起来很好的锁也能让你陷入虚假的安全感。
注意死锁和活锁
任何时候一个线程试图同时使用多个锁,就有可能发生死锁。当两个不同的线程持有另一个线程需要的锁并尝试获取另一个线程持有的锁时,会发生死锁。结果是每个线程永久阻塞,因为它永远无法获取其他锁。
活锁类似于死锁,并在两个线程竞争同一组资源时发生。在活锁情况下,一个线程放弃它的第一个锁,试图获得第二个锁。一旦它获得第二个锁,它就会返回并尝试再次获取第一个锁。它锁定了,因为它花费所有时间释放一个锁并试图获得另一个锁而不是做任何真正的工作。
避免死锁和活锁情况的最好方法是一次只取一个锁。如果你一次只能获得一个以上的锁,你应该确保其他线程不会尝试做类似的事情。
正确使用易失变量
如果您已经在使用互斥体来保护一段代码,请不要自动假设您需要使用volatile
关键字来保护该部分中的重要变量。互斥体包含一个内存屏障,以确保加载和存储操作的正确顺序。将volatile
关键字添加到临界区域内的变量会强制每次访问时从内存加载该值。如果只有互斥量足以保护变量,则省略volatile
关键字。
为避免使用互斥体,不要使用volatile
变量也很重要。一般来说,互斥锁和其他同步机制是保护数据结构完整性的一种更好的方式,而不是易失性变量。volatile
关键字只确保变量从内存中加载而不是存储在寄存器中。它不能确保您的代码正确访问该变量。
使用原子操作
非阻塞同步是执行某些类型的操作并避免锁的开销的一种方式。尽管锁是同步两个线程的有效方法,但即使在无可争议的情况下,获取锁也是一项相对昂贵的操作。相比之下,许多原子操作需要一小部分时间来完成,并且可以像锁一样有效。
原子操作允许您对32位或64位值执行简单的数学和逻辑运算。这些操作依赖于特殊的硬件指令(以及可选的内存屏障),以确保在受影响的内存再次访问之前完成给定的操作。在多线程的情况下,您应始终使用包含内存屏障的原子操作来确保内存在线程之间正确同步。
使用锁
锁是线程编程的基本同步工具。锁可让您轻松保护大量代码,以确保代码的正确性。OS X和iOS为所有应用程序类型提供基本的互斥锁,Foundation框架为特殊情况定义了互斥锁的一些其他变体。
使用POSIX互斥锁
POSIX互斥锁非常易于在任何应用程序中使用。要创建互斥锁,可以声明并初始化pthread_mutex_t结构。要锁定和解锁互斥锁,可以使用pthread_mutex_lock和pthread_mutex_unlock函数。下面显示了初始化和使用POSIX线程互斥锁所需的基本代码。当你完成锁定时,只需调用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);
}
使用NSLock类
一个NSLock
对象为Cocoa应用程序实现了一个基本的互斥锁。所有锁(包括NSLock
)的接口实际上由NSLocking
协议定义,该协议定义了lock
和unlock
方法。您可以像使用互斥锁一样使用这些方法来获取和释放锁。
除了标准的锁定行为之外,NSLock
类还添加了tryLock
和lockBeforeDate:
方法。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];
}
}
使用@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异常处理。如果您不想由隐式异常处理程序引起的额外开销,则应考虑使用锁类。
使用其他 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协议,因此没有锁定方法。锁定方法会阻止线程的执行并要求系统以预定速率轮询锁定。NSDistributedLock提供了一个tryLock方法,并且可以决定是否轮询,而不是在代码中施加这种惩罚。
由于它是使用文件系统实现的,因此除非所有者明确释放它,否则不会释放NSDistributedLock对象。如果您的应用程序在持有分布式锁的同时崩溃,则其他客户端将无法访问受保护的资源。在这种情况下,您可以使用breakLock方法来分解现有的锁,以便获取它。但是,通常应该避免打破锁定,除非您确定拥有的流程已经死亡并且无法释放锁。
使用条件
条件是一种特殊类型的锁,您可以使用它来同步操作必须执行的顺序。它们以微妙的方式不同于互斥锁。一个等待条件的线程将一直处于阻塞状态,直到该条件由另一个线程显式指示。
由于实现操作系统所涉及的细微之处,即使条件锁实际上没有被代码实际发送信号,也允许条件锁以虚假成功返回。为了避免由这些虚假信号引起的问题,您应该始终将谓词与条件锁定结合使用。他的谓词是确定线程是否安全进行的更具体的方法。条件只是保持你的线程睡着,直到谓词可以由信号线程设置。
使用NSCondition类
NSCondition类提供与POSIX条件相同的语义,但将所需的锁和条件数据结构封装在单个对象中。结果是一个对象,您可以像互斥锁一样锁定,然后像条件一样等待。
下面显示了一段代码片段,演示了在NSCondition
对象上等待的事件序列。cocoaCondition
变量包含一个NSCondition
对象,timeToDoWork
变量是一个整数,在发送条件前立即从另一个线程递增。
使用 Cocoa 条件:
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];
timeToDoWork--;
// Do real work here.
[cocoaCondition unlock];
下面显示了用来表示Cocoa条件并增加谓词变量的代码。在发出信号之前,您应该始终锁定条件。
发信号给 Cocoa 条件:
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];
Using POSIX Conditions
POSIX线程条件锁需要同时使用条件数据结构和互斥锁。虽然两个锁结构是分开的,但互斥锁在运行时与条件结构紧密相连。等待信号的线程应始终使用相同的互斥锁和条件结构。更改配对可能会导致错误。
下面显示了条件和谓词的基本初始化和用法。初始化条件和互斥锁后,等待线程使用ready_to_go
变量作为谓词进入while循环。只有当谓词被设置并且条件随后发出时,等待线程才会唤醒并开始工作。
使用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);
}
信号线程负责设置谓词并将信号发送到条件锁定。下面显示了实现这种行为的代码。在这个例子中,该条件在互斥体内部发出信号,以防止在等待条件的线程之间发生竞争条件。
发出条件锁信号:
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);
}