iOS 并发编程之 Thread

在 iOS 并发编程之 Operation 中,我们说过 GCD 是将线程管理的代码从应用层移到了系统层,它是基于线程技术的。虽然在 iOS 和 OS X 平台不鼓励直接使用线程实现并发,但它还是有它的应用场景。那么什么时候应该使用线程呢?

当我们的代码需要实时运行时用线程实现是一个好方法。虽然分发队列会尽可能快的执行你的任务,但是它们不能解决实时约束。如果你想让运行在后台的代码给你更可预期的行为,线程是一个更好的选择。

既然线程仍然有它的应用场景,我们还是有必要掌握它。

Thread是什么

线程是一种在应用中实现多条运行路径相对轻量的方法。在系统层,程序并排运行,系统根据程序的需要为每个程序分配执行时间。但是在每个程序内部存在一个或多个线程运行,它们可以被用来以同时或接近同时的方式执行不同的任务。系统本身实际上管理这些线程的运行,调度它们运行在可用的核上,根据需要中断它们以允许其他线程运行。

从技术角度看, 线程是管理代码执行所需的内核级和应用程序级数据结构的组合。内核级结构将事件分派到线程, 并在一个可用内核上协调线程的抢先调度。应用程序级结构包括用于存储函数调用的调用堆栈和需要管理、操作线程的属性和状态的应用程序结构。

在非并发应用中只有一个运行线程。它随应用的主程序开始和结束,一个一个地调用不同的方法或函数实现应用的行为。与此相反,支持并发的应用以一个线程开始,根据需要添加创建附加执行路径。每个新路径有它自定义的开始程序,它和应用的主程序是独立运行的。拥有多线程的应用提供两个重要的潜在优势:

  • 多线程可以改善应用的响应性
  • 多线程在多核系统上可以改善应用的实时性

如果你的应用只有一个线程,那么它需要做所有的事情。它必须响应事件,更新你应用的窗口和执行实现你应用行为需要的所有计算。单线程的问题在于它一次只能做一件事。当你的某个计算需要花费很长时间完成会发生什么呢?当你的代码忙于计算它需要的值,你的应用停止响应用户的事件和更新窗口。如果这种行为持续足够长的时间,用户也许认为你的应用已经挂起,于是尝试强制退出。但是,如果将自定义计算移到单独的线程上,则应用程序的主线程可以自由地响应用户交互。

在多核计算机如此普遍的今天,线程为某些类型的应用提供了一种提高性能的方法。线程可以在处理器不同的核上同时执行不同的任务,让应用在指定时间增加它工作数量成为了一种可能。

当然,线程并不是解决应用性能问题的万用药。提供好处的同时它也带来各种潜在问题。在应用内拥有多个可执行路径会增加你代码的复杂度。每个线程需要协调它和其他线程的行为以防止损坏应用的状态信息。因为单个应用的线程共享相同的内存空间,它们访问所有相同的数据结构。如果两个线程同时尝试操作相同的数据,一个线程可能覆盖另一个线程的修改,最终导致数据被损坏。即使在这里有了正确防护,你还是要当心编译器优化会在你的代码中引入隐蔽的问题。

iOS中如何使用Thread

在 iOS 中使用线程和其他平台一样, 需要做线程管理,线程同步,线程间通信这些工作,但是在 iOS 平台,Apple 还带来了它特有的技术 Run Loop。接下来的内容会一一介绍它们。

线程管理

线程管理主要是创建线程,设置线程相关的属性,配置线程的事件响应程序和终止线程。

创建线程

iOS中的Thread技术主要有两种:Cocoa 线程 和 POSIX 线程。

  • Cocoa 线程

    Cocoa 线程是用 NSThread 实现的,并且 NSObject 提供了生成新线程以及在已经运行的线程上执行代码的方法。所以使用 Cocoa 线程有四种方法:

    • 使用 detachNewThreadSelector:toTarget:withObject: 类方法生成新线程。
    • 创建一个新 NSThread 对象,调用它的 start 方法。(仅支持 iOS 和 OS X v10.5 及以上)
    • 使用 NSObject 的 performSelectorInBackground:withObject: 方法生成新线程并用指定的方法作为新线程的入口。(仅支持 iOS 和 OS X v10.5 及以上)
    • 使用 NSObject 的 performSelector:onThread:withObject:waitUntilDone: 几个类似方法在指定线程上执行代码。
  • POSIX 线程

    POSIX 线程提供基于 C 接口来创建线程。如果你不是在编写 Cocoa 应用程序,这是你创建线程最好的选择。POSIX 接口使用相对比较简单并且为你配置线程提供了很大的灵活性。

    创建 POSIX 线程的方法名为 pthread_create,下面是个示例:

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
29
30
31
32
33
34
35
#include <assert.h>

#include <pthread.h>

