Threading Programming Guide · Thread Management [翻译]
原文链接
OS X 或 iOS 中的每个进程(应用程序)由一个或多个线程组成,每个线程表示应用代码的单一执行路径。每个应用都从一个线程开始,该线程运行应用程序的主函数。应用程序可以派生其他线程,每个线程都执行特定函数的代码。
当应用产生先线程时,该线程成为应用进程空间中的一个独立实体。每个线程有它自己的执行栈,并由内核分别调度到运行时。线程可以与其他线程、其他进程进行通信,执行 I/O 操作,以及你需要的其他操作。但是,由于他们在同个进程空间中,单个应用的所有线程共享同样的虚拟内存空间,并具有与进程本身相同的访问权限。
本章概述了 OS X 和 iOS 中可用的线程技术,以及在应用中使用这些技术的示例。
Note: 要了解 Mac OS 线程架构的历史,以及有关线程的其他背景信息,请参见 《Technical Note TN2028, “Threading Architectures”》。
线程开销
线程在内存使用和性能方面对你的程序(和系统)有实际的成本。每个线程都需要在内核内存空间和程序内存空间请求分配内存。管理线程和协调线程调度所需的核心结构在内核中使用 wired 内存。线程的堆栈空间和每个线程的数据存储在程序的内存空间中。这些结构大多数是你第一次创建线程时创建和初始化的 —— 由于需要与内核进行交互,这个过程的开销相对较大。
Table 2-1 量化了应用中创建用户级线程相关的大约成本。其中一些开销是可配置的,比如辅助线程分配的堆栈空间。创建线程的时间成本是一个粗略的近似值,应该只用于互相之间的相对比较。线程创建时间可以随着处理器负载、计算机速度以及可用系统的程序内存数量而发生很大的变化。
Table 2-1 线程创建成本
选项 | 大致开销 | 说明 |
---|---|---|
内核数据结构 | 约 1 KB | 此内存用于存储线程数据结构和属性,其中大部分被分配为 wired 内存,因此不能分页到磁盘 |
堆栈空间 | 512 KB (辅助线程), 8 MB (OS X 主线程), 1 MB (iOS 主线程) | 辅助线程允许的最小堆栈大小是 16 KB,堆栈大小必须是 4 KB 的倍数。该内存的空间在线程创建时被预留在进程空间中,但是该内存关联的实际页直到被需要时才被创建。 |
创建耗时 | 约 90 ms | 此值反映创建线程的初始调用与线程入口例程开始执行之间的时间。这些数字是通过分析在基于 intel 的 Mac 上创建线程时生成的平均值和中指确定的。 |
Note: 由于底层内核支持,Operation 对象通常可以很快地创建线程。它们不是每次都从头开始创建,而是使用已经驻留在内核中的线程池来节省分配空间。有关使用 Operation 对象的更多信息,参见 《Concurrency Programming Guide》。
编写线程代码时要考虑的另一个成本是生产成本。设计线程应用有时需要对组织应用数据结构的方式进行基本更改。为了避免使用同步,可能需要进行这些更改,因为同步本身对设计糟糕的应用造成巨大的性能损失。设计这些数据结构,并在线程代码中调试问题,可能会增加开发线程应用所需的时间。但是,如果线程花费太多时间等待锁或者什么都不做,那么避免这些开销会在运行时带来更大的问题。
创建线程
创建低级线程相对简单。在所有情况下,必须有一个函数或方法作为线程的主要入口点,并且必须使用一个可用的线程例程来启动线程。下面几节将展示更常用的线程技术的基本创建过程。使用这些技术创建的线程继承一组默认属性集,默认属性集又你使用的技术决定。更多线程配置信息,参见 《Configuring Thread Attributes》。
使用线程
使用 NSThread 创建线程有两种方式:
- 使用 detachNewThreadSelector:toTarget:withObject: 类方法去生成新线程
- 创建一个新的 NSThread 对象并启动(仅 iOS 及 OS X v10.5+ 支持)
两种技术都会在应用中创建一个分离线程。分离线程意味着当线程退出时,系统会自动回收线程的资源。这也意味着你的代码以后不必显式地与线程连接。
由于 OS X 的所有版本都支持 detachNewThreadSelector:toTarget:withObject:
方法,所以在使用线程的现有 Cocoa 应用中经常能找到它。要分离一个新线程,只需要提供用作线程入口点的方法名(selector)、定义该方法的对象以及希望启动时传递给线程的任何数据。下面的示例展示了此方法的基本调用,该调用使用当前对象的自定义方法生成线程。
1 | [NSThread detachNewThreadSelector: (myThreadMainMethod:) toTarget:self withObject:nil]; |
在 OS X v10.5 之前,主要是用 NSThread
类来派生线程。虽然你可以获得 NSThread
对象并访问一些线程属性,但只能在线程运行之后从线程本身进行访问。在 OS X v10.5 中,添加了对创建 NSThread
对象的支持,而无需立即生成相应的新线程。(iOS 也提供这种支持。)这种支持使得在启动线程之前获取和设置各种线程属性成为可能。它还使得以后可以使用该线程对象来引用正在运行的线程。
在 OS X v10.5 和更高版本中初始化 NSThread
对象的简单方法是使用 initWithTarget:selector:object:
方法。这个方法获取与 detachNewThreadSelector:toTarget:withObject:
方法完全相同的信息,并使用它初始化一个新的 NSThread
实例。但是,它不会启动线程。要启动线程,需要显式调用线程对象的 start 方法,如下面示例所示:
1 | NSThread* myThread = [[NSThread alloc] initWithTarget:self |
Note: 子类化
NSThread
并覆写main
方法是initWithTarget:selector:object:
的另一种选择。你将使用此方法的重写版本来实现线程的主入口点。更多信息查看 NSThread Class Reference 的 Subclassing Notes。
如果你有一个 NSThread
对象,它的线程当前正在运行,你可以向这个线程发送消息的一种方法是使用 performSelector:onThread:withObject:waitUntilDone: 方法,该方法适用于你的应用程序中几乎所有的对象。OS X v10.5 引入了对在线程(主线程除外)上执行选择器的支持,这是线程之间通信的一种便捷方式。(iOS 也提供这种支持。)使用此技术发送的消息将由另一个线程直接执行,作为其正常 RunLoop 的一部分。(当然,这却是意味着目标线程必须在其 RunLoop 中运行;见 Run Loops。)当你以这种方式进行通信时,可能仍然需要某种形式的同步,但这比在线程之间设置通信端口要简单。
Note: 尽管使用于线程间的偶尔通信,但对于线程间的时间关键性通信或频繁通信,不应使用
performSelector:onThread:withObject:waitUntilDone:
方法。
有关其他线程通信选项的列表,参见 《Setting the Detached State of a Thread》。
使用 POSIX 线程
OS X 和 iOS 为使用 POSIX 线程 API 创建线程提供了基于 C 的支持。这种技术实际上可以在任何类型的应用程序使用(包括 Cocoa 和 Cocoa Touch 应用),如果你是为多平台编写软件,那么它可能更方便。你用来创建线程的 POSIX 例程被恰到好处地称为 pthread_create
。
Listing 2-1 展示了使用 POSIX 调用创建线程的两个定制函数。LaunchThread
函数创建一个新线程,该线程的主例程在 PosixThreadMainRoutine
函数中实现。因为 POSIX 默认情况下创建的线程是可连接的,所以本示例更改线程的属性以创建分离的线程。将线程标记为已分离使系统有机会在线程退出时立即返回该线程的资源。
Listing 2-1 Creating a thread in C
1 | #include <assert.h> |
如果将前面清单中的代码添加到源文件中并调用 LaunchThread
函数,它将在应用程序中创建一个新的分离线程。当然,使用此代码创建的新线程不会做任何有用的事情。线程将启动并几乎立即退出。为了是事情更有趣,你需要向 PosixThreadMainRoutine
函数中添加代码来执行一些实际的工作。为了确保线程知道你要做什么,可以在创建时间向它传递一个指向某些数据的指针。将该指针作为 pthread_create
函数的最后一个参数传递。
要将新创建线程的信息通信回应用的主线程,需要在目标线程之间建立通信路径。对基于 C 的应用,线程之间有几种通信方式,包括端口、条件和共享内存的使用。对于持久存活的线程,几乎总是应该设置某种线程间通信机制,以使应用程序的主线程能够检查线程的状态,或者在应用退出时干净地关闭它。
更多关于 POSIX 线程函数的信息,参见 《pthread》。
使用 NSObject 生成线程
在 iOS 和 OS X v10.5 以及更高版本中,所有对象都能够生成一个新线程并使用它执行其中一个方法。performSelectorInBackground:withObject: 方法创建一个新的分离线程,并使用指定的方法作为新线程的入口点。例如,如果你有一个对象 myObj 并且该对象有一个名为 doSomething 的方法,你希望后台线程中执行该方法,你可以使用一下代码来实现:
1 | [myObj performSelectorInBackground:@selector(doSomething) withObject:nil]; |
调用此方法的而效果与使用当前对象、选择器和参数对象作为参数调用 NSThread 的 detachNewThreadSelector:toTarget:withObject: 方法效果相同。新线程使用默认配置立即生成并开始运行。在选择器中,必须像配置任何线程一样配置线程。例如你需要设置一个自动释放池(如果你没有使用垃圾收集),如果你计划使用它,则需要配置线程的 RunLoop。有关如何配置新线程的信息,参见 《Configuring Thread Attributes》。
在 Cocoa 应用中使用 POSIX 线程
虽然 NSThread 类是 Cocoa 应用中创建线程的主接口,但如果使用 POSIX 线程会更方便的话,你可以自由使用它。例如,如果你已经有使用 POSIX 线程的代码,并且你不想重写它,那么你可能会使用它。如果你确实计划在 Cocoa 应用中使用 POSIX 线程,那么你仍然应该了解 Cocoa 和线程之间的交互,并遵守以下部分的指导原则。
保护 Cocoa 框架
对于多线程应用,Cocoa 框架使用锁和其他形式的内部同步来确保他们的行为正确。但是,为了防止这些锁在单线程情况下降低性能,Cocoa 在应用使用 NSThread 类生成第一个新线程之前不会创建他们。如果你仅使用 POSIX 线程例程来派生线程,那么 Cocoa 不会接收它所需的通知(当前应用是否为多线程)。当这种情况发生,设计 Cocoa 框架的操作可能会破坏应用的稳定性或使其崩溃。
要让 Cocoa 知道你打算使用多线程,你索要做的就是使用 NSThread 类生成一个线程并让该线程立即退出。线程入口点不需要做任何事情。仅使用 NSThread 生成线程的行为就足以确保 Cocoa 框架所需的锁到位。
如果你不确定 Cocoa 是否认为你的应用是多线程的,你可以使用 NSThread 的 isMultiThreaded 方法来检查。
混合 POSIX 与 Cocoa 锁
在同个应用中混合使用 POSIX 和 Cocoa 锁是安全的。Cocoa 锁和条件对象本质上只是 POSIX 互斥锁和条件的包装器。但是,对于给定的锁,必须始终使用相同的接口来创建和操作该锁。换句话说,你不能使用 Cocoa NSLock 对象来操作使用 pthread_mutex_init 函数创建的互斥锁,反之亦然。
配置线程属性
在创建线程之后,有时候在创建线程之前,你可能希望配置线程环境的不同部分。下面几节将描述可进行的一些更改,以及你什么时候可能进行这些更改。
配置线程的堆栈大小
对于你创建的每个新线程,系统将在进程空间分配特定数量的内存,作为该线程的堆栈。堆栈管理堆栈帧,也是声明线程任何局部变量的地方。分配给线程的内存数量列在了 《Thread Costs》 中。
如果要更改线程的堆栈大小,必须在创建线程之前修改。所有的线程技术都提供了一些设置堆栈大小的方法,尽管使用 NSThread 设置堆栈大小只在 iOS 和 OS X v10.5 以及更高版本中可用。Table 2-2 列出了每种技术的不同选项。
Table 2-2 Setting the stack size of a thread
技术 | 选项 |
---|---|
Cocoa | 在 iOS 和 OS X v10.5 以及更高版本中,分配和初始化一个 NSThread 对象(不使用 detachNewThreadSelector:toTarget:withObject: 方法)。在调用线程对象的 start 方法之前,使用 setStackSize: 方法指定新的堆栈大小。 |
POSIX | 创建一个新的 pthread_attr_t 结构,并使用 pthread_attr_setstacksize 函数更改默认堆栈大小。在创建线程时,将属性传递给 pthread_create 函数。 |
Multiprocessing Services | 在创建线程时,将合适的堆栈大小值传递给 MPCreateTask 函数。 |
配置线程本地存储
每个线程维护一个键值对的字典,可以从线程中任何位置访问该字典。你可以使用这个字典存储在线程执行过程中持久保存的信息。例如,你可以使用它来存储希望在线程 RunLoop 多次迭代中保持的状态信息。
Cocoa 和 POSIX 用不同的方式存储线程字典,所以你不能混用两种调用。但是只要你在线程代码中坚持用一种技术,最终结果应该是相似的。在 Cocoa 中,使用 NSThread 对象的 threadDictionary 方法来检索 NSMutableDictionary
对象,可以向该对象添加线程所需的任意键。在 POSIX 中,使用 pthread_setspecific
和 pthread_getspecific
函数设置和获取线程的键和值。
设置线程的分离状态
大多数高级线程技术默认创建分离线程。大多数情况下,首选分离线程,因为他们允许系统在线程完成时立即释放线程的数据结构。分离的线程也不需要与程序进行显式交互。从线程检索结果的方法由你自行决定。相比之下,另一个线程显式地与该线程连接之前,系统不会回收可连接线程的资源,这一过程可能会阻塞执行连接的线程。
你可以将可连接线程视为类似于子线程。虽然他们仍然作为独立线程运行,但可连接线程必须由另一个线程连接,然后系统才能回收其资源。可连接线程还提供了数据从现有线程传递到另一个线程的显式方法。在退出之前,可连接线程可以将数据指针或其他返回值传递给 pthread_exit
函数。然后,另一个线程可以通过调用 pthread_join
函数来声明这些数据。
Important: 在应用退出时,分离线程可以直接被终止,但可连接线程不行。在允许进程退出之前,必须连接每个可连接线程。因此,在线程正在执行时不应中断关键工作(如保存数据到磁盘)的情况下,可连接线程更可取。
如果你确实想创建可连接线程,那么唯一的方法就是使用 POSIX 线程。POSIX 默认情况下创建可连接的线程。要将线程标记为分离或是可连接,可在创建线程钱使用 pthread_attr_setdetachstate
函数修改线程属性。在线程开始之后,可以通过调用 pthread_detach
函数将可连接线程更改为分离线程。有关这些 POSIX 线程函数的更多信息,请参见 pthread 手册页。有关如何与线程连接的信息,参见 《pthread_join》 手册页。
设置线程优先级
你创建的任何新线程都具有阈值关联的默认优先级。内核的调度算法在决定运行哪个线程时考虑了线程优先级,高优先级的线程比低优先级的线程更有可能运行。更高的优先级并不保证线程有特定的执行时间,只是在调度程序在比较低优先级线程时更有可能选择它。
Important: 通常,让线程的优先级保持默认值是一个好主意。增加某些线程的优先级还会增加较低优先级线程饥饿的可能性。如果应用必须包含相互交互的高优先级与低优先级线程,那么低优先级线程的饥饿可能会阻塞其他线程并造成性能瓶颈。
如果想修改线程优先级,Cocoa 和 POSIX 都提供了一种方法。对于 Cocoa 线程,你可以使用 NSThread 的 setThreadPriority: 类方法来设置当前运行线程的优先级。对于 POSIX 线程,你可以使用 pthread_setschedparam
函数。更多信息,参见 《NSThread Class Reference》《pthread_setschedparam》。
编写线程入口例程
在大多数情况下,线程入口例程的结构在 OS X 中与其他平台上是相通的。你可以初始化你的数据结构,执行一些工作,或可选地设置一个 RunLoop,并在代码完成时进行清理。根据你的设计,在编写输入例程时可能需要采取一些额外的步骤。
创建一个自动释放池
在 Objective-C 框架中链接的应用通常必须在每个线程中创建至少一个自动释放池。如果应用使用托管模型(应用在其中处理对象的保留和释放),则自动释放池将捕获从该线程自动释放的任何对象。
如果应用使用垃圾回收而不是托管内存模型,啧没有必要创建自动释放池。在垃圾回收应用中存在自动释放池是无害的,而且在很大程度上被忽略了。在代码模块必须同时支持垃圾回收和托管内存模型的情况下,这是允许的。在这种情况下,必须提供自动释放池来支持托管内存模型代码,如果应用在弃用垃圾回收的情况下运行,则自动释放池将被忽略。
如果你的应用使用托管内存模型,那么创建一个自动释放池应该是线程入口例程中要做的第一件事。同样,销毁这个自动释放池也应该是线程中做的最后一件事。这个池确保捕获了自动释放的对象,尽管它知道线程本身退出时才释放它们。Listing 2-2 显示了使用自动释放池的基本线程入口例程的结构。
Listing 2-2 Defining your thread entry point routine
1 | - (void)myThreadMainRoutine |
因为顶级的自动释放池在线程退出之前不会释放它的对象,所以持久线程应创建额外的自动释放池来频繁释放对象。例如,使用 RunLoop 的线程可能每次通过 RunLoop 创建并释放一个自动释放池。更频繁地释放对象可以防止应用内存占用增长过大,从而导致性能问题。但是,对于任何与性能相关的行为,你都应该度量代码的实际性能,并适当地调整自动释放池的使用。
关于内存管理和自动释放池的更多信息,参见 《Advanced Memory Management Programming Guide》。
设置异常处理程序
如果应用捕获并处理异常,啧线程代码应用准备捕获可能发生的任何异常。虽然最好的异常可能是发生的时候处理它们,但在线程中捕获抛出的异常失败会导致应用退出。在线程入口例程中安放最终的 try/catch 允许捕获任何未知异常并提供适当的响应。
在 Xcode 中构建项目时,可以用 C++ 或 Objective-C 异常处理样式。关于 Objective-C 设置异常处理的信息,参见 《Exception Programming Topics》。
设置 RunLoop
编写单独线程上运行的代码,有两个选项。第一个选项是为线程编写代码,将其作为一个场任务执行,很少或没有中断,并在线程完成时退出。第二个选项是将线程放入循环中,让它在请求到达时动态地处理它们。第一个选项不需要对代码进行特殊设置;你只是开始做你想要的工作。第二个选项设计设置线程的 RunLoop。
OS X 和 iOS 在每个线程中实现 RunLoop 提供了内置支持。应用框架自动启动应用主线程的 RunLoop。如果创建任何辅助线程,则必须配置 RunLoop 并手动启动它。
关于使用和配置 RunLoop 的更多信息,参见 《Run Loops》。
终止线程
让线程正常退出其入口点例程是推荐做法。虽然 Cocoa、POSIX 和 Multiprocessing Services 提供了直接杀死线程的例程,但是强烈反对使用它们。杀死一个线程会组织该线程自清理。线程分配的内存可能会泄露,线程当前使用的任何其他资源可能不会被正确清理,这将在以后产生潜在的问题。
如果你逾期需要在操作过程中终止线程,那么应该从一开始就设计线程来响应取消或退出消息。对于长时间运行的操作,这可能意味着定期停止工作并检查是否有这样的消息到达。如果确实有消息要求线程退出,那么线程将有机会执行任何需要的清理并优雅地退出;否则,它可以简单地返回工作并处理下一阶段数据。
响应取消消息的一种方法是使用 RunLoop 输入源来接受此类消息。Listing 2-3 展示了改代码在线程主例程中的结构。(该示例仅展示主循环部分,不包括设置自动释放池和配置要做的实际工作的步骤)。该示例在 RunLoop 上安装了一个自定义输入源,可以从另一个线程发送消息;有关设置输入源的信息,参见 《Configuring Run Loop Sources》。在执行了总工作量的一部分之后,线程将简要地运行 RunLoop,以查看消息是否到达输入源。如果不是,RunLoop 将立即退出,循环将继续下一个工作块。因为处理程序不能直接访问 exitNow
本地变量,所以退出条件通过自称字典的键值进行通信。
Listing 2-3 Checking for an exit condition during a long job
1 | - (void)threadMainRoutine |