这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战。
iOS多线程编程系列
- iOS 多线程编程之概述
- iOS 多线程编程之RunLoop
iOS 多线程编程之同步
修改同一资源的两个线程可能会以意想不到的方式相互干扰。例如,一个线程可能会覆盖另一个线程的更改或将应用程序置于未知且可能无效的状态。如果幸运的话,损坏的资源可能会导致明显的性能问题或崩溃,这些问题相对容易追踪和修复。但是,如果不走运,损坏可能会导致微妙的错误,直到很久以后才会显现出来,或者这些错误可能需要对底层编码进行重大检查。
在线程安全方面,避免共享资源并最小化线程之间的交互可以降低这些线程相互干扰的可能性。然而,完全无干扰的设计并不总是可行的。在线程必须交互的情况下,需要使用同步工具来确保它们在交互时安全地进行。
OS X 和 iOS 提供了许多同步工具供使用,从提供互斥访问的工具到在应用程序中正确排序事件的工具。以下部分描述了这些工具以及如何在代码中使用它们来影响对程序资源的安全访问。
同步工具
Atomic Operations(原子操作)
原子操作是一种简单的同步形式,适用于简单的数据类型。 原子操作的优点是它们不会阻塞竞争线程。 对于简单的操作,例如递增计数器变量,这可以带来比锁定更好的性能。
iOS编程开发中很少会使用atomic来解决线程安全问题.
调用示例
#import <libkern/OSAtomic.h>
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.
注:iOS10 以后该相关API已被废弃: 'OSAtomicTestAndSet' is deprecated: first deprecated in iOS 10.0 - Use atomic_fetch_or_explicit(memory_order_relaxed) from <stdatomic.h> instead. 具体参见 链接
Memory Barriers and Volatile Variables
- Memory Barriers 为了达到最佳性能,编译器经常对汇编级指令重新排序,以使处理器的指令流水线尽可能满。作为此优化的一部分,编译器可能会在认为这样做不会生成错误数据时重新排序访问主存储器的指令。不幸的是,编译器并不总是能够检测到所有与内存相关的操作。如果看似独立的变量实际上相互影响,编译器优化可能会以错误的顺序更新这些变量,从而产生潜在的错误结果。
Memory Barriers是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。Memory Barriers的作用类似于栅栏,强制处理器在允许执行位于屏障之后的加载和存储操作之前完成位于屏障前面的任何加载和存储操作。Memory Barriers通常用于确保一个线程(但对另一个线程可见)的内存操作总是以预期的顺序发生。要使用内存屏障,只需在代码中的适当位置调用 OSMemoryBarrier 函数。
为了达到最佳性能,编译器通常会讲汇编级别的指令进行重新排序,从而保持处理器的指令管道尽可能的满。作为优化的一部分,编译器可能会对内存访问的指令进行重新排序(在它认为不会影响数据的正确性的前提下),然而,这并不一定都是正确的,顺序的变化可能导致一些变量的值得到不正确的结果。
Memory Barriers是一种不会造成线程block的同步工具,它用于确保内存操作的正确顺序。
- Volatile
Volatile变量将另一种类型的内存约束应用于单个变量。编译器通常通过将变量的值加载到寄存器中来优化代码。对于局部变量,这通常不是问题。但是,如果变量在另一个线程中可见,则这种优化可能会阻止其他线程注意到它的任何更改。将 volatile 关键字应用于变量会强制编译器在每次使用该变量时从内存中加载该变量。如果编译器可能无法检测到的外部源可以随时更改变量的值,则可以将变量声明为 volatile。
因为内存屏障和 volatile 变量都会减少编译器可以执行的优化次数,所以应该谨慎使用它们,并且只在需要确保正确性的地方使用它们。
LOCK
锁是最常用的同步工具之一。 可以使用锁来保护代码的关键部分,即一次只允许一个线程访问的一段代码。 例如,临界区可能会操纵特定的数据结构或使用一次最多支持一个客户端的某些资源。 通过锁定此部分,可以排除其他线程进行可能影响代码正确性的更改。
下图列出了一些程序员常用的锁。 OS X 和 iOS 为这些锁类型中的大多数提供了实现,但不是全部。 对于不支持的锁类型,描述栏解释了这些锁没有直接在平台上实现的原因。
| Lock | Description |
|---|---|
| Mutex | 互斥(或互斥)锁充当资源周围的保护屏障。 互斥锁是一种信号量,一次只允许访问一个线程。 如果互斥锁正在使用中并且另一个线程尝试获取它,则该线程将阻塞,直到互斥锁被其原始持有者释放。 如果多个线程竞争同一个互斥锁,则一次只允许一个线程访问它. |
| Recursive lock | 递归锁是互斥锁的变体。 递归锁允许单个线程在释放锁之前多次获取锁。 其他线程保持阻塞状态,直到锁的所有者释放锁的次数与其获得锁的次数相同。 递归锁主要用于递归迭代期间,但也可用于多个方法都需要分别获取锁的情况. |
| Read-write lock | 读写锁也称为共享排他锁。 这种类型的锁通常用于更大规模的操作,如果频繁读取受保护的数据结构并且只是偶尔修改,则可以显着提高性能。 在正常操作期间,多个阅读器可以同时访问数据结构。 但是,当一个线程想要写入结构时,它会阻塞,直到所有读者释放锁,此时它获取锁并可以更新结构。 当写入线程等待锁定时,新的读取线程会阻塞,直到写入线程完成。 系统仅支持使用 POSIX 线程的读写锁. |
| Distributed lock | 分布式锁在进程级别提供互斥访问。 与真正的互斥锁不同,分布式锁不会阻塞进程或阻止它运行。 它只是在锁忙时报告并让进程决定如何继续. |
| Spin lock | 自旋锁反复轮询其锁定条件,直到该条件变为真。 自旋锁最常用于锁的预期等待时间很短的多处理器系统上。 在这些情况下,轮询通常比阻塞线程更有效,这涉及上下文切换和线程数据结构的更新。 由于自旋锁的轮询性质,系统不提供任何实现,但可以在特定情况下轻松实现它们.具体参见 Kernel Programming Guide. |
| Double-checked lock | 双重检查锁试图通过在获取锁之前测试锁定条件来减少获取锁的开销。 因为双重检查锁可能是不安全的,所以系统不为它们提供明确的支持,并且不鼓励使用它们. |
Note:大多数类型的锁还包含一个内存屏障,以确保在进入临界区之前完成任何先前的加载和存储指令.
pthread mutex 代码示例
pthread_mutex_t mutex;
void MyInitFunction()
{
pthread_mutex_init(&mutex, NULL);
}
void MyLockingFunction()
{
pthread_mutex_lock(&mutex);
// Do work.
pthread_mutex_unlock(&mutex);
}
NSLock Class 示例代码
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];
}
}
NSLock 对象为 Cocoa 应用程序实现了一个基本的互斥锁。 所有锁(包括 NSLock)的接口实际上是由 NSLocking 协议定义的,它定义了 lock 和 unlock 方法。 您可以像使用任何互斥锁一样使用这些方法来获取和释放锁。
除了标准的锁定行为,NSLock 类添加了 tryLock 和 lockBeforeDate: 方法。 tryLock 方法尝试获取锁,但如果锁不可用则不阻塞; 相反,该方法只返回 NO。 lockBeforeDate: 方法尝试获取锁,但如果在指定的时间限制内未获取锁,则解锁线程(并返回 NO)
@synchronized
- (void)myMethod:(id)anObj
{
@synchronized(anObj)
{
// Everything between the braces is protected by the @synchronized directive.
}
}
@synchronized 指令是在 Objective-C 代码中动态创建互斥锁的便捷方式。 @synchronized 指令做了任何其他互斥锁会做的事情——它防止不同的线程同时获取相同的锁。
NSRecursiveLock
NSRecursiveLock 类定义了一个锁,它可以被同一个线程多次获取而不会导致线程死锁。 递归锁跟踪它被成功获取的次数。 每次成功获取锁都必须通过相应的调用来解锁锁来平衡。 只有当所有的锁和解锁调用都平衡时,锁才会真正释放,以便其他线程可以获取它。
顾名思义,这种类型的锁通常用于递归函数内部,以防止递归阻塞线程。 可以类似地在非递归情况下使用它来调用语义要求它们也获取锁的函数。 这是一个通过递归获取锁的简单递归函数的示例。 如果您没有为此代码使用 NSRecursiveLock 对象,则再次调用该函数时线程将死锁。
NSRecursiveLock *theLock = [[NSRecursiveLock alloc] init];
void MyRecursiveFunction(int value) ```
{
[theLock lock];
if (value != 0){
--value;
MyRecursiveFunction(value);
}
[theLock unlock];
}
MyRecursiveFunction(5);
注意:由于在所有锁调用与解锁调用平衡之前不会释放递归锁,因此您应该仔细权衡使用性能锁的决定与潜在的性能影响。 长时间持有任何锁可能会导致其他线程阻塞,直到递归完成。 如果您可以重写代码以消除递归或消除使用递归锁的需要,您可能会获得更好的性能。
Condition
条件是另一种类型的信号量,它允许线程在某个条件为真时相互发出信号。条件通常用于指示资源的可用性或确保以特定顺序执行任务。当一个线程测试一个条件时,它会阻塞,除非该条件已经为真。它一直处于阻塞状态,直到某个其他线程显式更改并发出条件信号。条件和互斥锁的区别在于可以允许多个线程同时访问条件。该条件更像是一个看门人,它根据某些指定的标准让不同的线程通过门。
使用条件的一种方法是管理待处理事件池。当队列中有事件时,事件队列将使用条件变量向等待线程发出信号。如果一个事件到达,队列将适当地发出条件信号。如果一个线程已经在等待,它将被唤醒,然后它将从队列中拉出事件并处理它。如果两个事件大致同时进入队列,队列将发出两次信号以唤醒两个线程.
NSCondition Class
//Using a Cocoa condition
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];
timeToDoWork--;
// Do real work here.
[cocoaCondition unlock];
//Signaling a Cocoa condition
[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];
POSIX Conditions
//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);
}
//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 线程条件函数的基本用法。 您自己的代码应该检查这些函数返回的错误代码并适当地处理它们.
NSConditionLock
NSConditionLock 对象定义了一个互斥锁,可以使用特定值锁定和解锁。不应将这种类型的锁与condition混淆。该行为与condition有些相似,但实现方式却大不相同。
通常,当线程需要以特定顺序执行任务时,例如当一个线程产生另一个线程使用的数据时,您使用 NSConditionLock 对象。在生产者执行时,消费者使用特定于您的程序的条件获取锁。 (条件本身只是一个您定义的整数值。)当生产者完成时,它解锁锁并将锁定条件设置为适当的整数值以唤醒消费者线程,然后消费者线程继续处理数据。
NSConditionLock 对象响应的锁定和解锁方法可以任意组合使用。例如,可以将 lock 消息与 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.
}
Perform Selector
Cocoa 中有一种方便的方式以同步方式将消息传递到单个线程。 NSObject 类声明了在app的一个活动线程上执行选择器的方法。 这些方法让线程异步传递消息,并保证它们将由目标线程同步执行。 例如,可以使用执行选择器消息将分布式计算的结果传递到应用程序的主线程或指定的协调器线程。 每个执行选择器的请求都在目标线程的RunLoop中排队,然后按照接收到的顺序依次处理这些请求.
线程安全 和 Signals
Signal是一种底层的 BSD 机制,可用于将信息传递给进程或以某种方式对其进行操作。一些程序使用signal来检测某些事件,例如子进程的死亡。该系统使用信号来终止失控的进程并传达其他类型的信息。
signal的问题不在于它们做了什么,而在于当App有多个线程时它们的行为。在单线程应用程序中,所有信号处理程序都在主线程上运行。在多线程应用程序中,与特定硬件错误(例如非法指令)无关的信号被传递到当时恰好正在运行的任何线程。如果多个线程同时运行,则信号将传递给系统碰巧选择的任何一个。换句话说,信号可以传递到应用程序的任何线程。
在应用程序中实现信号处理程序的第一条规则是避免假设哪个线程正在处理信号。如果一个特定的线程想要处理一个给定的信号,需要想办法在信号到达时通知那个线程。不能仅仅假设从该线程安装信号处理程序将导致信号被传递到同一线程.
线程安全设计技巧
同步工具是使代码线程安全的有用方法,但它们并不是万能的。 与非线程性能相比,使用过多的锁和其他类型的同步原语实际上会降低应用程序的线程性能。 在安全和性能之间找到适当的平衡是一门需要经验的艺术。 以下部分提供的提示可帮助App选择适当的同步级别.
完全避免同步
对于从事的任何新项目,甚至对于现有项目,设计代码和数据结构以避免需要同步是最好的解决方案。 尽管锁和其他同步工具很有用,但它们确实会影响任何应用程序的性能。 如果整体设计导致特定资源之间的高度争用,线程可能会等待更长时间。
实现并发的最佳方式是减少并发任务之间的交互和相互依赖。 如果每个任务都对自己的私有数据集进行操作,则不需要使用锁来保护该数据。 即使在两个任务共享一个公共数据集的情况下,也可以查看对该数据集进行分区或为每个任务提供其自己的副本的方法。 当然,复制数据集也有其成本,因此在做出决定之前,必须权衡这些成本与同步成本.
了解同步的限制
同步工具只有在应用程序中的所有线程一致使用时才有效。 如果创建互斥锁来限制对特定资源的访问,则所有线程都必须在尝试操作该资源之前获取相同的互斥锁。 不这样做会破坏互斥锁提供的保护,并且是程序员错误.
注意死锁和活锁
任何时候一个线程试图同时获取多个锁,就有可能发生死锁。当两个不同的线程持有另一个线程需要的锁,然后尝试获取另一个线程持有的锁时,就会发生死锁。结果是每个线程都永久阻塞,因为它永远无法获得另一个锁。
活锁类似于死锁,当两个线程竞争同一组资源时发生。在活锁情况下,线程放弃它的第一个锁以尝试获取它的第二个锁。一旦它获得第二个锁,它就会返回并尝试再次获得第一个锁。它锁定是因为它花费所有时间释放一个锁并尝试获取另一个锁,而不是做任何实际工作。
避免死锁和活锁情况的最好方法是一次只使用一个锁。如果必须一次获取多个锁,则应确保其他线程不会尝试执行类似的操作。
正确使用volatile
如果您已经在使用互斥锁来保护一段代码,请不要自动假设您需要使用 volatile 关键字来保护该段内的重要变量。互斥体包括一个内存屏障,以确保加载和存储操作的正确顺序。将 volatile 关键字添加到临界区中的变量会强制在每次访问该值时从内存中加载该值。这两种同步技术的组合在特定情况下可能是必要的,但也会导致显着的性能损失。如果仅使用互斥锁就足以保护变量,请省略 volatile 关键字。
同样重要的是不要使用 volatile 变量来避免使用互斥锁。一般来说,互斥锁和其他同步机制是比易失性变量更好的保护数据结构完整性的方法。 volatile 关键字仅确保变量从内存中加载,而不是存储在寄存器中。它不能确保您的代码可以正确访问该变量。