[译]线程编程指南(一)

本文选译自《Threading Programming Guide》

导语

线程技术作为在单个应用程序中并发执行多个代码路径的技术之一。尽管新的技术,诸如操作对象(Operation objects)大中枢调度(GCD)提供了一个更加现代和高效的并发实现方式,但OS X和iOS也提供了用于创建和管理线程的接口。

注意:如果你正在开发一个新的应用,你可以选择使用该技术作为并发操作的实现之一。假使你并未真正理解该技术对于实现多线程应用的技术细节,这里有一些简化并发操作实现难度并提供性能更加优越的技术方案可供选择。获取更多信息,请参见《Concurrency Programming Guide》

本文结构

  • 关于线程编程
  • 线程管理
  • Run Loops
  • 线程同步
  • 线程安全总结

关于线程编程

多年以来,计算机性能峰值在很大程度上受制于单个微处理器的计算机核心。当单个处理器的速度开始达到它们的实际限制时,芯片制造商切换到多核设计,以达到让计算机同时执行多个任务的目的。虽然操作系统可以利用多核技术来执行系统相关的任务,然而你自己的应用也可以通过线程技术来利用该技术。

何为线程?

线程是程序中一个更加轻量级的多路径执行实现方式。在系统级别,程序会根据系统为其提供的执行时间以及其他程序需要的执行时间统一调度执行。在程序内部,存在承载着不同任务的一个或多个同时或近乎同时执行的线程。实际上系统本身会管理线程的执行,调度其运行在某个核心上或在其他线程需要执行的时候强制中段该线程的执行。

从技术层面看,线程是一个内核级和应用级数据结构组合,用于管理代码的执行。内核级结构协调事件的调度和可用的核心抢占式调度。应用级结构包括用于存储函数调用的调用堆栈以及需要管理和操作线程的属性与状态的程序结构。

在非并发程序中,只有一个线程的执行。这个线程开始和结束于程序的main函数,并由一个接一个的不同方法或函数来实现程序的全部行为。相比之下,支持并发的程序可以启动一个线程,并按照需求增加线程以创建额外的执行路径。每一条新路径都有独立的自定义启动入口,在程序的main函数中独立运行代码。多线程的程序具有两个非常重要的潜在优势:

  • 多线程可以提高应用程序的响应能力。
  • 多线程可以提高应用程序在多核系统上的实时性能。

如果你的应用只有一个线程,那么单个线程就必须完成所有的操作。它必须对事件作出响应,更新应用窗口,以及完成应用行为的全部计算。单个线程面临着同一时刻只能执行一个任务的问题,所以当其中的一个计算任务需要花费很长时间来完成时怎么办?当你的应用忙于计算,却停止响应用户事件和更新窗口时。如果这样的情况长期持续下去,用户也许会任务应用被挂起并且会尝试强制退出。如果将自定义的计算操作移至单独的线程中去,应用程序的主线程才会在合适的时机有机会去和用户做交互。

随着多核计算机的日益普及,线程技术成为了某些应用提供了提升性能的一种方式。线程可以在多核设备上同时执行不同任务,这使得应用程序在同一时间大大地提高了工作效率。

当然,线程技术并不是解决应用性能问题的万能药。线程技术为我们开发带来好处的同时也伴随着隐患。程序中的多个执行路径会给代码添加相当数量的复杂度。每个线程与其他线程必须协调行动以防止程序的状态信息被破坏。因为在单个应用程序中各线程共享相同的内存空间,它们可以访问所有共享的数据。如果两个线程试图同时操纵共享数据,一个线程可能会覆盖其他线程的修改,导致数据破坏。即使有适当的保护措施,你仍需要注意编译器优化为代码引入微妙的(和不那么微妙的)错误。

相关术语

在深入讨论线程及其支持技术之前,必须定义一些基本术语。
如果你熟悉UNIX系统,你会发现task是由本文档中使用不同的。在UNIX系统中,术语task的使用有时指一个正在运行的进程。

本文采用以下术语:

  • 线程(thread)用来指代码的一个单独执行路径。
  • 进程(process)用来指一个正在运行的可执行文件,可包含多个线程。
  • 任务(task)用来指需要进行工作的抽象概念。

线程替代技术

自己创建线程的一个问题是它可能会给你的代码带来不确定性。因为线程是一个比较低级别且复杂的方式来支持应用程序的并发性。如果你不完全理解选择该方式的含义,你很容易地遭遇线程同步或开发时间问题,其严重程度可以从细微的行为变化到应用的崩溃以及用户数据的破坏。

另一个需要考虑的因素是应用到底是否需要线程或并发。线程解决了在同一进程中同时执行多个代码路径的具体问题。可能有先例,但你所做的工作不需要并发。线程在你的进程中引入巨大的开销,无论是在内存消耗和处理器时间方面。你可能会发现,对于预定的任务这个开销太大,或者说其他选择更容易实现。