void* PosixThreadMainRoutine(void* data)
{
    // Do some work here.
    
    return NULL;
}

void LaunchThread()
{
    // Create the thread using POSIX routines.
    pthread_attr_t  attr;

    pthread_t       posixThreadID;

    int             returnVal;

    returnVal = pthread_attr_init(&attr);
    assert(!returnVal);

    returnVal = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    assert(!returnVal);

    int     threadError = pthread_create(&posixThreadID, &attr, &PosixThreadMainRoutine, NULL);

    returnVal = pthread_attr_destroy(&attr);
    assert(!returnVal);

    if (threadError != 0) {
         // Report an error.
    }
}

设置线程相关的属性

线程的配置项有:

  • 线程的栈大小
  • Thread-Local 存储
  • 线程的 Detached 状态
  • 线程优先级

设置线程的栈大小

对于每个你创建的新线程,系统在你进程空间分配指定大小的内存充当它的栈。栈管理栈帧也是声明局部变量的地方。如果你想改变指定线程栈的大小,你需要在创建它之前就改变。所有的线程技术都提供一些设置栈大小的方法,虽然使用 NSThread 设置大小只在 iOS 和 OS X v10.5 及以上可用。下表列出了每种技术不同的方法。

技术 方法
Cocoa 在 iOS 和 OS X v10.5 及以上,分配和初始化一个 NSThread 对象(不要使用 detachNewThreadSelector:toTarget:withObject: 方法)。在调用 start 方法之前,使用 setStackSize:方法来指定新的栈大小。
POSIX 创建新的 pthread_attr_t 并使用 pthread_attr_setstacksize 来改变默认的栈大小。当创建线程时传递该属性给 pthread_create 函数。

配置 Thread-Local 存储

每个线程维护了一个在线程任何地方都可以存取的键-值对字典。你可以使用这个字典存储你想在你线程整个运行期间持久化的信息。例如,你可以使用它存储那些你想在你线程运行循环多次遍历中保持的状态信息。

Cocoa 和 POSIX 用不同的方法存储线程字典,所以你不能混用这两种技术。只要你在线程代码中坚持使用相同的技术,最终的结果是相似的。在 Cocoa 中,你使用 NSThread 对象的 threadDictionary 方法来获取 NSMutableDictionary 对象,你可以使用它来添加你线程需要的键。在 POSIX 中,你使用 pthread_setspecificpthread_getspecific 函数来存取你线程的键值。

设置线程的 Detached 状态

大多数上层线程技术默认创建 detached 的线程。在大多数情况下,detached 线程是更好的,因为他们允许系统在线程完成时立即释放线程的数据结构。Detached 线程不需要显示地与你的程序交互。从线程获取结果的方法是留给你自己决定。相反地,系统不会回收 joinable 线程的资源直到其它线程显示地 join 它, 进程可能会阻塞执行 join 的线程。

你可以把 joinable 线程想像成子线程。虽然它们仍然是独立运行的线程,一个 joinable 线程在系统回收资源前必须被另一个线程 join。在退出前,一个 joinable 线程可以传递一个数据指针或其它返回值给 pthread_exit 函数。另一个线程然后可能通过调用 pthread_join 函数得到这个数据。

如果你确实想创建 joinable 线程,唯一的方法是使用 POSIX 线程。POSIX 默认创建的线程就是 joinable 。为了标记线程是 detached 或者 joinable ,在创建线程前使用 pthread_attr_setdetachstate 函数修改线程的属性。

设置线程的优先级

任何你新创建的线程都有一个默认的优先级。内核的调度算法在决定哪个线程运行时会把线程的优先级考虑进来,优先级高的比优先级低的更可能运行。优先级并不会保证执行指定时长,仅仅是和低优先级比起来更可能被调度器选中。

如果你想修改线程的优先级,Cocoa 和 POSIX 都提供了方法。对于 Cocoa 线程,你可以使用 NSThread 的类方法 setThreadPriority: 来改变当前运行线程的优先级。对于 POSIX 线程,你使用 pthread_setschedparam 函数。

配置线程的事件响应程序

OS X 上你线程入口程序的结构大部分和其他平台是一样的。你初始化你的数据结构,做一些工作或者选择性的设置运行循环,完成时做好清理。根据你的设计,这里可能有些额外的步骤。

创建自动释放池

因为最上层的自动释放池在线程退出时才释放它持有的对象,长驻线程应用可以创建额外的自动释放池来更加频繁地释放对象。例如,一个使用运行循环的线程也许在每个循环创建和释放一个自动释放池。更加频繁地释放对象防止你应用的内存增长得太大,那会导致性能问题。和任何性能相关的行为一样,你应该测试你代码的实际性能然后合适地调节你自动释放池的使用。

设置异常处理程序

