Threading Programming Guide · About Threaded Programming [翻译]
原文链接
多年来,计算机的最大性能在很大程度上收到位于计算机核心的单个微处理器速度的限制。随着单个处理器的速度濒临实际极限,芯片制造商转向多核设计,使计算机有机会同时处理多个任务。OS X 在执行系统相关任务时利用了这些核心,你的应用同样也可以通过线程利用他们。
什么是线程
线程是应用中实现多路执行的轻量级方法。系统级程序并发执行,根据当前程序和其他程序的需要去分配执行时间。在每个程序内部都存在一个或更多执行线程,这些线程同时或几乎同时被用于执行不同的任务。系统将执行线程调度到可用的核心上运行,并根据需要预先中断它们,以允许其他线程运行。
从技术角度看,线程是管理代码执行所需的内核级与应用级数据结构的组合。内核结构协调线程的事件派发及在可用内核上抢占调度。应用级结构包括了存储函数调用栈,管理和操作线程的状态属性。
在一个非并发应用中,只有一个执行线程。该线程以应用主例程开始和结束,并逐个分支到不同的函数方法来实现应用的总体行为。相反,支持并发的应用从一个线程开始,并根据需要添加更多线程以实现多路执行。每个新路径都有自己的自定义启动例程,它独立于应用程序主例程代码。多线程应用提供非常重要的两个潜在优势:
- 多线程可以提升应用程序的感知和响应能力
- 多线程可以提升应用程序在多核系统中的实时性能
如果应用只有一个线程,这个线程必须做每一件事。它必须响应事件,更新应用窗口,执行应用行为所需的计算。问题在于,一个线程同时只能做一件事。那么一个耗时计算发生时,会发生什么?当代码忙于计算时,应用会停止响应用户事件及更新窗口。如果耗时行为足够长,用户可能会认为你的应用挂起,然后试图强退。如果你将自定义计算转移到独立线程中,应用的主线程则可以更及时地响应用户交互。
随着多核计算机的普及,线程在某类应用中提供了一种提高性能的方式。执行不同任务的线程可以在不同处理器内核上同时执行这些任务,从而使应用能够在给定时间内增加工作量。
当然,线程并不是解决应用性能问题的银弹。线程带来好处的同时也带来了潜在问题。应用的多路执行会给代码增加相当多的复杂性。每个线程必须与其他线程协调,以避免对应用的状态信息造成破坏。因为单应用中的线程共享相同的内存空间,所以他们可以访问所有相同的数据结构。如果两个线程试图在同一时间操作相同的数据,一个线程可能覆写另一线程的更改,最终破坏了该数据的正确结果。即使有了适当保护,你依然要小心编译器优化导致你的代码引入微妙错误。
线程术语
在深入讨论线程机器支持技术之前,有必要定义一些基本术语。
如果你熟悉 UNIX 系统,你可能会发现本文的术语 task
与之有所不同。在 UNIX 系统中,术语 task
有时指正在运行的进程。
本文档采用下列术语:
thread
: 线程,用于表示代码的单独执行路径process
: 进程,指正在运行的可执行文件,它可以包含多个线程task
: 任务,指需要执行的工作的抽象概念
线程的替代品
创建线程的一个问题是给代码增加了不确定性。线程是应用中支持并发的一种相对低级和复杂的方法。如果你没有完全理解设计选项的含义,你可能很容易遭遇同步问题,严重时,微小的行为修改可能熬制应用崩溃及用户数据的损坏。
另一个需要考虑的因素是你是否需要线程并发。线程解决了如何在同个进程中并发执行的问题。但在某些情况下,你所做的工作可能并不需要。线程会给进程带来大量的开销,包括消耗内存和 CPU 时间。你可能发现这个开销对于预期任务来说太大了,或者其他选项更容易实现。
Table 1-1 列出了线程的一些替代方案。此表包括线程的替代技术(如操作对象和 GCD),以及旨在有效调用现有单线程的替代技术
Table 1-1 线程的替代技术
术语 | 描述 |
---|---|
Operation objects | 在 OS X 10.5 中引入,操作对象通常是辅助线程上执行的任务包装器。这个包装隐藏了执行任务的线程管理,使你可以专注于任务本身。Operation 通常和操作队列一起使用,操作队列管理 Operation 在一个或多个线程上执行。更多信息参见 《Concurrency Programming Guide》。 |
Grand Central Dispatch (GCD) | 在 Mac OS X v10.6 中引入,是线程的另一种选择,它允许你专注于需要执行的任务中而非线程管理。使用 GCD,你可以定义要执行的任务,并将其添加到一个工作队列中,该队列在适当的线程中进行任务调度。工作队列考虑可用内核的数量和当前负载,从而比使用线程更有效地执行任务。更多信息参见 《Concurrency Programming Guide》。 |
Idle-time notifications | 对于相对较短且优先级很低的任务,空闲时间通知允许你在应用程序不那么忙的时间执行任务。Cocoa 使用 NSNotificationQueue 对象提供对空闲时间通知的支持。要请求空闲时通知,可使用 NSPostWhenIdle 选项在默认的 NSNotificationQueue 对象中发送通知。队列延迟传递通知对象,直到 RunLoop 变为空闲。更多信息参见 《Notification Programming Topics》。 |
Asynchronous functions | 系统接口包括许多为你提供自动并发性的异步函数。这些 API 可以使用系统后台程序和进程,或创建自定义线程来执行任务并将结果返回。(实际实现无关紧要,因为它和你的代码是分开的)在设计应用时,可寻找提供异步行为的函数,并考虑使用它们,而不是在自定义线程上使用等效的同步函数。 |
Timers | 你可以在应用主线程上使用定时器执行周期性任务,这些任务非常简单,不需要线程,但仍然需要定期进行维护。更多信息参见 《Timer Sources》。 |
Separate processes | 虽然进程比线程更重量级,但在任务与应用无关的情况下,创建独立的进程可能会很有用。如果任务需要大量内存或必须使用根权限执行,则可以使用进程。例如,在 32 位应用像用户显示结果时,可以使用 64 位的服务进程计算大量数据。 |
Warning: 当使用 fork 方法启动独立进程时,必须在 fork 调用后加上 exec 或类似方法。依赖于 CoreFoundation、Cocoa、CoreData 框架必须在后续调用 exec 方法,否则这些框架的行为可能不正确。
线程支持
如果你有使用线程的现有代码,OS X 和 iOS 提供了几种在应用程序中创建线程的技术。此外,这两个系统还支持管理和同步。以下部分描述了 OS X 和 iOS 中使用线程时需要了解的一些关键技术
Threading Packages
尽管线程的底层实现机制是 Mach 线程,但你很少需要在 Mach 级别上使用。而你通常可以使用更方便的 POSIX API 或它的一个衍生品。然而 Mach 实现确实提供了所有线程的基本特性,包括抢占执行模型和调度线程的能力,这样它们就彼此独立了。
Table 1-2 Thread technologies
术语 | 描述 |
---|---|
Cocoa threads | Cocoa 使用 NSThread 类实现线程。Cocoa 还提供了在 NSObject 上的方法用于生成新线程并在已经运行的线程上执行代码。更多信息参见 《Using NSThread》、《Using NSObject to Spawn a Thread》。 |
POSIX threads | POSIX 线程为创建线程提供了一个基于 C 的接口。如果你没有编写 Cocoa 应用,这是创建线程的最佳选择。POSIX 接口使用起来相对简单,并且为配置线程提供了足够的灵活性。更多信息参见 《Using POSIX Threads》。 |
Multiprocessing Services | 多处理服务是一个遗留的基于 C 的接口,用于旧版本 Mac OS 转换的应用。次技术仅在 OS X 中可用,任何新开发的应用都应避免使用它。你应该使用 NSThread 或 POSIX 线程去取代它。更多信息参见 《Multiprocessing Services Programming Guide》。 |
在应用级别,所有线程的行为本质上与其他平台相同。启动县城后,线程以三种主要状态之一运行:running, ready, or blocked。如果线程当前没有运行,它要么阻塞并等待输入,要么已经准备好运行但还未被调度。线程继续在这些状态中来回切换,知道最后退出并切换到终止状态。
创建新线程时,必须为该线程指定入口函数(对于 Cocoa 线程,则为入口方法)。这个入口函数构成了你希望在线程上执行的代码。当函数返回时,或者当你显式地终止线程时,线程将永久停止并由系统回收。由于线程在内存和时间方面的创建成本相对较高,因此建议入口函数执行大量工作,或设置一个 RunLoop 以允许执行重复的工作。
关于可用线程技术及如何使用它们的更多信息,参见 《Thread Management》
Run Loops
RunLoop 是管理线程上异步到达事件的基础设施。RunLoop 通过监视线程一个或多个事件源来工作。事件到达时,系统唤醒线程并派发事件到 RunLoop,然后 RunLoop 将事件分配到你指定的处理程序中。如果准备处理的事件出现,RunLoop 将使线程休眠。
你不必对创建的任何线程使用 RunLoop,但这样做可以为用户提供更好的体验。RunLoop 让使用最少资源创建持久线程成为可能。由于 RunLoop 无任务时会让线程处于休眠状态,因此它消除了轮询的需要,轮询会浪费 CPU 周期并阻止处理器本身休眠和节省电能。
配置 RunLoop,你只需要启动线程并获取对 RunLoop 对象的引用,安装你的事件处理程序,并使 RunLoop 运行。这个 OS X 提供的基础设施自动为你处理了主线程 RunLoop 的配置。但是,如果你计划创建持久的辅助线程,你必须自己为这个线程配置 RunLoop。
更多关于 RunLoop 的细节和样例,参见 《Run Loops》。
同步工具
线程编程的危险之一是多线程之间的资源竞争。如果多个线程试图同时使用或修改相同资源,可能就会出现问题。缓解这个问题的一种方法是完全消除共享资源,并确保每个线程都有自己要操作的一组不同资源。但是,如果不能维护完全独立的资源,则可能必须用锁、条件、院子操作和其他技术来同步对资源的访问。
锁为同一时间只能由一个线程执行的代码提供了强力保护。最常见的锁是互斥锁。当一个线程试图获取另一个线程持有的互斥锁时,它会阻塞,直到另一个线程释放锁为止。一些系统框架提供了对互斥锁的支持,尽管他们都基于相同的底层技术。此外,Cocoa 还提供了互斥锁的几个变体来支持不同类型的行为,比如递归。有关锁类型的更多信息,请参见 《Locks》。
除了锁之外,系统还提供了对条件的支持,这些条件确保应用中的任务顺序正确。一个条件作为一个看门人,阻塞一个给定线程,直到它所代表的条件变为真。当这种情况发生时,条件释放线程并允许它继续。POSIX 层和 Foundation 框架都为条件提供了直接支持。(如果你使用 Operation,你可以配置 Operation 对象之间的依赖关系,以保证任务的执行顺序,这与条件提供的行为非常类似。)
锁和条件在并发设计中非常常见,原子操作则是保护同步访问数据的另一种方式。在可以对标量数据执行数学或逻辑操作的情况下,院子操作提供了一种轻量级的锁替代方法。原子操作使用特殊的硬件指令,以保证其他线程有机会访问变量之前完成对变量的修改。
关于同步工具,更多信息参见 《Synchronization Tools》
线程间通信
虽然一个好的设计可以最小化所需的通信量,但某些时候,线程之间的通信是必要的。(线程为应用执行工作,但如果该工作结果从未使用过,那么它有什么意义呢?)线程可能需要处理新的作业请求,或者将它们的进度报告给应用主线程。在这些情况下,你需要一种将信息从一个线程获取到另一个线程的方法。幸运的是,线程共享相同的进程空间,这意味着你有很多通信选项。
线程间通信有很多种方式,它们各有优劣。Configuring Thread-Local Storage 列出了在 OS X 中最常见的通信机制。(除消息队列和 Cocoa 分布式对象外,这些技术在 iOS 中也可以用。)表中列出的技术是按照复杂性递增排序的。
Table 1-3 Communication mechanisms
机制 | 描述 |
---|---|
Direct messaging | Cocoa 应用程序支持在其他线程上直接执行选择器。这个功能意味着一个线程实际上可以在任何其他线程上执行一个方法。因为他们是在目标线程的上下文中执行的,所以这种方式发送的消息会在该线程上自动序列化。更多关于输入源的信息,参见 《Cocoa Perform Selector Sources》。 |
Global variables, shared memory, and objects | 在两个线程之间通信另一种简单的方法是使用全局变量、共享对象或共享内存块。尽管共享变量快速且简单,但他们也比直接传递消息更脆弱。共享变量必须小心地用锁或其他同步机制进行保护,以确保代码的安全性。如果不这样做,可能会导致静态条件,损坏数据或崩溃。 |
Conditions | 条件是一个同步工具,你可以使用它来控制线程何时执行代码的特定部分。你可以将条件视为看门人,只在满足指定条件的时候让线程运行。有关如何使用条件,参见 《Using Conditions》。 |
Run loop sources | 自定义 RunLoop source 是为线程上接收特定于应用的消息而设定的。因为它们是事件驱动,RunLoop sources 在无视可做时自动让线程休眠,从而提高了线程的效率。有关 RunLoop 和 RunLoop sources,参见 《Run Loops》。 |
Ports and sockets | 基于端口的通信是两线程间通信的一种更复杂的方式,但它也是一种非常可靠的技术。更重要的是,端口和套接字可用于与外部实体通信,如其他进程和服务。为了提高效率,端口是使用 RunLoop sources 去实现的,因此当端口上没有等待的数据时,线程处于休眠状态。有关 RunLoop 和基于端口的输入源信息,参见 《Run Loops》。 |
Message queues | 遗留的多进程服务定义了先进先出(FIFO)队列抽象,用于管理传入和传出数据。尽管消息队列简单方便,但他们不如其他一些通信技术有效。有关如何使用消息队列的更多信息,参见 《Multiprocessing Services Programming Guide》。 |
Cocoa distributed objects | 分布式对象是一种 Cocoa 技术,它提供了基于端口通信的高级实现。尽管可以将此技术用于线程间通信,但由于它会带来大量开销,因此非常不鼓励这么做。分布式对象更适合与其他进程通信,因为这些进程之间进行通信的开销已经非常高了。更多信息参见 《Distributed Objects Programming Topics》。 |
设计技巧
以下部分提供指导原则,帮助你以确保代码正确性的方式实现线程。其中一些指导原则还提供了帮助你使用自己线程代码获取更高性能的技巧。与任何性能提示一样,你应该总在更改代码前,期间和之后手机相关的性能统计信息。
避免显式创建线程
手动编写线程创建代码非常繁琐,而且可能出错,应该尽可能避免这种情况。OS X 和 iOS 通过其他 API 提供了对并发的隐式支持。与其自己手动创建线程,不如考虑使用异步 API,GCD、Operation 对象去完成这个工作。这些技术在幕后为你完成线程相关的工作,并保证正确地完成这些工作。此外,GCD 和 Operation 对象等技术旨在根据当前系统负载调整活动线程的数量,从而比你自己的代码更有效地管理线程。有关 GCD 和 Operation 对象的更多信息,参见 《Concurrency Programming Guide》。
保持线程合理繁忙
如果你决定手动创建和管理线程,请记住线程会消耗宝贵的系统资源。你应该尽力确保分配给线程的任何任务都具有合理的持久时间和高生产力。同时,你不应该害怕终止大部分时间处于空闲状态的线程。线程使用大量的内存,其中一些是连接的,因此释放空闲线程不仅有助于减少应用的内存占用,还可以释放更多的物理内存供其他操作进程使用。
Important: 在终止空闲进程之前,应该始终记录一族应用当前性能的基线度量。在尝试更改之后,采取额外的度量来验证更改实际上实在改进性能,而不是损害性能。
避免共享数据结构
避免线程相关的资源冲突最简易的办法是为程序中每个线程提供所需数据的副本。当线程之间的通信和资源竞争最小化时,并行代码工作得最好。
创建多线程应用是困难的。即使你非常小心,并在代码中所有正确的连接处锁定共享数据结构,你的代码在语义上仍不安全。例如,如果你的代码期望按照特定顺序修改共享数据结构,那么它可能会遇到问题。将代码更改为基于事务的模型进行补偿可能会抵消拥有多个线程的性能优势。消除资源竞争通常可使设计更简单,性能更好。
线程与用户界面
如果你的应用具有图形用户界面,建议你从应用主线程接受与用户相关的时间并更新界面。这种方法有助于避免与处理用户事件和回执窗口内容相关的同步问题。一些框架,如 Cocoa,通常需要这种行为,但即使对于那些不需要的框架,将这种行为保留在主线程上的好处是简化了管理用户界面的逻辑。
有几个值得注意的例外情况是,从其他线程执行图形操作是有利的。例如,你可以使用辅助线程来创建和处理图像,并执行其他与图像相关的计算。对这些操作使用辅助线程可以极大地提升性能。如果你不确定某个特定的图形化操作,可以从主线程着手。
关于 Cocoa 线程安全的更多信息,参见 《Thread Safety Summary》,关于 Cocoa 绘图的更多信息,参见 《Cocoa Drawing Guide》。
了解线程在退出时的表现
进程一直运行到所有非分离线程退出为止。默认情况下,只有应用的主线程被创建为非分离的,但你也可以以这种方式创建其他线程。当用户退出应用时,通常来说合理的做法是立即终止所有分离线程,因为分离线程所在的工作被认为是可选的。但是,如果应用使用后台线程将数据保存到磁盘或执行其他关键工作时,则可能希望这些线程创建为非分离线程,以防止在应用退出时丢失数据。
创建非分离(也称为可连接)线程需要你进行额外的工作。因为大多数高级线程技术默认情况下不创建可连接线程,所以你可能必须使用 POSIX API 去创建线程。此外,你必须将代码添加到应用的主线程中,以便在非分离线程最终退出时连接。有关可连接线程的信息,参见 《Setting the Detached State of a Thread》。
如果你正在编写 Cocoa 应用,你还可以使用 applicationShouldTerminate: 代理方法将应用的终止延迟到稍后的时间,或者完全取消它。当延迟终止时,你的应用需要等待直到每个关键线程完成他们的任务,然后调用 replyToApplicationShouldTerminate: 方法。有关这些方法的更多信息,参见 《NSApplication Class Reference》。
处理异常
异常处理机制依赖于当前调用堆栈,以便在抛出异常时执行任何必要的清理。每个线程都有自己的调用堆栈,因此每个线程负责补货自己的异常。在辅助线程中捕获异常失败与在主线程中捕获失败是一样的:拥有的进程被终止。你不能将未捕获的异常抛出到另一个线程进行处理。
如果你需要将当前线程中的异常通知另一个线程(比如主线程),那么你应该捕获异常并向另一线程发送一条消息,指示发生了什么。根据你的模型和你尝试做的事情,捕获异常的线程可以继续处理(如果可能的话),等待指令,或者干脆退出。
Note: 在 Cocoa 中,
NSException
对象是一个自包含的对象,一旦它被捕获,就可以从一个线程传递给另一个线程。
在某些情况下,异常处理程序可能会自动被创建。例如,Objective-C 中的 @synchronized 指定包含一个隐式异常处理程序。
干净地终止线程
线程退出的最佳方式自然是让它到达主入口例程的末尾。虽然有些函数可以立即终止线程,但这些函数只能作为最后的手段使用。在线程到达其自然终点之前终止线程,可能妨碍其之后清理工作的执行。如果线程分配了内存、打开了文件或获取了其他类型的资源,那么代码可能无法回收这些资源,从而导致内存泄露或其他潜在问题。
合理退出进程的更多信息,参见 《Terminating a Thread》。
库中的线程安全
虽然一个应用开发者可以控制应用是否用多线程执行,但库开发者不能。开发库的时候,你必须假定应用调用是多线程或可以在任何时候切换到多线程。因此,你应该始终对代码的关键部分使用锁。
对库开发者来说,仅在应用成为多线程时创建锁是不明智的。如果你需要在某个时机锁定代码,请在库的使用早期创建锁对象,最好是显式调用初始化库。尽管你也可以使用静态库初始化函数来创建此类锁,但只在没有其他方法时才尝试这么做。初始化函数的执行会增加加载库所需的时间,并可能对性能产生负面影响。
Note: 始终记得库中互斥锁的加锁与解锁调用成对。你还应该记住锁定库的数据结构,而不是依赖调用代码来提供线程安全的环境。
如果你正在开发 Cocoa 库,当你希望在应用变多线程时得到通知,你可以注册 NSWillBecomeMultiThreadedNotification 的观察者。但是,你不应该依赖接收的这个通知,因为它可能在调用库代码之前就已经分发了。