iOS - 关于线程同步

引用自多线程编程指南
应用程序里面多个线程的存在引发了多个执行线程安全访问资源的潜在问题。两个线程同时修改同一资源有可能以意想不到的方式互相干扰。比如,一个线程可能覆盖其他线程改动的地方,或让应用程序进入一个未知的潜在无效状态。如果你幸运的话,受损的资源可能会导致明显的性能问题或崩溃,这样比较容易跟踪并修复它。然而如果你不走运,资源受损可能导致微妙的错误,这些错误不会立即显现出来,而是很久之后才出现,或者导致其他可能需要一个底层的编码来显著修复的错误。
但涉及到线程安全时,一个好的设计是最好的保护。避免共享资源,并尽量减少线程间的相互作用,这样可以让它们减少互相的干扰。但是一个完全无干扰的设计是不可能的。在线程必须交互的情况下,你需要使用同步工具,来确保当它们交互的时候是安全的。

一、同步工具

  • 原子操作
    原子操作是同步的一个简单的形式,它处理简单的数据类型。原子操作的优势是它们不妨碍竞争的线程。对于简单的操作,比如递增一个计数器,原子操作比使用锁具有更高的性能优势。
  • 内存屏障和 Volatile 变量
    为了达到最佳性能,编译器通常会对汇编基本的指令进行重新排序来尽可能保持处理器的指令流水线。作为优化的一部分,编译器有可能对访问主内存的指令,如果它认为这有可能产生不正确的数据时,将会对指令进行重新排序。不幸的是,靠编译器检测到所有可能内存依赖的操作几乎总是不太可能的。如果看似独立的变量实际上是相互影响,那么编译器优化有可能把这些变量更新位错误的顺序,导致潜在不不正确结果。
    内存屏障(memory barrier) 是一个使用来确保内存操作按照正确的顺序工作的非阻塞的同步工具。内存屏障的作用就像一个栅栏,迫使处理器来完成位于障碍前面的任何加载和存储操作,才允许它执行位于屏障之后的加载和存储操作。内存屏障同样使用来确保一个线程(但对另外一个线程可见)的内存操作总是按照预定的顺序完成。如果在这些地方缺少内存屏障有可能让其他线程看到看似不可能的结果(比如,内存屏障的维基百科条目)。为了使用一个内存屏障,你只要在你代码里面需要的地方简单的调用 OSMemoryBarrier 函数。
    Volatile 变量适用于独立变量的另一个内存限制类型。编译器优化代码通过加载这些变量的值进入寄存器。对于本地变量,这通常不会有什么问题。但是如果一个变量对另外一个线程可见,那么这种优化可能会阻止其他线程发现变量的任何变化。在变量之前加上关键字 volatile 可以强制编译器每次使用变量的时候都从内存里面加载。如果一个变量的值随时可能给编译器无法检测的外部源更改,那么你可以把该变量声明为 volatile 变量。
    因为内存屏障和 volatile 变量降低了编译器可执行的优化,因此你应该谨慎使用它们,只在有需要的地方时候,以确保正确性。

  • 锁是最常用的同步工具。你可以是使用锁来保护临界区(critical section),这些代码段在同一个时间只能允许被一个线程访问。比如,一个临界区可能会操作一个特定的数据结构,或使用了每次只能一个客户端访问的资源。
    下面列出了程序最常使用的锁。Mac OS X 和 iOS 提供了这些锁里面大部分类型的实现,但是并不是全部实现。对于不支持的锁类型,说明列解析了为什么这些锁不能直接在平台上面实现的原因。
    • Mutex 互斥锁:
      一个互斥锁扮演了围绕一个资源的保护的栅栏。互斥锁是一种让资源同一时间只能通过一个线程访问的信号量。如果一个互斥锁正在使用,有其他线程尝试去访问它,那么线程会一直阻塞知道互斥锁被原来的持有者释放。如果多线程为了一个互斥锁竞争,在同一时间只有一个线程被允许访问。
    • Recursive lock 递归锁
      递归所是一个变种的互斥锁。地贵所允许一个线程去在释放之前访问多次。其他的线程一直保持着阻塞状态知道所的持有者释放锁的次数和创建时候一样为止。递归锁主要是在递归期间使用但是也可以被用在当多个方法都需要分别获取锁的时候来使用。
    • Read-write lock 读写锁
      读写锁也被称为 shared-exclusive 锁,这个类型的锁通常用于大范围的操作并且能够显著的提升性能如果受保护的数据结构经常被频繁的读取和偶尔的修改。在普通的操作期间,多个读者可以同时访问数据结构。当一个线程想要去写数据的时候,他会一直阻塞知道所有的读者释放这个锁,到那时他才可以拿到这个锁并且更新数据。当一个写线程正在等待锁的时候,一个新的读线程会组织色知道这个写线程结束操作。系统只支持 POSIX 线程使用读写锁。想知道更多的信息请查看这里 pthread
    • Distributed lock分布锁
      一个分布锁在程序级别提供了互斥的访问。不像一个真正的互斥锁,一个分布所不会阻塞一个程序或者在它运行的时候去保护它。它仅仅是当锁忙的时候会报告之后让程序来决定如何处理。
    • Spin lock自旋锁
      自旋锁会一直检测它锁的情况知道条件为真。自旋锁多被用在多处理器系统的期待等待锁的时间比较小的时候。在这些情况下,更有效率的做法是去检测而不是去包括上下文的切换和刷新线程的数据结构那样去阻塞线程。因为自旋锁的轮询性质系统不提供任何的自旋锁。更多的信息可以参考内核编程指南。
    • Double-checked lock 双重检查锁
      双重检查锁定试图采取一个锁的开销减少测试前锁定标准锁。因为双重检查锁定潜在的不安全,系统不提供显式支持他们和他们的使用是。
      注意 :大部分锁类型都合并了内存屏障来确保在进入临界区之前它前面的加载和存储指令都已经完成。
  • 条件
    条件是信号量的另外一个形式,它允许在条件为真的时候线程间互相发送信号。条件通常被使用来说明资源可用性,或用来确保任务以特定的顺序执行。当一个线程测试一个条件时,它会被阻塞直到条件为真。它会一直阻塞直到其他线程显式的修改
    信号量的状态。条件和互斥锁(mutex lock)的区别在于多个线程被允许同时访问一个条件。条件更多是允许不同线程根据一些指定的标准通过的守门人。
    一个方式是你使用条件来管理挂起事件的池。事件队列可能使用条件变量来给等待线程发送信号,此时它们在事件队列中的时候。如果一个事件到达时,队列将给条件发送合适信号。如果一个线程已经处于等待,它会被唤醒,届时它将会取出事件并处理它。如果两个事件到达队列的时间大致相同,队列将会发送两次信号唤醒两个线程。
  • 执行 Selector 例程
    Cocoa 程序包含了一个在一个线程以同步的方式传递消息的方便方法。NSObject类声明方法来在应用的一个活动线程上面执行 selector 的方法。这些方法允许你的线程以异步的方式来传递消息,以确保它们在同一个线程上面执行是同步的。比如,你可以通过执行 selector 消息来把一个从你分布计算的结果传递给你的应用的主线程或其他目标线程。每个执行 selector 的请求都会被放入一个目标线程的 run loop的队列里面,然后请求会按照它们到达的顺序被目标线程有序的处理。