如果你的应用程序捕获和处理异常,你的线程代码应该准备捕获所有可能发生的异常。虽然最好处理异常的地方是它发生的地方,但是漏掉一个异常会导致应用程序退出。在你线程的入口程序中安装一个最终的 try/catch 允许你捕获任何未知的异常和提供合适的响应。

设置运行循环

当你编写想要在单独线程上运行的代码,你有两个选项。第一个选项是为一个很少或不被打断的长时间任务线程编写代码。第二个选项是把你的代码放到一个循环中并且让它处理动态到达的请求。第一个选项你的代码不需要特别的设置;你只需要开始做你想做的工作。但是第二个选项牵涉到设置你线程的运行循环。

OS X 和 iOS 为每个线程实现运行循环提供内建支持。应用程序框架自动开始你应用主线程的运行循环。如果你创建任何其他线程,你必须配置运行循环并手动运行它。

终止线程

退出线程推荐的方法是让它正常地退出入口程序。虽然 Cocoa, POSIX 提供程序直接杀掉线程,使用这些程序是强烈不推荐的。杀掉线程会妨碍线程清理它自己。线程分配的内存可能会潜在泄露,并且正常使用的资源可能不会被正确的回收,导致潜在的问题。

如果你需要在中途终止线程,你应该设计你的线程可以响应取消和退出消息。对于长时间运行的操作,这可能是周期性的停止工作然后检查有没有这种消息。如果收到请求退出的消息,线程拥有机会执行任务清理工作并优雅地退出;如果没有,它可以返回继续工作并处理后续的数据块。

一种响应取消消息的方法是使用运行循环输入源来接收这样的消息。

Run Loop

剖析 Run Loop

运行循环是与线程关联的基本基础结构的一部分。运行循环是一个事件处理循环,用于调度工作并协调传入事件的接收。运行循环的目的是在有工作要做时保持线程忙碌,并在没有工作时将其置于休眠状态。

运行循环非常像它的名字。它是你线程进入的一个循环,并且用来运行事件响应程序来响应进来的事件。你代码中的控制语句用来实现真正的循环部分,换句话说,你的代码提供的 while 或者 for 循环驱动运行循环。在你的循环里面,你使用一个运行循环对象来运行事件处理代码,它接收事件并且调用安装的响应程序。

运行循环接收两种不同类型的事件源。Input sources 传递异步事件,经常是来自其他线程或应用的消息。 Timer source 传递同步的事件,发生在调度的时间或者间隔重复。两种类型的源使用应用指定的响应程序来处理到来的事件。

下图展示了运行循环的概念结构和各种源。Input sources 传递异步的事件给相应的响应程序并导致 runUntilDate 方法退出。 Timer sources 传递事件到他们的响应程序但是不会导致运行循环退出。

除了处理输入源,运行循环也产生关于它行为的通知。注册 run-loop observers 可以接收这些通知并在线程中使用它们做额外的处理。你使用 Core Foundation 来安装 run-loop observers 到你的线程。

Run Loop Modes

Run Loop mode 是一个需要监视的 input sources 和 timers 集合和需要通知的 run loop observers 的集合。每次你运行你的运行循环,你指定(显式或隐式)一个特定的模式。当事件经过运行循环,只有和该模式关联的源会被监视并允许传递它们的事件。(类似地,只有和该模式关联的观察者会被通知。)和其他模式关联的源会保留到新的事件直到通过合适模式的循环。

在你的代码中,你用名字标识模式。Cocoa 和 Core Foundation 都定义了默认的模式和几个常用的模式以及在你代码中用来指定这些模式的字符串。你可以通过指定自定义字符串来定义自定义模式。虽然自定义模式的名字可以是任意的,但是它们的内容却不可以。为了他们有用,你必须添加一个或多个 input sources, timers 或者 run-loop observers 到你创建的任意模式。

你使用模式在源通过运行循环时过滤掉不想要的源。大多数时候,你会想要在系统定义的默认模式下运行你的运行循环。但是,一个模态面板可能运行在模态模式。当在这个模式的时候,只有和模态面板相关的源会传递事件到线程。对于次要线程,你可能想使用自定义模式防止在时间关键的操作过程中传递低优先级源事件。

总结一下,运行循环是与线程关联的基本基础结构的一部分,它是一个事件处理循环。接收的事件可以分为异步和同步两大类。Input sources 传递异步事件;Timer source 传递同步事件。我们是通过指定 run loop mode 来指定想要监视的源,Cocoa 和 Core Foundation 定义了默认的模式和几个常用的模式,我们也可以自定义模式。除了接收处理事件,它还可以生成与自身进度相关的消息通知观察者。

那么 Input sources 具体有哪些源呢?它由三部分组成:1.基于端口的源;2.自定义的源;3.Cocoa Perform Selector 源;