表1-1 线程替代技术

名称 描述
操作对象(Operation objects) OS X 10.5引入,操作对象是对运行在辅助线程上执行任务的封装。这层封装隐藏了执行任务对于线程的管理,让你可以自由地专注于任务本身。通常,你需要将操作对象与一个操作队列对象协同使用,该操作队列对象实际上管理一个或多个线程中的操作对象的执行。
大中枢调度(GCD) OS X 10.6引入,大中枢调度是另外一种线程技术,它让你专注于你需要执行的任务而不是线程管理。通过GCD,你定义需要执行的任务并将其添加到一个工作队列中,它会调度你的任务在一个适当的线程上执行。工作队列会根据可用的处理器核心数量和当前的任务负载来执行你的任务,这比你自己使用线程更加高效。
空闲时间通知(Idle-time notifications) 对于相对较短且优先级很低的任务,当你的应用程序不忙的时候空闲时间通知会执行你的任务。Cocoa使用NSNotificationQueue对象为空闲时间通知提供支持。向NSNotificationQueue对象发送一个默认选项为NSPostWhenIdle的通知,即可完成空闲时间通知的请求。直到run loop处于空闲状态时该队列才会完成对通知对象的消息传递。
异步函数(Asynchronous functions) 系统接口包括许多为你提供自动并发的异步函数。这些API会使用系统守护进程和进程或创建自定义线程来执行任务并返回结果给你。(实际的实现是不相关的,因为它是从你的代码中分离出来的),当设计你的应用时,寻找提供异步行为的函数,并考虑使用它们,而不是使用自定义线程上的等效同步函数。
定时器(Timers) 你可以用定时器在应用程序的主线程上周期性地执行一些太过琐碎而不需要创建线程,但仍需要定期进行的任务。
独立进程(Separate processes) 虽然比线程更重量级,创建一个单独的进程在可能的情况下是为了完成与应用相切的任务。如果任务需要大量的内存或者必须以root权限执行,你需要使用一个进程。例如,你可以使用64位服务器进程来计算一个大数据集,而让32位应用为用户显示结果。

线程支持

如果你的代码中已经使用了线程,OS X和iOS提供了多个为应用创建线程的技术。此外,所有操作系统同样为这些线程提供了管理和同步支持。以下各节描述了一些你需要知道工作在OS X和iOS系统中线程的关键技术。

线程的层级

虽然线程的底层实现机制是Mach线程,但是你很少(如果有)在Mach线程上完成工作。相反,你通常使用更方便的POSIX API或其衍生物。Mach线程实现方式能提供所有线程的基本功能,包括抢占式的执行模型和线程调度的能力,并且这两部分是彼此独立的。

表1-2 线程技术

名称 描述
Cocoa线程(Cocoa threads) Cocoa使用NSThread来实现线程。同时也在已经存在的线程上为NSObject提供了产生新线程(perform selector)的方法。
POSIX线程(POSIX threads) POSIX线程使用基于C语言的接口创建线程。如果你编写的不是Cocoa应用,这会是创建线程的最佳方式。POSIX接口使用相对简单并且为线程的配置提供了足够的灵活性。
多进程服务(Multiprocessing Services) 多进程服务是一种从老版本Mac OS过渡的基于传统C语言的应用技术。这项技术仅用于OS X,并应避免用于任何新的发展。相反,你应该使用NSThread类或POSIX线程。

在应用级,所有线程的行为与其他平台基本上是一样的。在启动一个线程之后,线程运行在三个主状态中的一个:运行、就绪或阻塞。如果一个线程当前未运行,它可以被阻塞以等待输入,或者它已经处于就绪状态,但还没有被调度执行。该线程会持续在这些状态中来回切换,直到它最后退出并切换到终止状态。

当你创建新的线程时,必须为其指定一个入口点函数(或Cocoa中的一个入口点方法)。这个入口点函数组合了你想在线程上运行的代码。当函数返回时,或当你显示地终止线程时,线程会永久停止并被系统回收。由于线程的创建对于内存和时间消耗比较大,因此建议你的输入点函数里完成重要部分的工作或设置一个run loop以执行重复性工作。

Run Loops

Run loop是一个在线程中异步地管理事件到达的基础结构。它通过在线程上监视一个或多个事件源来完成相应工作。当事件到达时,系统会唤醒线程并且在run loop中分发事件,而run loop则将事件分发给你指定的回调处理代码。如果没有到达事件以及待处理事件时,run loop会让线程处于休眠状态。

你大可不必为创建的线程配置一个run loop,但你这样做了,将会为用户带来更好的体验。Run loop使得花费少量资源以创建长生命周期的线程成为可能。因为它会让线程在无事可做的时候休眠,它会停止轮询工作以避免CPU资源的浪费和电量的消耗。

