注:本文翻译自About Threaded Programming
关于线程的编程
多年来,几乎所有的电脑性能都被单核处理器的运算速度限制地非常严重。当单个处理器的运行速度达到瓶颈之后,芯片就切换到了多核设计,这就为电脑提供了可以同时执行多个任务的机会。虽然OS X在执行系统相关的任务时已经利用好了多核心处理器,但你的应用也需要通过线程来利用好它们。
什么是线程?
线程是一种在应用程序中相对轻量级地实现多条路径执行指令的方式。在系统层面,所有程序一个接一个地执行,系统会根据需要为每一个程序腾出执行时间并同时满足其他程序。但是,在每一个程序内部,存在着一个或多个线程,它们被用于同时或几乎同时去执行多个任务。实际上系统自身管理着这些线程,根据需要调度它们到可用核心中或主动打断它们的执行来允许其他线程执行。
从技术的角度来看,一个线程就是一个核心级和应用级数据结构的组合用以管理代码的执行。核心级结构将事件派发给指定线程并使用此线程抢占可用核心。应用级结构包含了用于存储函数调用的调用栈和应用程序需要的数据结构,用它来管理并控制线程的属性和状态。
在一个非并发的应用中,只有唯一一个线程在执行。此线程随着应用的主程序从开始到结束,一个接一个地从不同的方法或函数中切换分支,来实现应用的所有行为。作为对比差异,支持并发的应用以一个线程开始,之后根据需要添加更多的线程来创建额外的执行路径。每一个新路径都有它自己的开始程序,且此程序与应用的主程序相对独立。在应用中包含多个线程可以提供两个非常重要的优点:
- 多线程可以提升应用的响应性。
- 多线程可以在多核心系统中提高应用的实时性能。
如果你的应用只有一个线程,此线程就必须做每一件事。它必须响应事件、更新应用的窗口并执行所有需要的运算来实现应用的行为。但只有一个线程的问题在于同一时刻只能做一件事。所以当一个计算任务花费时间过长会发生什么?当你的代码正在忙于计算需要的值时,你的应用会停止响应用户的事件并停止更新窗口。如果这个行为持续太长时间,用户可能就认为你的应用已经卡死并试图强退了。如果你把这个自定的计算任务放到一个单独线程中去执行,应用的主线程就会被释放以一个比较实时地状态来响应用户的交互。
随着多核心电脑普及的这些日子,线程提供了一种方式为某些类型的应用提升了性能。执行不同任务的线程可以在不同的处理器核心上同时执行,使一个应用在指定时间内完成更多任务成为了可能。
当然,线程也不是解决应用性能问题的万能药。随着线程带来的性能提升也带来了一些潜在问题。在应用中的多个执行路径会让你的代码更加复杂。每个线程都要与其他线程定位好对应的行为来防止应用的状态信息被搞乱。因为在一个应用中的所有线程会在同一时间共享相同的内存空间,一个线程可能会覆盖了其他线程的修改最终导致了结果数据的混乱。虽然在指定位置进行了适当保护,你还是需要警惕编译器对代码的优化可能引入的微小概率的bug。
线程术语
在深入讨论线程之前,关于线程和支持的技术,有必要定义一些基本的术语。
如果你比较熟悉UNIX系统,你可能会发现“任务”这个术语在此文档中用于不同的目的。在UNIX系统中,“任务”被用于指代一个正在执行的进程。
本文中使用了如下术语:
- 线程 被用于指代一个执行代码的单独路径。
- 进程 被用于指代一个正在执行的,包含多个线程的可执行程序。
- 任务 被用于指代一个需要被执行的工作的抽象概念。
线程的替代方案
自己创建线程的一个问题是它们会给你的代码带来不确定性。线程是一种的相对底层且比较复杂的方式来为应用添加并发能力。如果你没有完全理解你定义的选择方式,你可能会很容易就遇到同步或者时间上的问题,这几种问题可以将微小的修改变为应用程序的崩溃或者用户数据的混乱。
另一个考虑的因素就是你是否真的需要多线程或者并发功能。线程可以解决指定的问题,如在一个进程中同时执行多个路径的代码。也许存在一些情况,比如,大多情况下,你所执行的工作并不是并发的。线程会给你的进程引入大量的额外工作,包括内存占用和CPU时间占用。你要考虑好对于本来任务引入的这些额外工作,或者是否存在其他方式可以更容易地实现。
表1-1列出了一些对于线程的替代方案。这个表不仅包含了对于多线程的替代技术(如operation对象和GCD),还包含了使用单线程也能提高效率的替代方案。
技术 | 描述 |
---|---|
Operation对象 | OS X v10.5引入,一个operation对象是一个任务的封装,可以在次要线程中执行。此封装隐藏了执行任务时的线程管理方面,让你专注于任务自身。你只需要结合着一个操作队列对象使用operation对象,这个操作队列会在一个或多个线程中管理着operation的执行。 (查看并发编程指南以学习如何使用) |
大中枢派发(GCD) | OS X v10.6引入,大中枢派发是另一个线程的替代方案,可以让你专注于需要的任务而不是线程的管理。使用GCD,你定义好需要执行的任务并将其添加到工作队列中,此队列负责将你的任务调度到合适的线程中。工作队列专注于可用核心数和当前加载并执行的任务,这比你自己使用线程进行处理要更加高效。 (查看并发编程指南以学习如何使用) |
闲时通知(Idle-time notifications) | 对于那些相对短时间且低优先级的任务来说,闲时通知可以让你在应用不忙时执行任务。Cocoa提供了NSNotificationQueue对象来支持闲时通知。要要请求一个闲时通知,使用NSPostWhenIdle参数,发送一个notification给默认的NSNotificationQueue对象。直到运行循环(run loop)空闲时,队列则传递你的notification对象。 (查看通知编程指南以获取更多信息) |
异步函数 | 系统接口中包含了许多异步函数可以提供自动并发功能。这些API可以用于系统进程或者创建自定义线程来执行任务并返回结果给你。(实际实现无关紧要,因为它们与你的代码是分隔开的。)在你开发应用时,查找一下提供异步功能的函数并考虑使用它们,从而避免在一个自定义线程中使用等价的同步函数。 |
定时器 | 在任务没有重要到需要创建线程时,你可以在应用的主线程中使用定时器来执行周期性的任务,但是定时器依然需要遵循正常的间隔。 (查看定时器资源来获取更多信息) |
独立进程 | 虽然要比线程更重,但是当指定任务与你的应用基本无关时,创建一个独立进程也许更有用。如果一个任务需要分配一块重要的内存或者必须使用根权限执行时,你可以使用一个进程来实现。举例来说,你可以使用一个64位的服务器进程来计算大数据集,而使用一个32位的应用将结果显示给用户。 |
线程支持
如果你有使用线程的现有代码,在你的应用中,OS X和iOS已经提供了几种技术来创建线程。此外,此两种系统都提供了对于在多线程中进行管理和同步的功能。下面的小结描述了一些关键技术,在OS X和iOS中使用线程时,这些技术你都需要了解。
线程的封装
虽然对于线程来说,底层的实现机制是Mach线程,你几乎不会在Mach层级去使用线程。相反,你经常使用更方便的POSIX API或者是派生子对象。可是,Mach的实现确实提供了所有线程的基本特性,包括主动抢占式执行模型和调度线程的能力,因此他们彼此独立。
表1-2列出了你可以在应用中使用的线程技术。
技术 | 描述 |
---|---|
Cocoa线程 | Cocoa使用NSThread类实现了线程。Cocoa还在NSObject中提供了创建新线程的功能和在已经运行的线程上执行代码的功能。(更多信息请查看使用NSThread和使用NSObject创建一个新线程) |
POSIX线程 | POSIX线程提供了一个基于C的接口来创建线程。如果你没有编写Cocoa应用,这就是创建线程的最好选择。对于线程的配置,POSIX接口相对简单易用且提供了足够的扩展性。(更多信息请查看使用POSIX线程) |
多进程服务 | 多进程服务是从老版本Mac OS中转换应用时遗留下的基于C的接口。这项技术只在OS X中可用且在新的开发中应当避免使用。作为替代,你应该使用NSThread类或是POSIX线程。(更多信息请查看多核心服务编程指南) |
在应用程序级来看,所有线程的行为看起来就和在其他平台上看起来一样。在开启一个线程之后,线程就会处于三种状态:运行态、就绪态和阻塞态。如果一个线程当前没处在运行态,那么它或者是被阻塞了正在等待输入,或者是准备好运行但是还没有被调度。线程会继续返回并执行第四个状态(就是循环会开始状态)直到它最终退出并且变为终止状态。
当你创建一个新线程时,你必须为线程指定一个入口点函数(在Cocoa的线程中为入口点方法)。此入口点函数组成了你要在线程中执行的代码。当函数返回时,或者当你主动结束此线程时,线程会永久停止且会被系统回收。由于线程的创建在内存和时间上的开销比较大,因此建议让你的入口点函数做一些重要的工作或者配置一个运行循环以便可以反复执行工作。
查看更多关于可用线程技术和如何使用的相关信息,请查看线程管理。
运行循环
运行循环是用于在线程中处理异步接收事件的一种基础设施。一个线程指定的运行循环通过监控一个或多个事件源进行工作。一旦收到事件,系统就会唤醒线程并把事件派发给运行循环,之后此运行循环就会将它发送给你指定的处理对象。如果当前没有事件出现且准备好要处理,运行循环就会使线程休眠。
你无需使用一个带有你创建线程的运行循环,但是这样做可以给用户带来更好的体验。运行循环可以让使用最小内存占用来创建长期生存的线程成为可能。因为运行循环可以在没事可做时让线程进入休眠,它排除了需要轮询的可能,因为轮询会浪费CPU资源且阻止处理器自身进入休眠状态,省电。
要配置一个运行循环,所有你需要做的就是启动一个线程,获取一个运行循环对象的索引,安装你的事件处理器,并告知运行循环启动运行。OS X系统自动为你配置了主线程的运行循环。可是,如果你想要创建一个长期生存的子线程,你就必须要自己配置那些线程的运行循环。
有关运行循环的详细信息和使用方式在运行循环中提供说明。
同步工具
多线程编程中的其中一个危险点就是多个线程间的资源竞争。如果多个线程在同一时间试图使用或修改相同资源,问题就可能发生。减轻此问题的一种方式就是尽量避免共享资源并且确保每个线程都有自己的资源集合并在里面进行操作。当处理完全独立的资源不是一个可选项时,你可能必须通过锁、条件、原子操作或者其他技术来同步访问资源。
锁通过一种简单粗暴地保护代码的方式来使每次同时只能有一个线程可以被执行。最普通的锁的类型就是互斥锁,也被叫做mutex。当一个线程试图获取一个已经被其他线程保留的互斥锁时,此线程会被阻塞直到其他线程释放此锁。好几个系统框架提供了对于互斥锁的支持,虽然它们都基于同样的底层技术。此外,Cocoa还提供了多种互斥锁的变体以支持不同种类的行为,比如递归。要获取更多关于可用类型锁的相关信息,请查看锁。
除了锁以外,系统还提供了条件支持,条件对象可以确保在你的应用中的任务以指定的顺序执行。一个条件对象扮演了门卫的角色,阻塞指定线程直到它设置的条件变为真。当此情况发生时,条件对象释放此线程并允许它继续执行。POSIX层和Foundation框架都直接支持了条件对象。(如果你使用了operation对象,你可以在你的operation对象间设置依赖以使任务按顺序执行,这种行为与条件对象提供的行为非常相似。)
虽然在并发设计中,锁和条件对象非常常见,但原子操作也是保护并同步访问数据的另一种方式。在你需要对纯数据类型执行数学或逻辑运算时,原子操作是一种轻量级的锁的替代方案。原子操作使用特别的硬件指令来确保在其他线程有机会访问之前就对指定变量完成修改。
要查看关于可用的同步工具的更多信息,请查看同步工具。
线程间通信
虽然一个好的设计可以将必要的通信降为最少,但是,在某些点看来,线程间的通信变得很有必要。(一个线程的职责就是为你的应用工作,但是如果工作的结果从来不会使用,那又有什么用呢?)线程也许需要处理新的工作请求或者向你的应用的主线程汇报它们的工作进度。在这些情况下,你需要一种方式将一个线程的信息获取到另一个线程中。幸运的是,真相是所有线程都共享着相同的进程空间,这意味着你可以有许多种选择进行通信。
这里有许多种方式用于线程间通信,每一种都有自己的优点和缺点。配置线程局部存储(TLS)列举出了大多数的在OS X中可用的通信机制。(除了消息队列和Cocoa发布对象以外,这些技术都可以用于iOS中。)这些技术按照复杂性的增长顺序列举在了下表中。
机制 | 描述 |
---|---|
直接发送消息 | Cocoa应用支持直接在其他线程中执行选择器的能力。这项能力意味着一个线程在本质上可以在任意其它线程上执行方法。因为这些方法是在目标线程的上下文对象中执行,这种方式发送的消息可以在目标线程中自动序列化。要查看相关的输入资源信息,请查看Cocoa执行选择器资源。 |
全局变量、共享内存和对象 | 在两个线程间交换信息的另一种简单方法就是使用全局变量、共享对象或者共享的内存块。虽然共享变量又快又简单,但此方式相比直接发送消息更加脆弱。共享变量必须使用锁或者其它同步机制进行小心保护来确保代码的正确。忘记使用此方式会导致竞争条件、混乱的数据或者崩溃。 |
条件 | 当一个线程执行一部分指定代码时,条件是一种你可以用于控制使用的同步工具。你可以将其看做门卫,只有当条件状态满足时才会让线程执行。对于更多信息,请查看使用条件。 |
运行循环源 | 一个自定义的运行循环源是你在一个线程中用于接收指定的应用程序消息。由于是事件驱动,在无事可做时运行循环会让你的线程进入睡眠状态,这会提高线程效率。对于更多关于运行循环和运行循环源,请查看运行循环。 |
端口和套接字 | 基于端口的通信是一种两个线程间更加精细的通信方式,而且是一个更可靠的技术。更重要的是,端口和套接字可以用于和外部实体进行通信,如其它进程和服务。为了提升效率,端口使用运行循环源进行实现,因此在端口没有数据等待时,你的线程会进入休眠。对于更多关于运行循环和基于端口的输入源,请查看运行循环。 |
消息队列 | iOS不可用 |
Cocoa发布对象 | iOS不可用 |
设计技巧
下面几节提供了一些指导来帮助你以某种方式确保代码的正确性,进而提升线程的使用效果。某些指导还提供了一些关于使用你自己的线程代码来获得更好性能的相关技巧。就任何的性能技巧来说,你应该在每次修改代码之后,都要在执行之前、执行中和执行之后统计出相关的性能表现。
避免直接创建线程
是移动编写创建线程的代码是单调的并且容易有潜在的错误出现,因此可能的话尽量避免这样做。OS X和iOS在内部都通过其他API实现了并发支持。考虑使用异步API、GCD或者operation对象来完成工作,而不是自己创建线程。这些技术在幕后帮你完成了线程相关的工作且能够保证准确性。此外,如GCD和operation对象这种技术,在设计时就是为了在线程管理上比你自己的代码要更加高效(通过基于当前系统的状态自动调整可用线程的数量)。要了解更多关于GCD和operation对象的信息,查看并发编程指南。
保持线程合理地执行
如果你要要手动创建并管理线程,记住线程会消耗相当多的系统资源,你应该尽最大努力来确保交付给线程的任务要合理地生存并有产出。同时,你不要害怕终止那些在大多时间都处于空闲状态下的线程。线程占用了非同一般的内存大小,有些数据发生了错误,因此释放一个闲置线程不仅有助于降低应用的内存占用,还可以释放更多的物理内存以便为其他的系统进程所使用。
重点:在你准备终止空闲线程之前,你总是应当对应用的当前性能进行一系列的测试并记录。在完成修改之后,进行额外的测试来验证此修改确实提高了性能,而不是降低了。
避免共享数据
在程序中,最简单最容易去避免线程资源冲突的方式就是给每个线程都配备一份所需数据的拷贝。在你的线程间最小化通信和资源竞争时,平行无关的代码效果最好。
创建一个多线程的应用是很困难的。就算你非常认真并且在所有正确的时间点在代码中对共享数据进行加锁操作,你的代码可能在语义上依然是不安全的。比如,如果你希望共享数据按照指定顺序被修改,但是你的代码可能还会遇到问题。将代码修改为基于事务的模型作为补偿反而可能会抵消掉多线程的性能优势。将排除资源竞争作为第一要务可能会得到更简单的设计和更棒的性能。
线程和你的用户界面
如果你的应用包含用户图形界面,极力建议你将接收用户相关事件和进行界面更新放到应用的主线程中。这种做法有助于避免在处理用户事件和绘制界面相关内容时出现同步问题。一些框架,如Cocoa,一般都作为必要行为,但是对于那些即使没有这么做的框架,将这些行为放到主线程中对于管理你的UI界面也有简化逻辑的优势。
在其他线程中执行图形操作时,还是存在几个例外事件是有性能优势的。比如,你可以使用子线程来创建并处理图片或者是其他的图片相关的计算。对这些操作使用子线程可以有效地提高性能。如果你不确定某个指定的图形操作能否提高性能,就计划将它放到主线程中进行处理吧。
对于更多关于Cocoa的线程安全,请查看线程安全总结。对于更多的关于Cocoa的绘制信息,请查看Cocoa绘制指南。
在退出时注意线程行为
一个进程会持续运行直到所有的非分离线程(non-detatched thread)都退出为止。默认来说,只有应用的主线程是非分离的,但是你也可以按照这种方式来创建其他线程。当用户退出应用时,通常要做正确行为的就是直接终止所有的分离线程执行,因为分离线程默认将此工作(关闭应用时自动退出)作为了可选操作。如果你的应用正在使用后台线程向磁盘中保存数据或者做一些其他的重要工作,可是,你也许要要将那些线程创建为非分离的方式来防止应用退出时出现的数据丢失。
创建非分离的线程(也叫做可绑定线程)需要你做额外的工作。因为大多数的上层线程技术默认都不会创建这种可绑定线程,你需要不得不使用POSIX的API来创建线程。此外,你还必须在主线程中添加代码来绑定这个非分离线程,以便它们可以在最终自动退出。要了解更多信息,请查看设置分离状态的线程。
处理异常
当异常抛出时,异常处理机制依赖于当前的调用栈来执行所有必要的清理工作。因为每个线程都有自己的调用栈,因此每个线程都要负责捕获自己的异常。在子线程中没有捕获异常与在主线程中没有捕获异常的结果一样:自身进程被终止。你不能抛出一个未捕获的异常给其他线程去处理。
如果你需要在当前线程通知另一个线程(比如主线程)有异常情况发送,你需要捕获这个异常后,直接给那个线程发送消息并说明发生的状况。依照你的模型和你所做的工作,捕获到异常的线程可以在之后继续处理(如果可以)、等待指示或者直接退出。
注意:在Cocoa中,一个NSException对象是一个自我包含的对象,即一旦被捕获到,就可以从一个线程传递到另一个线程中(即NSException对象是线程安全的)。
在某些情况下,一个异常处理对象可能会是自动创建的。比如,@synchronized 指令在Objective-C中就包含一个隐含的异常处理对象。
干净地终止线程
退出线程的最好方式就是自然地,通过让它执行到主入口点程序的结尾。虽然也存在一些可以直接终止线程的函数,可那些函数应该只作为最后的办法来使用。在线程执行到自然结束之前终止它会阻止线程的自动清除功能。如果线程已经开辟了内存、打开了文件或者获取了其他类型的资源,你的代码也许不能再回收那些资源,导致了内存泄漏或者其他的潜在问题。
对于以适当的方式来退出线程的更多其他信息,请查看终止线程。