Cocoa 和 Core Foundation 为使用端口相关的对象和函数创建一个基于端口的输入源提供内建的支持。例如,在 Cocoa 中,你绝不需要直接创建一个输入源。你简单的创建一个端口对象并使用 NSPort 的方法把端口添加到运行循环。端口对象为你创建和配置需要的输入源。在 Core Foundation 中,你必须手动创建端口和它的运行循环源。在两种情况中,你使用端口不透明类型(CFMachPortRef, CFMessagePortRef or CFSocketRef)关联的函数来创建合适的对象。

何时需要使用运行循环

你唯一需要隐式执行运行循环的地方是当你为你的应用创建线程的时候。你应用主线程的运行循环是基础设施中重要的一块。因此,应用框架为运行主应用循环提供了代码并自动启动了循环。在 iOS 中 UIApplication(NSApplication 在 OS X)的 run 方法启动了应用的主循环作为正常启动的一部分。如果你使用 Xcode 模板工程来创建你的应用,你应该绝不需要显式调用这些程序。

对于子线程,你决定是否需要运行循环,如果需要,你自己配置和启动它。例如,如果你使用线程执行某些长时间运行并预先确定的任务,你可以避免启动运行循环。运行循环适用于你想要和线程有更多交互的场景。例如,如果你打算做任何下列事情时需要启动运行循环:

  • 使用端口或自定义输入源来和其他线程通信
  • 在线程上使用定时器
  • 在 Cocoa 应用程序中使用任何 performSelector…方法
  • 使用线程执行周期性的任务

如果你选择使用运行循环,配置和设置非常直接了当。就像所有其他的线程编程,你应该有一个计划让子线程在合适的时机退出。干净地退出来结束一个线程总是好过强制终止它。

使用运行循环对象

运行循环提供了主接口去添加 input sources, timers 和 run-loop observers 到你的运行循环并运行它。每个线程有一个关联它的单一的运行循环对象。在 Cocoa 中,这个对象是 NSRunLoop 类的实例。在一个抽象度低的应用中,这是一个 CFRunLoopRef 不透明类型指针。

得到一个运行循环对象

为了从当前线程得到运行循环,你可以使用以下方法之一:

  • 在 Cocoa 应用程序中,使用 NSRunLoop 的类方法 currentRunLoop 获取一个 NSRunLoop 对象
  • 使用 CFRunLoopGetCurrent 函数

虽然它们并不是自由桥接的类型,当你需要时你可以从 NSRunLoop 对象得到一个 CFRunLoopRef 不透明类型。因为两个对象是指向相同的运行循环,你可以根据需要混合 NSRunLoop 对象和 CFRunLoopRef 不透明类型的调用。

配置运行循环

在子线程上运行运行循环之前,你必须为它至少添加一个输入源或定时器。如果运行循环没有任何要监视的源,当你尝试运行它时它马上就退出了。

除了安装源,你也可以安装运行循环观察者来监测运行循环不同的执行阶段。为了安装运行循环观察者,你创建一个 CFRunLoopObserverRef 不透明类型,使用 CFRunLoopAddObserver 函数添加它到你的运行循环。运行循环观察者必须使用 Core Foundation 创建,即使是 Cocoa 应用程序。

启动运行循环

有很多方法启动运行循环,展示如下:

  • 无条件的
  • 有时间限制的
  • 在特定模式的

无条件进入运行循环是最简单的选项,但是它也是最不可取的。无条件运行运行循环将你的线程置于永久的循环中,这使你对运行循环本身有很少的控制。你可以添加和移除输入源和定时器,但是你只能通过杀死它来停止它。也没办法让它运行在自定义模式下。与其无条件的运行一个运行循环,使用一个超时值来运行运行循环更好。当你使用超时值时,运行循环会一直运行直到事件到来或者分配的时间过期。如果一个事件到达,它会被分发到响应程序处理然后退出运行循环;如果分配的时候过期了,你可以简单地重启运行循环或者用这个时间去做任何清扫工作。

除了超时值,你可以用一个特定的模式来运行你的运行循环。模式和超时值不是互斥的,它们可以一起用来启动运行循环。

退出运行循环

在处理事件前有两种方法退出运行循环:

  • 用超时值配置运行循环
  • 告诉运行循环停止

如果你能管理它,使用超时值肯定是推荐的。指定一个超时值在退出前完成所有的处理,包括传递通知到运行循环观察者。

用 CFRunLoopStop 函数显式地停止运行循环产生和超时相似的结果。它发出任何遗留的运行循环通知然后退出。不同的地方在于你不可以在无条件启动的运行循环中使用这种技术。

虽然从运行循环中移除输入源和定时器也会导致运行循环退出,但是这不是停止运行循环可靠的方法。某些系统程序添加输入源到运行循环来处理需要的事件。因为你的代码可能意识不到这些输入源,它可能不能移除他们,这会阻止运行循环退出。