二、同步的成本和性能

  • 同步帮助确保你代码的正确性,但同时将会牺牲部分性能。锁和原子操作通常包含了内存屏障和内核级别同步的使用来确保代码正确被保护。如果,发生锁的争夺,你的线程有可能进入阻塞,在体验上会产生更大的迟延。
    以下数据列出了在无争议情况下使用互斥锁和原子操作的近似的相关成本。这些测试的平均值是使用了上千的样本分析出的结果。随着线程创建时间的推移,互斥采集时间(即使在无争议情况下)可能相差也很大,这依赖于进程的加载,计算机的处理速度和系统和程序现有可用的内存。
    • 互斥获取时间约0.2微秒,这是在一个无争议的情况下锁获取时间。如果另一个线程持有的锁,获取时间可以更大。测定的数据分析生成的均值和中位数的值在互斥锁在英特尔的iMac收购2 GHz酷睿双核处理器,1 GB的运行Mac OS X v10.5RAM。
    • 原子微妙比较和交换,0.05微妙,这是比较时间在一个无争议的情况下。数据分析测定均值和中位数的值操作,生成一个基于英特尔处理器的iMac 2 GHz酷睿双核处理器,1 GB内存运行原子大约0.05微秒比较。
      当设计你的并发任务时,正确性是最重要的因素,但是也要考虑性能因素。代码在多个线程下面正确执行,但比相同代码在当线程执行慢,这是难以改善的。如果你是改造已有的单线程应用,你应该始终给关键任务的性能设置测量基线。当增加额外线程后,对相同的任务你应该采取新的测量方法并比较多线程和单线程情况下的性能状况。在改变代码之后,线程并没有提高性能,你应该需要重新考虑具体的实现或同时使用线程。