要配置一个run loop,你需要做的只是启动你的线程,得到一个run loop的引用对象,设置好事件回调处理代码,并且让run loop启动。操作系统已经自动地为你提供一个主线程的run loop回调,然而你必须为你自己的线程配置run loop。

同步工具

线程编程的一个危险在于多线程之间资源访问的冲突。如果多个线程尝试在同一时间使用或修改同一资源,可能会出现问题。缓解这个问题的一个方法是完全消除共享资源,确保每个线程都有它自己的一组资源来操作。当不能保持完全独立的资源时,你可能需要使用锁、条件锁、原子操作和其他技术来同步访问资源。

锁提供了一种强有力地保护代码在同一线程的同一时间安全执行的形式。最常见的锁类型是互斥排他锁,也被称为互斥锁。当一个线程试图获取已被另一个线程持有的锁,该线程会处于阻塞状态直到锁被其他线程释放。系统框架提供了互斥锁的支持,虽然它们都是基于相同的底层技术。此外,Cocoa提供了互斥锁的几个变种以支持不同类型的行为,如递归锁。

除了锁,系统还提供了条件锁的支持,确保在你的应用程序中进行任务的适当排序。条件锁充当一个“看门人”,阻塞一个给定的线程,直到它具有的条件满足。当发生上述情况,条件锁会为线程放行,并允许其继续执行。POSIX层和Foundation Framework都为条件锁提供了直接支持。(如果使用操作对象,则可以将操作对象间的依赖关系配置为执行任务的顺序,这与条件锁提供的行为非常相似。)

尽管锁和条件锁在并发设计中极为常见,原子操作是另一种方式来保护和同步访问数据。原子操作是执行标量数据类型的数学或逻辑运算时提供的一个轻量级的锁替代方案。原子操作使用特殊的硬件指令,以确保在其他线程有机会访问之前完成对变量的修改。

线程间的通信

一个好的设计是最大限度地减少所需的通信量,但在某些时候,线程之间的通信成为必要。(线程的职能是为你的应用程序工作,但如果工作的结果不被使用,那它的好处是什么?)线程可能需要处理新的工作请求,或者向应用程序的主线程报告其进展情况。在这些情况下,你需要一种方式来从一个线程到另一个线程获取信息。幸运的是,线程共享相同的进程空间意味着你有很多可选的通信方式。

线程间的通信方式有很多,各有其优缺点。在OS X中,配置线程本地存储作为最常见的通信机制。(除了消息队列Cocoa分布式对象,这些技术在iOS也可用)。

表1-3 通信机制

名称 描述
直接通信(Direct messaging) Cocoa应用程序支持在其他线程上直接执行选择器的方式。这种能力意味着一个线程基本上可以在任何其他线程上执行方法。因为它们都在目标线程的上下文中执行,消息会以这种方式自动序列化该线程上。
全局变量、共享内存及对象(Global variables, shared memory, and objects) 另一种简单的方法是使用全局变量、共享对象、或共享内存块来传递信息。虽然共享变量是快速和简单的,但它们比直接通信更为脆弱。共享变量必须小心地由锁或其他同步机制来确保代码的正确性。这样做失败的话可能导致竞态条件、数据损坏或应用崩溃。
条件锁(Conditions) 条件锁是一种可以控制线程执行某个特定的代码段的同步工具。你可以把条件锁当作“看门人”,只有当规定的条件满足时才让线程执行。
Run loop源(Run loop sources) 自定义run loop源可以让你在线程上接收应用具体的消息。因为它们是事件驱动的,当没有任何事可以做时线程会自动休眠,这提高了线程的效率时。
端口与套接字(Ports and sockets) 基于端口的通信是一种比较复杂的线程间的通信方式,但它也是一种非常可靠的技术。更重要的是,端口和套接字可用于与外部实体进行通信,如其他进程和服务。为了提高效率,端口由run loop源实现,所以当没有数据在端口等待时线程会休眠。
消息队列(Message queues) 传统的多进程服务定义了先进先出(FIFO)队列用于处理传入和传出的数据。虽然消息队列简单且方便,但它们不像其他一些通信技术那样高效。
Cocoa分布式对象(Cocoa distributed objects) 分布式对象是一种基于端口通信的高等级实现的Cocoa技术。虽然该技术用于跨线程通信可行,但这样做是非常不鼓励的,因为它会导致资源过度开销。分布式对象更适合于与其他进程进行通信,其中进程间的开销已经很高了。

线程开发技巧

下面的部分提供指导,以帮助你用正确的代码实现线程编程,并让你的线程代码实现更好的性能。正如任何性能优化一样,你总应该在收集相关的性能统计数据之前,期间和之后,再对代码进行优化。

避免显式创建线程

手动编写线程创建代码是冗长的,而且有可能出现错误,你应该尽可能的避免它。OS X和iOS通过其他API提供隐式支持的并发。相较于自己创建线程,可以考虑使用异步APIGCD,或操作对象来完成工作。这些技术为你的代码在底层做线程相关的工作,并保证其正确性。此外,GCD操作对象能够根据当前系统的负载调节活动线程的数量,这比你自己的代码管理线程更加有效。