线程安全和运行循环对象

线程安全随你使用哪个 API 来操纵运行循环而改变。 Core Foundation 的函数通常是线程安全的可以从任何线程调用。任何可能的时候从拥有运行循环的线程来执行那些改变它配置的操作都是好的实践。

Cocoa NSRunLoop 类没有继承它 Core Foundation 对应部分。如果你使用 NSRunLoop 类来修改你的运行循环,你应该只从拥有它的线程中去做。添加一个属于不同线程的输入源或定时器可能导致崩溃或行为异常。

配置运行循环源

运行循环用模式来标识自己监视的循环源,配置循环循环源要做的工作包括创建循环源,添加循环源到运行循环,移除循环。前面提到运行循环源有两类:1. Input sources;2. Timer Sources; Input sources 是异步事件源,它主要包括 Port-base sources, 我们也可以自定义异步事件源。应用程序框架做了很多封装工作,我们使用系统内建的端口源只需要使用 NSPort 及其子类对象就好,并不需要自己去创建相应事件源,NSPort 会为我们做这些事情。

Timer sources 是运行循环的同步事件源,在 Cocoa 中是使用 NSTimer 的类方法 scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:scheduledTimerWithTimeInterval:invocation:repeats: 或者创建一个定时器对象并把它添加到运行循环指定的模式来配置;在 Core Foundation 中是使用一个 CFRunLoopTimerRef 不透明类型实例添加到运行循环来配置;

除了系统内建的源,我们也可以自定义运行循环源,虽然我们可能很少这么做,但是通过自定义运行循环源,我们可以更深入的理解它。使用自定义运行循环源来让子线程为主线程工作是学习自定义循环源一个很好的实例。因为要和子线程通信交互,所以子线程的代码使用运行循环是个很好的选择。

创建一个运行循环源是通过使用 CFRunLoopSourceRef CFRunLoopSourceCreate(CFAllocatorRef allocator, CFIndex order, CFRunLoopSourceContext *context); 函数,CFRunLoopSourceContext 这个结构体指定了运行循环调度、执行事件处理和取消时对应的函数。为了让主线程能操纵子线程,主线程会持有子线程事件源,运行循环的引用,事件源引用用来发送事件信号,运行循环引用可以唤醒循环,它们之间可以通过共享对象来进行数据传递。

自定义 Run-loop source 示例

线程间通信

虽然一个好的设计最小化了通信的需求,在某些时候,线程间通信是需要的。(一个线程的职责是为你的应用程序工作,如果它工作的结果从来没被使用,它有什么好处呢?)线程也许需要处理新的工作请求或者向你应用的主线程报告它们的进度。在这些情况,你需要一种从一个线程到另一个线程获取信息的方法。很幸运,线程共享相同的进程空间的事实意味着你有很多选项来通信。

线程间通信有很多方法,每个都有自己的优缺点。配置 Thread-Local 存储是 OS X 中最常用的通信机制。下面的通信技术按复杂度递增。

直接消息

Cocoa 应用程序支持在其他线程上执行方法的能力。这种能力意味着一个线程可以任意地执行另一个线程的方法。因为它们在目标线程的上下文中运行,这种方式发送的消息在目标线程是串行的。

全局变量,共享内存和对象

另一种在两个线程间通信的简单方法是使用一个全局变量,共享对象或者共享一块内存。虽然共享变量很快并且简单,它们比直接消息更加脆弱。共享变量必须用锁或者其他同步机制小心地保护来保证你代码的正确性。如果不这么做可能导致竞争条件,数据损坏或崩溃。

条件

条件是一个你用来控制一个线程何时执行某部分代码的同步工具。你可以把它想像成门卫,仅仅当条件满足时才让线程运行。

Run loop sources

一个自定义的 run loop source 是你设置在线程上接收应用指定消息的装置。因为它们是事件驱动,当没什么事要做时 run loop source 让你的线程自动去休眠,这样可以提高你线程的效率。

端口和 sockets

基于端口的通信是一种更精密的方法,但是它也是非常可靠的技术。更重要的是,端口和 sockets 可以用来和外部的实体通信,例如其他的进程和服务。为了提高效率,端口是用 run loop sources 实现的,所以当端口没有数据等待时你的线程是休眼的。

消息队列

传统的多进程服务定义一个先进先出的队列抽象用来管理数据的进出。虽然消息队列简单方便,它们并不如其他通信技术高效。

Cocoa distributed objects

Distributed objects 是一个 Cocoa 技术,它提供了一个基于端口通信的高层实现。虽然可以使用它来进行线程间通信,因为它经常性开销的数量,这么做是非常不推荐的。Distributed objects 更加适合用来和其他进程通信,这里进程间的开销本来已经很高了。

线程同步