三、线程安全和信号量

当涉及到多线程应用程序时,没有什么比处理信号量更令人恐惧和困惑的了。信号量是底层 BSD 机制,它可以用来传递信息给进程或以某种方式操纵它。一些应用程序使用信号量来检测特定事件,比如子进程的消亡。系统使用信号量来终止失控进程,和作为其他类型的通信消息。
使用信号量的问题并不是你要做什么,而是当你程序是多线程的时候它们的行为。在当线程应用程序里面,所有的信号量处理都在主线程进行。在多线程应用程序里面,信号量被传递到恰好运行的线程,而不依赖于特定的硬件错误(比如非法指令)。如果多个线程同时运行,信号量被传递到任何一个系统挑选的线程。换而言之,信号量可以传递给你应用的任何线程。
在你应用程序里面实现信号量处理的第一条规则是避免假设任一线程处理信号量。如果一个指定的线程想要处理给定的信号,你需要通过某些方法来通知该线程信号何时到达。你不能只是假设该线程的一个信号处理例程的安装会导致信号被传递到同一线程里面。
关于更多信号量的信息和信号量处理例程的安装信息,参见 signal 和 sigaction主页。


四、线程安全设计的技巧

同步工具是让你代码安全的有用方法,但是它们并非灵丹妙药。使用太多锁和其他同步的类型原语和非多线程相比明显会降低你应用的线程性能。在性能和安全之间寻找平衡是一门需要经验的艺术。以下各部分提供帮助你为你应用选择合适的同步级别的技巧。

  • 完全避免同步
    对于你新的项目,甚至已有项目,设计你的代码和数据结构来避免使用同步是一个很好的解决办法。虽然锁和其他类型同步工具很有用,但是它们会影响任何应用的性能。而且如果整体设计导致特定资源的高竞争,你的线程可能需要等待更长时间。
    实现并发最好的方法是减少你并发任务之间的交互和相互依赖。如果每个任务在它自己的数据集上面操作,那它不需要使用锁来保护这些数据。甚至如果两个任务共享一个普通数据集,你可以查看分区方法,它们设置或提供拷贝每一项任务的方法。当然,拷贝数据集本身也需要成本,所以在你做出决定前,你需要权衡这些成本和使用同步工具造成的成本那个更可以接受。
  • 了解同步的限制
    同步工具只有当它们被用在应用程序中的所有线程是一致时才是有效的。如果你创建了互斥锁来限制特定资源的访问,你所有线程都必须在试图操纵资源前获得同一互斥锁。如果不这样做导致破坏一个互斥锁提供的保护,这是编程的错误。
  • 注意对代码正确性的威胁
    当你使用锁和内存屏障时,你应该总是小心的把它们放在你代码正确的地方。即
    使有条件的锁(似乎很好放置)也可能会让你产生一个虚假的安全感。以下一系列例
    子试图通过指出看似无害的代码的漏洞来举例说明该问题。其基本前提是你有一个可变的数组,它包含一组不可变的对象集。假设你想要调用数组中第一个对象的方法。你可能会做类似下面那样的代码:
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];
[anObject doSomething]