保证线程合理地忙

如果你决定手动创建和管理线程,请记住线程会占用宝贵的系统资源。你应该尽你最大的努力确保分配给线程的任务是合理的长期且高效。同时,你不应该害怕终止大部分时间都是处于空闲的线程。线程消耗不少的内存,一些还是线性的(一定时间内不能交换到磁盘),所以释放空闲线程不仅有助于减少应用程序的内存占用,也释放更多的物理内存供系统其他进程使用。

注意:在终止空闲线程之前,你应该经常记录一组应用程序当前性能的基准测量值。尝试更改后,使用额外的测量来验证这些更改实际上提高了多少性能,而不是直接终止线程。

避免线程共享数据

避免线程相关的资源冲突最最简单的方法是给每个线程在程序中它自己需要的任何数据的副本。当你最大限度地减少线程间的通信和资源冲突时,并行代码就可以良好运行了。

创建多线程应用程序异常困难。即便你很小心地在代码中所有正确的时刻锁定共享数据,代码仍然可能处于不安全的状态。例如,你的代码可以解决问题假如在共享数据以特定的顺序进行修改。将代码更改为基于事务的模型可能随后会抵消多线程的性能优势。消除资源争用是首先要解决的问题,一个简单的设计常常会带来优异的性能。

线程与用户界面

如果你的应用程序具有图形化的用户界面,建议在应用主线程接收用户相关事件和界面更新。此方法有助于避免与处理用户事件和绘图窗口内容相关联的同步问题。某些框架如Cocoa,一般都要求这样做,但即使对于那些不这样要求,保持这种在主线程上的方式会有助于你简化用户界面逻辑。

有几个显著的例外情况,在辅助线程上完成图形化操作将会有巨大的性能优势。例如,你可以使用辅助线程来创建和处理图像并进行其他图像相关的计算,这可以大大提高性能。如果你不确定某个特定的图形化操作,可以计划在主线程上进行。

注意线程退出时行为

一个进程会运行直到所有的非分离(合并)线程退出。默认情况下,只有应用程序的主线程被创建为非分离的,但你也可以用同样的方式创建其他线程。当用户退出应用程序时,立即终止所有分离线程被认为是最恰当的行为,因为分离线程上的工作是可选的。如果你的应用程序是使用后台线程来保存数据到磁盘或做其他的关键工作,你需要创建非分离线程以防止应用程序退出时数据丢失。

创建非分离线程需要额外的工作。因为最高级线程技术默认不创建可连接线程,可以使用POSIX API来创建它。此外,当它们最终退出时,必须添加代码将其合并到主线程中。

如果你正在编写一个Cocoa应用,你还可以使用applicationShouldTerminate:委托回调方法在应用完全终止前延迟终止。当延迟终止时,应用程序将等到任何关键线程已经完成了它们的任务,然后才调用replyToApplicationShouldTerminate:方法。

异常处理

异常处理机制依赖于当前调用堆栈,以在异常抛出时执行任何必要的清理。由于每个线程都有自己的调用堆栈,所以每个线程只负责捕捉自己的异常。同时未能在辅助线程和主线程中捕获异常则说明该进程终止了。你不能将未捕获的异常抛给同一进程中的不同线程。

如果需要通知另一个线程(如主线程)在当前线程中的异常情况,你应该捕获异常并简单地发送消息到另一个线程以说明发生了什么。根据你的需求,捕获异常的线程可以等待指令继续执行(如果可能的话),或者干脆退出。

注意:在Cocoa中,NSException对象作为一个独立的对象被捕获后可以在线程中传递。

在某些情况下,异常处理回调会自动创建。例如,@synchronized代码块在Objective-C中就包含隐式的异常处理回调。

干净利落地终止线程

线程退出的最佳途径是让其自然地到达它的主入口路径结束。尽管有立即终止线程的函数,但这些函数应该只作为最后手段使用。不建议在线程达到了它的自然终点时提前终止线程。如果线程已分配内存,打开了文件,或获得其他类型的资源,这样做可能无法回收这些资源,导致内存泄漏或其他潜在问题。

库的线程安全

应用开发者已经掌握了应用中是否使用多线程,然而库开发人员却不一定。当开发库时,你必须假定调用应用程序是多线程的,或者可以随时切换到多线程。因此,你应该经常为关键部分代码上锁。

对于库开发人员而言,只有当应用程序成为多线程时才创建锁是不明智的。如果你需要在某个时候锁定代码,在早期的库中创建一个锁对象,最好是在一个显式调用库中进行初始化。虽然你也可以使用静态库的初始化函数来创建这样的锁,但只有当没有其他方法时,才可以尝试这样做。执行一个初始化函数增加了加载库所需的时间,并可能对性能产生不利影响。