应用程序中存在多个线程会打开有关从多个执行线程安全访问资源的潜在问题。修改同一资源的两个线程可能会以非预期的方式相互干扰。例如,一个线程可能会覆盖他人的更改,或者将应用程序置于未知且可能无效的状态。如果你幸运,损坏的资源可能会导致明显的性能问题或崩溃,它们相对容易跟踪和修复。但是,如果你不是那么幸运,损坏可能导致微妙的错误,直到很晚才显示自己,或者错误可能需要对您的基础编码假设进行重大检修。

当谈到线程安全,好的设计是你拥有的最好保护。避免共享资源和减少你线程间的交互可以降低线程相互影响的可能性。但是,不是总会有完全不相互影响的设计。在那些线程必须交互的场合,你需要使用同步工具来确保它们能安全交互。

OS X 和 iOS 为你提供了大量的同步工具,从互斥访问到正确的事件序列。下面的内容描述了这些工具并介绍如何使用它们来保证安全访问你程序的资源。

同步工具

  • 原子操作
  • 内存屏障和 Volatile 变量
  • Conditions
  • Perform Selector Routines

原子操作

原子操作是一种用于简单数据类型简单形式地同步。原子操作的优点是他们不会阻塞竞争线程。对于简单的操作,像增加计数变量,可以比使用锁带来很大性能提升。

OS X 和 iOS 包含大量操作可以对 32 位和 64 位值进行简单的算术和逻辑操作。这些操作包含 compare-and-swap, test-and-set, 和 test-and-clear 操作的原子版本。对于支持的原子操作列表,查看 /usr/include/libkern/OSAtomic.h 头文件或查看 atomic man 页面。

内存屏障和 Volatile 变量

为了获取最好的性能,编译器经常重新排序汇编级指令来保证给处理器的指令管道尽量是满载的。作为这些优化的一部分,编译器在它认为不会产生错误数据时可能会重新排序访问主存的指令。不幸的是,编译器不能检测到所有依赖内存的操作。如果变量表面分开实际相互影响,编译器优化可能以错误的顺序更新这些变量,产生潜在错误的结果。

内存屏障是一种非阻塞类型的同步工具,它确保内存操作以正确的顺序发生。它强制处理器完成在它之前的加载和存储操作才允许执行在它之后的加载和存储操作。内存屏障经常用来确保线程内的内存操作是按预期的顺序发生。在这种情况下缺少内存屏障的话可能允许其他线程看到完全不可能的结果。你可以在你代码合适的点调用 OSMemoryBarrier 函数来使用内存屏障。

Volatile 变量应用另一种类型的内存约束到单独的变量上。编译器经常通过加载变量的值到寄存器来优化代码。对于局部变量,这通常不是问题。但是,如果变量对其他线程是可见的,这种做法可能会导致其他线程注意不到它的任何改变。应用 volatile 关键字到变量强制编译器每次使用它时都从内存中加载。你也能把一个变量声明为 volatile 如果它的值任意时刻都可能被外部源所改变而编译器不能检测到。

因为内存屏障和 volatile 变量都减少了编译器可以进行的优化数量,应该节俭地使用它们,只有需要确保正确性时才使用。

锁是最常用的同步工具之一。你可能使用锁来保护你代码的关键部分,它是一段每次只允许一个线程访问的代码段。例如,关键部分可能是操纵一个特定的数据结构或者使用某些一次最多只能支持一个客户端的资源。通过放置一个锁来包围这个部分,你排除其他线程做可能影响你代码正确性的改变。

下面是程序员经常使用的一些锁。OS X 和 iOS 提供了大部分这些锁的实现,但是不是所有,对于不支持的锁,描述中会解释为什么这些锁没有在平台上直接实现。

Mutex

互斥锁充当围绕资源的保护屏障。mutex 是一种类型的量,它保证每次只有一个线程访问。如果一个 mutex 在使用,另一个线程尝试获得它,这个线程会一直阻塞直到原始拥有者释放互斥锁。如果多个线程为同一个互斥锁竞争,一次仅仅只有一个允许访问它。

Recursive lock

递归锁是互斥锁的变种。递归锁允许单个线程在释放前多次获得锁。其他线程保持阻塞直到锁的拥有者释放和获得相同的次数。递归锁主要用于递归遍历期间,但是也可以用于多个方法每个都需要单独获得锁的场合。

Read-write lock

读写锁也称为 shared-exclusive 锁。这种锁经常用于大范围的操作,在数据结构频繁读取偶尔修改时可以显著提高性能。在正常的操作期间,多个读者可以同时访问数据结构。当一个线程想要写入数据结构,它会一直阻塞直到所有的读者释放锁,在那个点获得锁然后可以更新。当写入线程在等待锁,新的读取线程会阻塞直到写入线程完成。系统只支持 POSIX 线程使用读写锁。