以上例子说明了想操作可变数组中的一个对象去做一些事情,当我拿到这个对象之后如果有其他线程进入到锁中删除了这个对象,那么就会出现问题,那么可能会像下面这样修改代码:

NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;
[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];

这样做也是不好的,因为如果 dosomething 的执行时间很长的话就会产生性能瓶颈。所以最好的办法是这样:

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 位的处理器上面执行简单的数学和逻辑的运算操作。这些操作依赖于特定的硬件设施(和可选的内存屏障)来保证给定的操作在影响内存再次访问的时候已经完成。在多线程情况下,你应该总是使用原子操作,它和内存屏障组合使用来保证多个线程间正确的同步内存。


原子操作.png

六、使用锁

锁是线程编程同步工具的基础。锁可以让你很容易保护代码中一大块区域以便你可以确保代码的正确性。Mac OS X 和 iOS 都位所有类型的应用程序提供了互斥锁,而 Foundation 框架定义一些特殊情况下互斥锁的额外变种。以下个部分显式了如何使用这些锁的类型。

  • 使用 POSIX 互斥锁
    POSIX 互斥锁在很多程序里面很容易使用。为了新建一个互斥锁,你声明并初始化一个 pthread_mutex_t 的结构。为了锁住和解锁一个互斥锁,你可以使用pthread_mutex_lock 和 pthread_mutex_unlock 函数。列表 4-2 显式了要初始化并使用一个 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);
}