注意:永远记住在你的库中锁定解锁操作的平衡。你还应该记住为库的数据上锁,而不是依赖于调用代码来提供一个线程安全的环境。

如果你正在开发一个Cocoa库,可以注册一个接收NSWillBecomeMultiThreadedNotification的观察者,以在应用成为多线程时得到通知。然而你不应该依赖于此通知,因为它可能会在你的库代码被调用之前就分发出了。

线程管理

OS X和iOS中每个进程(应用程序)是由一个或多个线程组成,每个线程通过代码表示一个独立的执行路径。每个应用程序都以单个线程开始,它运行应用程序的main函数。应用程序可以产生附加的线程,每个线程都执行特定功能的代码。

当应用程序启动一个新线程,该线程在应用程序的进程空间成为一个独立的实体。每一个线程都有自己的执行堆栈,并由内核单独调度运行。一个线程可以与其他的线程和其他进程进行通信,执行I/O操作,以及做其他你可能需要它做的事情。因为它们处于相同的进程空间内,所有线程在应用中共享相同的虚拟内存空间,并具有和进程相同的访问权限。

本章概述了在OS X和iOS中的线程技术,可随着例子说明如何在应用中使用这些技术。

资源消耗

线程会在内存使用和性能方面对你的程序(和系统)产生消耗。每个线程都需要内核内存空间和程序内存空间中的内存分配。管理和协调调度线程的核心结构由线性内存存储在内核中。线程的堆栈空间和每个线程数据存储在程序的内存空间中。当你首次创建线程时这些结构被创建和初始化,由于需要和内核进行交互这次资源消耗相对昂贵。

表2-1量化了创建应用程序中一个新用户级线程的大致消耗。其中一些指标是可配置的,比如为辅助线程分配的堆栈空间的量。创建一个线程的时间成本是一个粗略的近似,应该只用于彼此间相对比较。线程创建时间会根据处理器的负载、计算机的速度和可用的系统和程序存储器的数量而发生变化。

表2-1 线程创建消耗

指标 近似消耗 描述
内核数据(Kernel data structures) 大约1KB 该部分内存用于存储线程基本数据结构和属性,且大部分属于线性内存因此它不能被交换到磁盘上去。
堆栈空间(Stack space) 512KB(辅助线程)、8MB(OS X主线程)、1MB(iOS主线程) 允许的最小堆栈大小为16KB,辅助线程的堆栈大小是4KB的倍数。在线程创建时,该内存的空间被放置在你的进程空间中,但与该内存相关联的实际页面直到线程被需要时才创建。
创建用时(Creation time) 大约90微秒 这个值反映了初始调用创建线程和线程的切入点开始执行时之间的时间。测定数据基于英特尔的iMac/2 GHz双核处理器/1 GB RAM/OS X 10.5,通过分析平均值和中位数的过程中产生的线程创建。

注意:由于底层内核的支持,操作对象通常可以更快地创建线程。并非每次都从头开始创建线程,它们使用在内核中已驻留的线程池以节省分配时间。

另一个需要考虑的是写线程代码的生产成本。设计一个线程应用程序,有时可能需要对你的应用程序的数据结构组织方式的根本变化。这些变化可能是必要的,以避免使用时同步,这本身就可以对设计简单的应用产生时间成本消耗。设计这些数据结构,并在线程代码中调试问题,会增加开发一个线程应用程序所需要的时间。如果你的线程花太多时间等待锁或不做任何事,在运行时会产生更大的问题。

线程创建

创建低级别线程相对简单。在所有情况下,你必须有一个函数或方法来充当线程的主要入口点,并且必须使用一个可用的线程例程来启动你的线程。下面的部分显示了更为常用的线程技术的基本创建过程。使用这些技术创建的线程继承了默认的属性集取决于所使用的技术。

使用NSThread

有两种使用NSThread来创建线程的方式:

  • 使用类方法detachNewThreadSelector:toTarget:withObject:来产生新线程。
  • 创建一个NSTread对象并调用其start方法。(iOS及OS X 10.5后支持)

两种方式都会在应用中创建一个分离线程分离线程意味着该线程的资源会被系统自动回收,即便在线程存在的情况下。这也意味着该线程上的代码不能显式地合并到其他线程上去。

由于OS X所有版本均支持detachNewThreadSelector:toTarget:withObject:方法,所以该方法在Cocoa线程应用中十分常见。创建一个新的分离线程时,你仅需提供一个方法(具体为一个选择器)作为线程执行的切入点,定义该方法的对象,以及任何你想在线程启动时传递的数据。下面的代码示例将展示使用该方法来为当前对象的自定义方法完成线程的创建。

[NSThread detachNewThreadSelector:@selector(myThreadMainMethod:)
                         toTarget:self
                       withObject:nil];