Distributed lock

分布锁在进程级提供互斥访问。和真正的互斥锁不同,分布锁不会阻塞进程或阻止它运行。当锁在忙碌时它只是简单地报告,让进程决定如何去处理。

Spin lock

Spin lock 重复轮询它的锁条件直到条件为真。它经常用于多处理器系统,在这里对于锁的期望等待时间很小。在这些情况下,轮询比阻塞线程更高效,因为阻塞线程牵涉到上下文切换和更新线程数据结构。因为它们轮询的本质系统没有提供 spin lock 的实现,但是在特定的情形你可以很容易地实现。

Double-checked lock

Double-checked 锁尝试通过在获得锁之前测试锁条件来减少获得锁的经常性开销。因为 double-checked 锁是潜在不安全的,所以系统没有提供显式的支持,使用它们是不鼓励的。

Conditions

Condition是另种类型的量,当某个条件为真时它允许线程相互通知。条件经常用来暗示资源可用或者确保任务按指定的顺序执行。当一个线程测试一个条件,它会阻塞直到条件已经为真。它保持阻塞直到其他的线程显示的改变并发送条件信号。条件和互斥锁的不同之处是多个线程可能同时允许访问条件。条件更像一个门卫,依据某些条件让线程通过大门。

Perform Selector Routines

Cocoa 应用程序有一个便利方法以同步的方法传递消息到单一线程。NSObject 类为在一个应用的活动线程上执行选择器声明了方法。这些方法让你的线程异步地传递消息并保证它们会在目标线程上同步的执行。例如,你可能使用执行选择器方法传递分布计算的结果到你应用的主线程或一个设计的协调线程。每个执行选择器的请求都在目标线程的运行循环上排队,然后它们会以被接收到顺序串行处理。

同步开销和性能

同步帮助确保你代码的正确性,但是是以花费昂贵性能为代价。使用同步工具带来延时,即使在没有竞争的情况。锁和原子操作通常牵涉到使用内存屏障以及内核级同步来确保代码被合适的保护。而且如果锁有条件,你的线程可能阻塞并经历更加大的延时。

当设计你的并发任务,正确性总是最重要的因素,但是你也应该考虑性能因素。代码在多线程中正确的运行,但是比运行在单线程上相同的代码还要慢,这很难说是改善。

如果你在改造一个现有的单线程应用,你应该总是为关键任务性能设置一个测量基线。当添加额外的线程,你应该对这些相同的任务做测量并和单线程的性能做比较。如果在调整代码之后,线程没有带来性能提升,你可能想重新考虑具体的实现或多线程的使用。

线程安全和信号

当提到线程应用程序,没什么比处理信号让你更害怕或困惑。信号是低层的 BSD 机制,它可以用来传递信息到一个进程或以其他方式操纵。某些程序使用信号来检测特定的事件,例如子进程的死亡。系统使用信号来终止运行的进程或交流其他类型的信息。

信号的问题不是它做了什么,而是当你的应用有多个线程时的行为。在单线程应用中,所有的信号响应程序都跑在主线程上。在多线程应用中,那些不是和指定硬件错误(例如非法指令)绑定的信号会传递到当时运行的线程。如果多个线程同时运行,信号传递到哪个线程是系统随机选择的。换句话说,信号可以被传递到应用的任意线程。

在应用程序中实现信号响应程序的第一原则是不要假设哪个线程会处理信号。如果指定的线程想要处理给定的信号,你需要找出某些方法在信号发生时通知那个线程。

线程安全设计的提示

同步工具是让代码线程安全很有用的方法,但是他们不是万用药。使用太多锁或者其他主要类型的同步工具可能实际上减少了你应用的性能。找到安全和性能的最佳平衡是需要经验的艺术。下面的提示可以帮助你为应用选择合适级别的同步。

总是避免同步

对于你工作的任何新工程,即使是存在的工程,设计你的代码和数据结构来避免同步是最好的解决方案。虽然锁和其他的同步工作都很有用,它们确实影响应用的性能。如果总体设计导致指定资源存在严重竞争,你的线程可能会等待相当长时间。

实现并发最好的方法是减少你并发任务它之间的交互和依赖。如果每个任务操作它自己私有的数据集合就不需要使用锁来保护数据。即使在两个任务需要共享同一个数据集,你可以寻找分割数据的方法或者为每个任务提供它自己的副本。当然,复制数据集也有它的开销,所以在做决定前你需要权衡它和同步的开销。

理解同步的限制

同步工具只有被应用中所有的线程一致使用时才高效。如果你创建了一个互斥锁来限制访问指定的资源,所有你的线程在尝试操纵资源时必须获得相同的互斥锁。不这么做会破坏互斥锁提供的保护,它是一个程序员错误。

当心 Deadlocks 和 Livelocks