注意:上面的代码只是简单的显式了使用一个 POSIX 线程互斥锁的步骤。你自己的代码应该检查这些函数返回的错误码,并适当的处理它们。

  • 使用NSLock类
    在 Cocoa 程序中 NSLock 中实现了一个简单的互斥锁。所有锁(包括 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 指令,你必须在你的代码中启用异常处理。如果你不想让隐式的异常处理例程带来额外的开销,你应该考虑使用锁的类。

  • 使用其他 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:和 lock 消息,或使用 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.
-  使用 NSDistributedLock 对象

NSDistributedLock 类可以被多台主机上的多个应用程序使用来限制对某些共享资源的访问,比如一个文件。锁本身是一个高效的互斥锁,它使用文件系统项目来实现,比如一个文件或目录。对于一个可用的 NSDistributedLock 对象,锁必须由所有使用它的程序写入。这通常意味着把它放在文件系统,该文件系统可以被所有运行在计算机上面的应用程序访问。
不像其他类型的锁,NSDistributedLock 并没有实现 NSLocking 协议,所有它没有 lock 方法。一个 lock 方法将会阻塞线程的执行,并要求系统以预定的速度轮询锁。以其在你的代码中实现这种约束,NSDistributedLock 提供了一个 tryLock 方法,并让你决定是否轮询。
因为它使用文件系统来实现,一个 NSDistributedLock 对象不会被释放除非它的拥有者显式的释放它。如果你的程序在用户一个分布锁的时候崩溃了,其他客户端无法访问该受保护的资源。在这种情况下,你可以使用 breadLock 方法来打破现存的锁以便你可以获取它。但是通常应该避免打破锁,除非你确定拥有进程已经死亡并不可能再释放该锁。
和其他类型的锁一样,当你使用 NSDistributedLock 对象时,你可以通过调用unlock 方法来释放它。


七、使用条件

条件是一个特殊类型的锁,你可以使用它来同步操作必须处理的顺序。它们和互斥锁有微妙的不同。一个线程等待条件会一直处于阻塞状态直到条件获得其他线程显式发出的信号。
由于微妙之处包含在操作系统实现上,条件锁被允许返回伪成功,即使实际上它们并没有被你的代码告知。为了避免这些伪信号操作的问题,你应该总是在你的条件锁里面使用一个断言。该断言是一个更好的方法来确定是否安全让你的线程处理。条件简单的让你的线程保持休眠直到断言被发送信号的线程设置了。

  • 使用NSCondition类
    NSCondition 类提供了和 POSIX 条件相同的语义,但是它把锁和条件数据结构封装在一个单一对象里面。结果是一个你可以像互斥锁那样使用的对象,然后等待特定条件。
    以下显示了一个代码片段,它展示了为等待一个 NSCondition 对象的事件序列。cocaoCondition 变量包含了一个 NSCondition 对象,而 timeToDoWork 变量是一个整形,它在其他线程里面发送条件信号时立即递增。
[cocoaCondition lock];
while (timeToDoWork <= 0)
[cocoaCondition wait];
timeToDoWork--;
// Do real work here.
[cocoaCondition unlock];

以下代码显示了用于给 Cocoa 条件发送信号的代码,并递增他断言变量。你应该在给它发送信号前锁住条件。

[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()
{
//锁住互斥锁
pthread_mutex_lock(&mutex);
//如果断言已经被设置,那么就绕过 while 循环,如果没有,线程会一直休眠知道断言被设置。
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()
{
//在这里,应该有其他线程的工作要做。
pthread_mutex_lock(&mutex);
ready_to_go = true;
//通知其他线程开始工作
pthread_cond_signal(&condition);
pthread_mutex_unlock(&mutex);
}

注意:上述代码是显示使用 POSIX 线程条件函数的简单例子。你自己的代码应该检测这些函数返回错误码并恰当的处理它们。


八、总结

  • Cocoa
    在 Cocoa 上面使用多线程的指南包括以下这些:
    • 不可改变的对象一般是线程安全的。一旦你创建了它们,你可以把这些对象在线
      程间安全的传递。另一方面,可变对象通常不是线程安全的。为了在多线程应用里面使用可变对象,应用必须适当的同步。关于更多信息,参阅”可变和不可变对比”。
    • 许多对象在多线程里面不安全的使用被视为是”线程不安全的”。只要同一时间只有一个线程,那么许多这些对象可以被多个线程使用。这种被称为专门限制应用程序的主线程的对象通常被这样调用。
    • 应用的主线程负责处理事件。尽管 Application Kit 在其他线程被包含在事件路径里面时还会继续工作,但操作可能会被打乱顺序。
    • 如果你想使用一个线程来绘画一个视图,把所有绘画的代码放在 NSView 的lockFocusIfCanDraw 和 unlockFocus 方法中间。为了在 Cocoa 里面使用 POSIX 线程,你必须首先把 Cocoa 变为多线程模式。关于
      更多信息,参阅“在 Cocoa 应用里面使用 POSIX 线程”部分。
      基础框架(Fondation Framework)的线程安全
      有一种误解,认为基础框架(Foundation framework)是线程安全的,而Application Kit 是非线程安全的。不幸的是,这是一个总的概括,从而造成一点误导。每个框架都包含了线程安全部分和非线程安全部分。以下部分介绍 Foundationframework 里面的线程安全部分。
    • 线程安全的类和函数
      下面这些类和函数通常被认为是线程安全的。你可以在多个线程里面使用它们的同一个实例,而无需获取一个锁。


      1.png

      2.png
  • 非线程安全类
    以下这些类和函数通常被认为是非线程安全的。在大部分情况下,你可以在任何线程里面使用这些类,只要你在同一个时间只在一个线程里面使用它们。参考这些类对于的额外详细信息的文档。


    3.png

    4.png

    注意,尽管 NSSerializer,NSArchiver,NSCoder 和 NSEnumerator 对象本身是线程安全的,但是它们被放置这这里是因为当它们封装的对象被使用的时候,更改这些对象数据是不安全的。比如,在归档情况下,修改被归档的对象是不安全的。对于一个枚举,任何线程修改枚举的集合都是不安全的。
    多线程编程指南

    • 只能用于主线程的类
      以下的类必须只能在应用的主线程类使用。
      • NSAppleScript
    • 可变 vs 不可变
      不可变对象通常是线程安全的。一旦你创建了它们,你可以把它们安全的在线程间传递。当前,在使用不可变对象时,你还应该记得正确使用引用计数。如果不适当的释放了一个你没有引用的对象,你在随后有可能造成一个异常。
      可变对象通常是非线程安全的。为了在多线程应用里面使用可变对象,应用应该使用锁来同步访问它们(关于更多信息,参见“原子操作”部分)。通常情况下,集合类(比如,NSMutableArray,NSMutableDictionary) 是考虑多变时是非线程安全的。这意味着,如果一个或多个线程同时改变一个数组,将会发生问题。你应该在线程读取和写入它们的地方使用锁包围着。
      即使一个方法要求返回一个不可变对象,你不应该简单的假设返回的对象就是不可变的。依赖于方法的实现,返回的对象有可能是可变的或着不可变的。比如,一个返回类型是 NSString 的方法有可能实际上由于它的实现返回了一个NSMutableString。如果你想要确保对象是不可变的,你应该使用不可变的拷贝。
    • 可重入性
      可重入性是可以让同一对象或者不同对象上一个操作“调用”其他操作成为可能。保持和释放对象就是一个有可能被忽视的”调用”的例子。
      以下列表列出了 Foundation framework 的部分显式的可重入对象。所有其他类可能是或可能不是可重入的,或者它们将来有可能是可重入的。对于可重入性的一个完整的分析是不可能完成的,而且该列表将会是无穷尽的。


      5.png
    • 类的初始化
      Objective-C 的运行时系统在类收到其他任何消息之前给它发送一个 initialize消息。这可以让类有机会在它被使用前设置它的运行时环境。在一个多线程应用里面,运行时保证仅有一个线程(该线程恰好发送第一条消息给类)执行 initialized 方法,第二个线程阻塞直到第一个线程的 initialize 方法执行完成。在此期间,第一个线程可以继续调用其他类上的方法。该 initialize 方法不应该依赖于第二个线程对这个类的调用。如果不是这样的话,两个线程将会造成死锁。
    • 自动释放池(Autorelease Pools)
      每个线程都维护它自己的 NSAutoreleasePool 的栈对象。Cocoa 希望在每个当前线程的栈里面有一个可用的自动释放池。如果一个自动释放池不可用,对象将不会给释放,从而造成内存泄露。对于 Application Kit 的主线程通常它会自动创建并消耗一个自动释放池,但是辅助线程(和其他只有 Foundationd 的程序)在使用 Cocoa前必须自己手工创建。如果你的线程是长时间运行的,那么有可能潜在产生很多自动释放的对象,你应该周期性的销毁它们并创建自动释放池(就像 Application Kit 对主线程那样)。否则,自动释放对象将会积累并造成内存大量占用。如果你的脱离线程没有使用 Cocoa,你不需要创建一个自动释放池。
    • Run Loops
      每个线程都有一个或多个 run loop。然而每个 run loop 和每个线程都有它自己的输入模式来决定 run loop 运行的释放监听那些输入源。输入模式定义在一个 runloop 上面,不会影响定义在其他 run loop 的输入模式,即使它们的名字相同。
      如果你的线程是基于 Application Kit 的话,主线程的 run loop 会自动运行,但是辅助线程(和只有 Foundation 的应用)必须自己启动它们的 run loop。如果一个脱离线程没有进入 run loop,那么线程在完成它们的方法执行后会立即退出。
      尽管外表显式可能是线程安全的,但是 NSRunLoop 类是非线程安全的。你只能在拥有它们的线程里面调用它实例的方法。
  • Application Kit 框架的线程安全
    • 非线程安全类
      以下这些类和函数通常是非线程安全的。大部分情况下,你可以在任何线程使用这些类,只要你在同一时间只有一个线程使用它们。查看这些类的文档来获得更多的详细信息。
      • NSGraphicsContext。多信息,参见“NSGraphicsContext 限制”。
      • NSImage.更多信息,参见“NSImage 限制”。
      • NSResponder。
      • NSWindow 和所有它的子类。更多信息,参见“Window 限制
        只能用于主线程的类
        以下的类必须只能在应用的主线程使用。
      • NSCell 和所有它的子类。
      • NSView 和所有它的子类。更多信息,参见“NSView 限制”。
    • Window 限制
      你可以在辅助线程创建一个 window。Application Kit 确保和 window 相关的数据结构在主线程释放来避免产生条件。在同时包含大量 windows 的应用中,window对象有可能会发生泄漏。
      你也可以在辅助线程创建 modal window。在主线程运行 modal loop 时,Application Kit 阻塞辅助线程的调用。
    • 事件处理例程限制
      应用的主线程负责处理事件。主线程阻塞在 NSApplication 的 run 方法,通常该方法被包含在 main 函数里面。在 Application Kit 继续工作时,如果其他线程被包含在事件路径,那么操作有可能打乱顺序。比如,如果两个不同的线程负责关键事件,那么关键事件有可能不是按照顺序到达。通过让主线程来处理事件,事件可以被分配到辅助线程由它们处理。
      你可以在辅助线程里面使用 NSApplication 的 postEvent:atStart 方法传递一个事件给主线程的事件队列。然而,顺序不能保证和用户输入的事件顺序相同。应用的主线程仍然辅助处理事件队列的事件。
    • 绘画限制
      Application Kit 在使用它的绘画函数和类时通常是线程安全的,包括NSBezierPath 和 NSString 类。关于使用这些类的详细信息,在以下各部分介绍。关于绘画的额外信息和线程可以查看 Cocoa Drawing Guide。
      • a) NSView 限制
        NSView 通常是线程安全的,包含几个异常。你应该仅在应用的主线程里面执行对NSView 的创建、销毁、调整大小、移动和其他操作。在其他辅助线程里面只要你把绘画的代码放在 lockFocusIfCanDraw 和 unlockFocus 方法之间也是线程安全的。
        如果应用的辅助线程想要告知主线程重绘视图,一定不能在辅助线程直接调用display,setNeedsDisplay:,setNeedsDisplayInRect:,或 setViewsNeedDisplay:方法。相反,你应该给给主线程发生一个消息让它调用这些方法,或者使用performSelectorOnMainThread:withObject:waitUntilDone:方法。
        系统视图的图形状态(gstates)是基于每个线程不同的。使用图形状态可以在单线程的应用里面获得更好的绘画性能,但是现在已经不是这样了。不正确使用图形状多
        态可能导致主线程的绘画代码更低效。
      • b) NSGraphicsContext 限制 NSGraphicsContext 类代表了绘画上下文,它由底层绘画系统提供。每个NSGraphicsContext 实例都拥有它独立的绘画状态:坐标系统、裁剪、当前字体等。该类的实例在主线程自动创建自己的 NSWindow 实例。如果你在任何辅助线程执行绘画操作,需要特定为该线程创建一个新的 NSGraphicsContext 实例。
        如果你在任何辅助线程执行绘画,你必须手工的刷新绘画调用。Cocoa 不会自动更新辅助线程绘画的内容,所以你当你完成绘画后需要调用 NSGraphicsContext 的flusGrahics 方法。如果你的应用程序只在主线程绘画,你不需要刷新绘画调用。
      • c) NSImage 限制线程可以创建 NSImage 对象,把它绘画到图片缓冲区,还可以把它传递给主线程来绘画。底层的图片缓存被所有线程共享。关于图片和如何缓存的更多信息,参阅Ccocoa Drawing Guide。
  • Core Foundation(核心框架)
    Core Foundation 是足够线程安全的,如果你的程序注意一下的话,应该不会遇到任何线程竞争的问题。通常情况下是线程安全的,比如当你查询(query)、引用(retain)、释放(release)和传递(pass)不可变对象时。甚至在多个线程查询中央共享对象也是线程安全的。
    像 Cocoa 那样,当涉及对象或它们内容突变时,Core Foundation 是非线程安全的。比如,正如你所期望的,无论修改一个可变数据或可变数组对象,还是修改一个可变数组里面的对象都是非线程安全的。其中一个原因是性能,这是在这种情况下的关键。此外,在该级别上实现完全线程安全是几乎不可能的。例如,你不能排除从集合中引用(retain)一个对象产生的无法确定的结果。该集合本身在被调用来引用(retain)它所包含的对象之前有可能已经被释放了。这些情况下,当你的对象被多个线程访问或修改,你的代码应该在相应的地方使
    用锁来保护它们不要被同时访问。例如,枚举 Core Foundation 数组对象的代码,在枚举块代码周围应该使用合适的锁来保护它免遭其他线程修改。

推荐阅读更多精彩内容