在OS X 10.5之前,主要使用NSThread类产生线程。尽管会得到一个NSThread对象和访问一些线程属性,这只能在线程本身运行之后才行。在OS X 10.5,支持添加创建NSThread对象没有立即产生相应的新线程。(iOS同样支持)这使得在启动线程之前可以获取和设置不同的线程属性,也使得能使用该线程的引用对象来稍后启动线程。

在OS X 10.5及之后版本有一种初始化NSThread对象的简单方法,即使用initWithTarget:selector:object:方法。该方法和detachNewThreadSelector:toTarget:withObject:方法一样,可使用它来初始化一个新的NSThread实例。然而,它并不启动线程。要启动线程,需要显式调用线程对象的start方法,如下面的示例所示:

NSThread* myThread = [[NSThread alloc] initWithTarget:self
                                             selector:@selector(myThreadMainMethod:)
                                               object:nil];

[myThread start];  // 线程实际创建

注意:使用initWithTarget:selector:object:方法是子类化NSThread并重写其main方法。最好使用重写版本的这个方法实现线程的主入口点。

如果有一个NSThread对象的线程正在运行,应用中绝大多数对象可以使用performSelector:onThread:withObject:waitUntilDone:方法来向该线程发送消息。OS X 10.5引入的在除主线程外上执行选择器的支持是线程之间一种便捷的通信方式。(iOS同样支持)使用该技术发送的信息直接由其他线程处于正常run loop时处理。(当然,这并不意味着目标线程必须运行在自身的run loop中)。当以这种方式通信时,仍然需要某种同步形式,但这样做比设置线程间的通信端口更简单。

注意:虽然在线程间通信时偶尔使用这种方式还行,但在时间敏感或线程间通信频繁时最好不要使用performSelector:onThread:withObject:waitUntilDone:方法。

使用POSIX线程

OS X和iOS支持使用基于C语言的POSIX线程API来创建线程。这项技术实际能够被用于任何类型的应用(包括Cocoa和Cocoa Touch应用)以及对你编写跨平台的应用大有裨益。POSIX中创建线程的入口叫做pthread_create

代码2-1展示了用POSIX调用完成线程创建的两个自定义函数。LaunchThread函数创建了一个主入口由PosixThreadMainRoutine函数实现的线程。由于POSIX方式创建的线程默认是可合并的,本例中创建了一个分离线程。将线程标记为可分离使得线程退出时其资源会迅速被系统回收。

代码2-1 C语言线程创建

#include <assert.h>
#include <pthread.h>


void* PosixThreadMainRoutine(void* data)
{
    // 完成某些工作
    return NULL;
}

void LaunchThread()
{
        // 使用POSIX例程创建线程
        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)
        {
            // 记录错误
        }
}

如果将上述代码加入你的源文件并完成对LaunchThread函数的调用,这会在你的应用中创建一个新的分离线程。显然,使用该代码创建的线程并不完成任何有意义的工作。线程在启动之后几乎很快就会退出。为了使事情更加有趣,你可以在代码中为PosixThreadMainRoutine函数添加实质性的工作。此外,你还可以在创建时向函数指针传递指针数据,该指针作为pthread_create函数最后一个参数传入。

为了让新创建的线程与应用的主线程交流信息,你必须在目标线程间创建通信路径。基于C语言的应用程序,有几个线程间通信的方式,包括端口、条件锁或共享内存。对于长生命周期的线程来讲,你通常应该设置某些线程内部通信机制以使应用的主线程能够在应用退出时检测其他线程的状态或者干净地结束它们。

使用NSObject产生线程

在iOS及OS X 10.5之后,所有对象都能够产生新的线程并且将其用于执行它们的方法。performSelectorInBackground:withObject:方法会创建新的分离线程并且使用具体的方法作为新线程的切入点。例如,如果有某个对象(用变量myObj表示)以及对象有一个需要在后台运行的方法名叫doSomething,可以使用如下代码来完成:

[myObj performSelectorInBackground:@selector(doSomething) withObject:nil];

这样做的效果与调用NSThread的detachNewThreadSelector:toTarget:withObject:方法,辅以传递当前对象、选择器加上参数对象的方式一样。新的线程生成方式会立即以默认配置生成线程并立即启动。在选择器内部,你可以像配置其他的线程一样的配置该线程。例如,你可以按照需要创建一个自动释放池(如果你不使用垃圾回收机制)并在想要使用时配置该线程的run loop。

Cocoa应用中使用POSIX线程

尽管NSThread是Cocoa应用中�创建线程的主要接口,如果方便你反而尽可以使用POSIX线程。例如,你会在已经使用POSIX线程的代码基础上继续使用而不是重写这部分代码。如果你打算在Cocoa应用中使用POSIX线程,你仍需清楚Cocoa与线程的交互以及遵循以下部分建议。

保护Cocoa框架