线程同时尝试获得一个以上的锁的任何时候都潜在可能发生死锁。当两个不同的线程拥有对方需要的锁并尝试获得对方拥有的锁就会发生死锁。结果就是两个都永远阻塞,因为它们永远不能得到另一个锁。

Livelock 和死锁类似,当两个线程为同一个资源集竞争时会发生。在 livelock 场景下,一个线程放弃第一个锁去尝试获得第二人锁。一旦得到第二个锁,它返回并尝试获得第一个锁。它花费所有的时间释放一个锁并尝试获得另一个锁而不是做任何实际工作。

避免死锁和 livelock 最好的办法是每次只使用一个锁。如果你必须一次使用一个以上的锁,你应该确保其他的线程不要尝试做类似的事情。

正确的使用 Volatile 变量

如果你已经使用互斥锁来保护一部分代码,不要自动假设你需要使用 volatile 变量来保护在这部分中重要变量。互斥锁包含内存屏障来确保加载和存储的合适顺序。为关键部分中的变量添加 volatile 关键字强制每次使用都从内存加载。两种同步技术结合使用在特定场合可能需要,但是也会导致严重的性能损失。如果单独的互斥锁来保护变量就够了,忽略 volatile 关键字。

不要使用 volatile 变量尝试来避免使用互斥锁也很重要。通常互斥锁和其他同步机制用来保护你数据结构的完整比 volatile 变量更好。volatile 关键字仅仅确保变量是从内存加载而非保存在寄存器的值。它不保证变量被你的代码正确地访问。

使用原子操作

非阻塞同步是一种执行某些类型的操作并避免锁带来代价的方法。虽然锁是同步两个线程有效的方法,获得锁是一个相对昂贵的操作,即使是在非竞争条件下。与此相反,许多原子操作花费少量时间完成并且可以和锁一样高效。

原子操作让你对 32 位 或者 64 位值进行简单的算术和逻辑操作。这些操作依赖特殊的硬件指令(和可选的内存屏障)来保证给定的操作会在被影响的内存被访问前完成。在多线程情况下,你应该总是和内存屏障搭配使用来确保内存被正确的同步。

使用锁

POSIX mutex Lock

POSIX 互斥锁在任何应用中使用都很简单。为了创建互斥锁,你声明和初始化一个 pthread_mutex_t 结构。为了锁住和解锁,你使用 pthread_mutex_lock 和 pthread_mutex_unlock 函数。当你用完了锁,简单地调用 pthread_mutex_destory 来释放锁数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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)如果锁没有在指定的时间限制内获得。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 对象作为锁令牌。

1
2
3
4
5
6
7
8
- (void)myMethod:(id)anObj
{
    @synchronized(anObj) {

        // Everything between the braces is protected by the @synchronized directive.

    }
}

传入 @synchronized 关键字的对象是一个用来区分保护块的唯一标识。如果你在两个不同的线程执行上面的方法,在每个线程为 anObj 参数传入不同的对象,它们彼此都会得到锁并继续处理而并不会被另一个阻塞。如果你在两处传入相同的对象,其中一个线程将首先获得锁而另一个会阻塞直到前面的线程完成关键部分。

作为预防措施,@synchronized 块隐式的添加异常响应程序到保护代码上。响应程序在捕获到异常事件时会自动释放互斥锁。这意味着为了使用 @synchronized 关键字,你必须在你代码中使能 Objective-C 异常处理。如果你不想要因为隐式异常响应程序带来的经常性开销,你应该考虑使用锁类。

使用条件

条件是特殊类型的锁,你可以用来同步操作必须被执行的顺序。它和互斥锁有细微的区别。等待条件的线程保持阻塞直到条件被其他线程显示的发送。

因为这些细微的差别牵涉到操作系统的实现,条件锁允许返回带着虚假成功即使它们实际没有被你的代码发送。为了避免被这些虚假信号导致的问题影响,你应该总是使用一个谓词来结合你的条件锁。谓词是一个更加具体的方法来确定你线程去处理是不是安全。条件简单保持你的线程休眼直到谓词可以被信号线程设置。

使用 NSCondition 类

NSCondition 类提供和 POSIX 条件相同的语义,但是在单一的对象里包裹必须的锁和条件数据结构。结果对象你可以像互斥锁一样上锁,也可以像条件一样等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 条件

POSIX 条件锁需要同时使用条件数据结构和互斥锁。虽然两种锁结构是分离的,互斥锁在运行时密切地捆绑到条件结构。

下面的示例展示了条件和谓词的基本使用。在初始化条件和互斥锁,等待线程进入 while 循环使用 ready_to_go 变量作为谓词。只有当谓词被设置并且条件最终被发出才会唤醒等待线程并开始它的工作。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 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);
}

Reference

  • Thread Programming Guide