对于多线程应用,Cocoa框架会使用锁及其他同步机制来保证正确的行为。为了防止锁导致单线程情况下性能降低的问题,Cocoa使用NSThread产生新线程时并没有创建锁。如果你仅使用POSIX例程来创建线程,Cocoa框架并没有收到你的应用变为多线程的通知。如此,涉及到Cocoa框架的操作将会变得不稳定甚至导致应用崩溃。

为了让Cocoa知道你的应用即将使用多线程,你需要做的是使用NSThread产生一个线程并使其立即退出。线程的切入点里并不做任何工作。然而,仅仅这样的行为就足以让Cocoa在需要的地方加锁。

如果你不确定Cocoa是否认为你的应用处于多线程状态,你可以使用NSThread的isMultiThreaded方法检测。

混用POSIX和Cocoa锁

在同一应用中混用POSIX和Cocoa锁是安全的。Cocoa的锁和条件对象本质上是POSIX上的一层简单封装。然而,对于既定的锁,你必须总是使用同一接口来创建和控制该锁。换句话说,你不能用Cocoa的NSLock对象控制一个由pthread_mutex_init函数创建的锁来完成互斥操作,反之亦然。

线程配置

在线程创建完成之后,你也许想配置些不同的线程环境。下面章节将描述一些能够做以及何时做出这些修改的建议。

配置线程堆栈大小

每一个新创建好的线程,系统会在进程空间中分配具体大小的内存作为该线程的堆栈。堆栈管理着堆栈片以及线程声明的任何本地变量。这部分为线程分配的内存叫做线程消耗。

如果想改变给定线程的堆栈大小,在线程创建之前你必须这样做。所有基于线程的技术都会提供一些方法来设置堆栈大小,尽管只允许在iOS和OS X 10.5及之后版本使用NSThread的方式来设置。表2-2列出了每个技术的不同配置选项。

表2-2 设置线程堆栈大小

技术 选项
Cocoa 在iOS和OS X 10.5及之后版本,创建并初始化NSThread对象(不使用detachNewThreadSelector:toTarget:withObject:方法)。在调用线程对象的start方法之前,使用setStackSize:来指定堆栈大小。
POSIX 创建pthread_attr_t结构体并调用pthread_attr_setstacksize函数来改变默认堆栈大小。最后将该属性结构体传递给pthread_create函数以创建线程。
多进程服务(Multiprocessing Services) 在创建线程时传递合适的线程大小给MPCreateTask函数。
配置线程级储存

每个线程维护着一个在线程中可以访问的键值对字典。你可以使用该字典来存储在整个线程执行阶段的数据。例如,你可以存储与线程run loop交互的状态信息。

Cocoa和POSIX使用不同的方式存储该线程字典,所以你不能混用这两种技术。只要在线程代码中坚持使用其中一种技术,后期的方式也应该相同。在Cocoa中,可以使用NSThread的threadDictionary方法获取到一个NSMutableDictionary对象,在里面可以随意添加线程需要的键值。在POSIX中,可以使用pthread_setspecificpthread_getspecific函数对线程的键值进行设置和获取操作。

设置独立线程状态

大部分的高等级线程技术默认会创建分离线程。多数情况下,分离线程更受青睐的原因是当线程周期结束后系统可以立即回收线程持有的数据。分离线程同样不需要显式地和应用进行交互。这意味着从线程中获取的结果可以交由自己处理。相比较而言,系统不会回收合并线程的资源直到其他线程显式地和该线程进行合并时,此时进程会阻塞线程以完成合并。

你可以将合并线程理解为子线程。尽管它们仍作为独立的线程,合并线程必须与其他线程合并其资源才能被系统回收。合并线程同样提供线程间显式传递数据的方式。在其退出之前,合并线程可以传递一个数据指针或者其他返回类型给pthread_exit函数,其他线程可以通过调用pthread_join函数来获得该数据。

注意:在应用退出时,分离线程会立即终止而合并线程却不是。每个合并线程必须在进程允许其退出前完成合并操作。因此合并线程常用于关键且不被打断的工作,如保存数据到磁盘。

如果你想创建合并线程,你只能使用POSIX线程来完成该操作。POSIX默认创建合并线程为了标记线程是可分离或者可合并的,在创建线程前需使用pthread_attr_setdetachstate函数修改其线程属性。在线程开始后,可以使用pthread_detach函数将合并线程改变成分离线程

设置线程优先级

任何新创建的线程都有一个默认的优先级与其关联。内核的调度算法会根据线程的优先级来决定线程的运行顺序,高优先级的线程比低优先级的线程更容易被调度执行。高优先级并不保证线程具体的执行时间,仅仅意味着它相对于低优先级线程更容易被调度器选择。

注意:最好的建议是保持各自线程默认的优先级。提高某些线程的优先级也可能会增加一些低优先级线程的饥饿程度。如果你的应用存在一个高优先级线程和一个低优先级线程进行交互,低优先级线程的“饥饿”会阻塞其他线程并造成性能瓶颈。

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

编写线程入口

对于大多数情况,在OS X以及其他平台上线程的入口部分结构大致相同。你会初始化数据结构,布置一些工作或者选择性地配置run loop,然后在线程代码完成后做清理工作。取决于你的设计,你需要在线程的入口点做些额外的工作。

创建自动释放池

由Objective-C框架链接的应用通常需要为其线程创建至少一个自动释放池。如果应用使用管理内存方式(MRC和ARC),自动释放池会捕获任何在线程中标记为可自动释放的对象。

如果应用使用垃圾回收(GC)而不是管理内存,自动释放池并不是严格意义上的需要。自动释放池对于垃圾回收机制下的应用并无害处,且多数情况下它会被忽略。代码模块同时支持管理内存和垃圾回收是能够被允许的。在这种情况下,自动释放池必须支持管理内存方式而在垃圾回收允许时会被忽略。

如果你的应用使用管理内存方式,创建一个自动释放池是作为线程入口点的首要任务。同理,销毁自动释放池则是线程中最后需要完成的事情。自动释放池会确保需要自动释放的对象被捕获,尽管它直到线程退出才会释放它们。代码2-2展示了使用自动释放池的基本线程代码结构。

代码2-2 定义线程入口点

- (void)myThreadMainRoutine
{
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; // Top-level池
    
    // 完成线程工作
    
    [pool release];  // 释放池中对象
}

由于top-level的自动释放池直到线程退出才释放其中对象,长时间运行的线程需要创建额外的释放池来更加频繁地释放对象。例如,配置run loop的线程会在每一个run loop周期完成自动释放池的创建和释放。更加频繁地释放对象以防止应用内存消耗暴增。和任何性能相关的问题一样,你都应该在实际测量代码性能之后再正确地使用自动释放池。

创建异常处理回调

如果你的应用要捕获和处理异常,那么你的线程代码应该准备好捕获任何可能产生的异常情况。尽管处理异常的最佳地点是在其可能发生的地方,捕获异常失败会导致应用的退出。在线程入口代码中加入try/catch快可以让你捕获任何未知的异常并提供正确的处理方式。

在Xcode创建的项目中你可以使用C++或者Objective-C的风格的异常处理方式。

创建Run Loop

编写代码时想运行在单独的线程上时,有两种选择。第一种选择是为线程编写一个尽可能长的且很少中断的任务,当任务完成时线程自然会退出。第二种是将线程放进一个run loop中以在请求到达时动态地处理。第一种选择不需要代码中特殊的设置,你只需直接开始你要完成的工作。然而第二种选择,需要对线程的run loop做额外设置。

OS X和iOS对每个线程的的run loop实现提供了内建支持。应用框架会自动地为主线程开启run loop。如果你创建了一个辅助线程,你必须配置run loop并且手动启动它。

终止线程

退出一个线程的推荐方式是让其正常地退出它的入口点。尽管Cocoa、POSIX以及多进程服务提供了直接杀线程的方法,但不鼓励使用这些方法。杀死线程阻止了线程的自我清理功能。分配给线程的内存可能会泄漏以及其他正在被线程使用的资源得不到正确的清理,随之而来的是潜在的问题发生。

如果你预先要在线程执行的过程中终止线程,你应该为线程设计一套响应取消和退出信息的操作。对于长周期操作,这会意味着周期性地停止工作以检查消息是否到达。如果要求线程退出的消息到达,线程才有时间执行清理工作并优雅地退出;反之,它会继续回到工作中并等待下一次消息的到来。

使用run loop的输入源可以响应取消操作消息及其类似消息。代码2-3展示了线程中主入口的相关操作。该示例代码为run loop配置了一个接受其他线程可能发送消息的输入源。在完成部分任务之后,线程会进入run loop以查看输入源中的信息是否到达。如果没有,run loop立即退出并进入下一个工作周期。由于回调并不直接访问exitNow变量,退出条件通过线程的键值字典获取。

代码2-3 在长周期任务中检测退出条件

- (void)threadMainRoutine
{
    BOOL moreWorkToDo = YES;
    BOOL exitNow = NO;
    NSRunLoop* runLoop = [NSRunLoop currentRunLoop];
    
    // thread-local加入exitNow布尔类型变量
    NSMutableDictionary* threadDict = [[NSThread currentThread] threadDictionary];
    [threadDict setValue:[NSNumber numberWithBool:exitNow] forKey:@"ThreadShouldExitNow"];
    
    // 配置自定义输入源
    [self myInstallCustomInputSource];
    
    while (moreWorkToDo && !exitNow)
    {
        // 完成工作

        // 工作完成后修改moreWorkToDo标志

        // 如果输入源并未到达则run loop超时直接运行
        [runLoop runUntilDate:[NSDate date]];

        // 检测输入源回调并修改exitNow值
        exitNow = [[threadDict valueForKey:@"ThreadShouldExitNow"] boolValue];
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容