简介
线程是在一个程序中并发的执行代码的方法之一。虽然有一些新的技术(operations, GCD)提供了更先进高效的并发实现,OS
X和iOS同时也提供了创建和维护线程的接口。
这里将要介绍线程相关的包以及如何使用他们。同时也会介绍程序中多线程代码的同步。
关于线程开发
多年以来,电脑的最大处理速度受制于单个处理器的处理速度。当单核处理器开始达到他们的上线时,芯片市场转向了多核的设计,这样电脑就可以同时处理多个任务了。OS
X在处理系统任务时使用了这些优势,你自己的程序也可以使用这些优势。
什么是线程
线程是在程序中实现并发的相对轻量级的方式。在系统级别,程序都在运行,系统根据每个程序的需要来分配执行时间。在每个程序中,可以有一个或多个线程来执行,他们可以同时处理不同的任务。实际上是由系统来管理这些线程的计划,执行,中断等。
在技术的角度上来说,线程是执行代码所需的内核级别和程序级别的数据结构的组合。内核级的数据结构用来给线程分发事件以及把线程放到可用的cpu上。程序级的数据机构用来处理调用队列以及线程要用到的属性和状态。
在非并发的程序中,只有一个线程在执行。这个线程的开始和结束伴随着程序的main方法,在这期间一个接一个的执行实现的方法。相比之下,支持并发的程序由一个线程开始,然后在需要的时候创建新的线程。每个新线程有他们自己的开始并且独立于主线程之外运行。程序中的多线程有两个明显的优势:
- 多线程可以优化程序的相应时间
- 多线程可以优化程序在多核处理器上的性能。
如果你的程序只有一个线程,那么这个线程需要做所有的事。它要相应事件,更新窗口以及程序中实现的所有行为。单线程的问题是一次只能做一件事。所以如果有一件事要很长时间才能处理完呢?当代码在忙着计算需要的结果时,程序就停止了相应用户事件以及更新窗口。如果这个行为持续太长时间,用户可能会强制退出程序。如果把自定义的计算移到其他线程,这样程序的主线程就能自由的相应用户事件了。
随着多核处理器的流行,线程提供了一种优化性能的方式。线程可以同时在不同的核上处理不同任务,这样可以在给定的时间里做更多的事情。
不过,线程也不是优化性能的灵丹妙药。随着线程的好处带来的也有一些潜在问题。多线程会让你的代码变的复杂。每个线程都需要核其他线程合作来避免发生冲突。因为同一个程序中的线程分享同一片内存,他们可以访问相同的数据结构。如果两个线程同时在维护一个数据,一个线程可能会覆盖了另一个的数据这样可能会破坏数据结构。虽然有合适的保护机制,你也需要注意编译选项可能会给你带来微小(有可能不是那么微小)的bug
多线程的相似方案
自己创建线程的一个问题是增加了代码的不确定性。线程在程序中是一种相对底层核复杂的支持并发的方式。如果不完全理解,可能会碰到同步和时间的问题,可能会导致程序行为和预期的不一致,或者crash,或者破坏了用户的数据。
另外一个要考虑的问题是是否真的需要多线程和并发。有时候可能有很多事情要做但是也不一定需要多线程。多线程会加大进程的内存和CPU开销。可能会发现计划的任务开销太大或者有更好的其他选择来实现。
下面列出了一些多线程的相似方案。不仅有多线程多替换方案(比如operation和GCD)也有单个线程一些很有效的相似方案。
技术 | 描述 |
Operation |
Operation对象会封装一个在另一个线程上执行的任务。这个封装影藏了执行任务时的线程管理,让你可以专注于处理自己的事情。通常会和operation |
Grand Central Dispatch(GCD) |
GCD也是让你专注于自己事情的另一个替代方案。使用GCD的话,你只需要定义你需要执行的任务然后加到工作队列中,它就会自动在合适的线程上计划你的任务了。相对于自己使用多线程,工作队列会查看CPU的内核数以及负载来更效率的执行任务。 |
闲时消息 |
对于一些优先级相对较低的任务,闲时消息可以让程序在不忙的时候来执行。Cocoa使用NSNotificationQueue来支持闲时消息。如果需要闲时消息,向NSNotificationQueue对象发送一个带NSPostWhenIdle选项的消息。队列在空闲的时候会分发消息。 |
异步方法 |
系统提供了很多异步的方法来自动实现并发。这些API使用系统进程或者自定义线程来执行任务(实际的执行是由系统控制的)。当设计程序的时候,优先查看是否提供了这些异步方法。 |
定时器 |
可以使用定时器来处理一些琐碎的小事情,但是需要定期检查。 |
支持线程
OS X和iOS都有几种技术来支持多线程。另外他们都提供管理和同步线程的方法。下面会介绍一些使用多线程必须要知道的一些东西。
多线程技术
技术 | 描述 |
Cocoa threads |
Cocoa使用NSThread实现多线程。Cocoa在NSObject中也提供了产生新线程和在已有线程上执行代码的方法。 |
POSIX threads |
POSIX |
在应用级别看,
所有的线程在本质上是一样的。在线程开始之后,线程主要在三种状态:运行,准备,阻塞。如果线程没有运行,要么是阻塞了等待输入,或者是已经准备运行但是还没被调度。线程不断在这几种状态间切换知道他们运行完。
创建一个新线程时,必须要制定入口方法。入口方法种包含你要在线程种运行的程序。当方法返回,或者你明确的终止了,这个线程就永久的停止了并且系统会回收它。因为多线程相对是很消耗内存和CPU的,所以尽量让入口方法处理的事情很明确或者设置一个run
loop来处理周期性的任务。
Run Loops
run loop是线程异步接受消息的基础设施。run
loop会检测线程的一个或多个事件。当事件触发后,系统会唤醒线程然后把事件分发给run
loop,然后它会把事件分发给你指定的处理程序。如果没有事件要处理,run loop会让线程挂起。
并不需要为每个线程创建一个run loop,但是创建一个可以有更好的用户体验。Run
loop可以让线程使用最少的资源长期存活着。因为没事做的时候run
loop让线程挂起,这样省去了轮询的过程。轮询很浪费CPU并且会阻止CPU省电和休眠。
要配置run loop只需要在线程开始时指定一个run loop对象,设置事件处理程序,然后让run
loop运行就可以了。系统会自动为主线程创建一个run loop。如果你想创建另外一个长期存在的线程,可以自己为他们配置一个run loop。
同步工具
多线程最大的坏处之一就是争夺资源。如果多个线程同时使用或修改一个资源,问题就出现了。缓解这个问题的一种方式是避免使用共同的资源,让每个线程都有他们独立的资源。完全保持独立并不是很好的选择,你也可以使用锁,conditions,原子操作等技术。
锁提供了一种强有力的方法来保证一个资源只被一个线程访问。最经常用到的锁叫做互斥锁。当一个线程想要访问被另一个线程锁住的资源时,它要一直等到另一个线程释放锁。很多系统框架提供了互斥锁,虽然他们底层的技术都是一样的。另外Cocoa提供了一些互斥锁的扩展来支持不同类型的行为,比如递归。
除了锁以外,系统还提供conditions,它来保证程序种的任务安正确的顺序执行。conditions就像是看门人,它会阻塞线程直到它达到运行的条件。当达到条件后,condition会让线程继续运行。POSIX和Foundation框架都提供了conditions。(如果是使用operation,你可以配置operation对象执行任务的依赖关系,和conditions的行为很相似)
虽然锁和conditions是并发中很常见的设计,原子操作是保护和同步访问资源的另一种方式。当进行数学运算或表量数据的逻辑运算时,原子操作是替代锁的一个轻量级的方法。原子操作使用特殊的硬件指令来保证修改变量完成后其他线程才能访问。
线程间通讯
好的设计会尽量减少通讯,但是,线程间通讯也是必要的。线程可能需要发起新任务请求或者把计算的结果报告给主线程。这些情况下,就需要有方法来支持线程间通讯了。很幸运,同一个进程下的线程有很多方式可以通讯。
线程通讯有很多方式,每个都有好处和不足。下面列出了OS X上的大部分的通讯机制。(除了message queues和Cocoa
distributed object,其他的在iOS上都可以用)。下面的技术是按照复杂度递增排序的。
通讯机制
机制 | 描述 |
直接发消息 |
Cocoa程序支持直接在另一个线程上执行selector。这就意味着一个线程可以直接在另一个线程上执行方法。由于是在另一个线程上执行,这种方式发送的消息会直接在目标线程上序列化 |
全局变量,共享内存或对象 |
另一个线程间通讯的方式是使用全局变量,共享对象或内存。虽然共享变量很快也很简单,但是它比直接发消息更脆弱。在并发程序中全局变量必须要用锁或者其他同步机制小心的保护。没保护好可能会导致争夺资源,数据损坏甚至crash。 |
Conditions |
Conditions是一个同步工具,它可以控制一个线程什么时候执行一段代码。可以把Conditions看作是一个看门人,只有当状态满足时才会让线程运行。 |
Run loop资源 |
自定义的run loop是你在线程上设置来接收指定的消息的。由于它是事件驱动的,run |
Ports 和 sockets |
线程间基于port的通讯是一个更复杂的方式,同时也是一个很可靠的技术。更重要的是,ports和sockets可以和外部实体通讯,比如其他进程和服务。对于效率方面,port的实现是基于run |
消息队列 |
基于legacy的多线程服务在管理数据方面定义了一个先进先出的队列。虽然消息队列和简单方便,但是相对于其他技术它并不是那么高效。 |
Cocoa distributed object |
Dirtributed |
设计的小提示
为了确保并发代码的正确性下面说一些提示,有一些对于提升性能也会有帮助。对于任何更新,最好写代码的前,中,后都要对比一下代码的性能。
避免手动创建线程
手动创建线程比较繁琐也容易出错,应该尽量避免。OS
X和iOS提供了隐式支持并发的API。相对于自己创建线程,更推荐异步API,GCD以及operation
对象。这些技术在幕后做线程相关的工作并且可以保证他们正确的工作。另外,相对于自己创建线程,GCD和operation对象可以根据系统负载更高效的管理线程。
让线程合理的繁忙
如果需要自己创建管理线程,要记住线程会消耗宝贵的系统资源。要确保线程分配任务所消耗的时间和产出是合理的。同时,应该终止大部分时间不做事的线程。线程会占用内存,释放它不仅可以增加当前程序的可使用内存,也能让其他程序有更过的内存可以使用
注意:在释放空闲线程之前,最好记录一下程序的当前性能。在更改之后,在记录一些性能看看是否有优化。
避免共享数据
最简单的避免线程资源冲突的方法是给每个线程一份他们需要的资源。最小化线程间通讯和争夺资源能让他们更好的工作。
就算很注意共享资源的加锁,可能还是会有其他问题。比如一些数据修改的时候需要按照特殊的顺序,这时候可以用基于transaction的代码来解决这个问题。在设计多线程代码时,避免竞争资源是首先要考虑的事情。
线程和用户界面
如果程序有图形用户界面,推荐在主线程中接收用户事件和更新界面。这样可以避免处理事件和绘制窗口的线程同步问题。有一些框架,比如Cocoa必须是这样,对于一些不强制这样的框架,建议这样做和让逻辑变简单。
也有一些例外可以使用另一个线程来处理图形操作。比如,使用另一个线程来处理图片计算。这样可以优化性能。
清楚线程退出的行为
进程在所有非独立线程退出后退出。默认情况下只有程序的主线程是非独立的,但是也可以自己创建非独立线程。当用户退出程序时,系统会立即结束其他独立线程,因为独立线程做的事情被认为是可有可无的。如果在用后台线程存储数据到硬盘或其他重要工作,尽量使用非独立线程来防止程序退出时数据丢失。
创建非独立线程需要做一些额外的工作。因为上层线程技术默认不创建非独立线程,所以你需要使用POSIX
API来创建。另外,需要在主线程中添加非独立线程结束的代码。
如果是Cocoa程序,可以使用applicationShouldTerminate:回调方法来让程序延时退出。使用延时结束时,重要的线程完成他们的任务后应该调用replyToApplicationShouldTerminate:方法。
处理异常
异常处理机制会在异常抛出时根据当前调用堆栈来做必要的清理。因为每个线程有它自己的调用堆栈,所以每个线程负责捕捉它自己的异常。其他线程的异常捕捉失败和主线程捕捉失败是一样的,会导致它所属的进程被终止。不能把异常抛给另一个线程。
如果需要提示其他线程(比如主线程)当前线程的异常状态,应该捕捉这个异常然后只是把消息发给另一个线程来告诉它发生了什么。取决于你的设计和你具体想做什么,出现异常的线程可以继续执行(如果还能执行的话),等待指示,或直接退出。
注意:在Cocoa中,NSException对象会自己维护自己,所以在捕捉到后可以在线程间传递。
有些情况下,会自动捕捉异常,例如@synchronized在objective-c中隐式的异常处理。
线程结束后的清理
最好的线程退出方式是自然退出,也就是运行到它主入口的结束。虽然有方法让线程立即终止,不过这些方法是最后的办法。在线程自然结束前终止它会让它不能清理自己的现场。如果它有申请内存,打开文件或者占用其他资源,其他代码可能就不能在访问这些资源,或者内存泄漏,或者导致其他潜在的问题。
库中的线程安全
虽然程序开发这自己会控制程序是否用多线程执行,库的开发者不能控制。开发库的时候,你应该假设使用者会用多线程或者随时会切换到多线程。因此,关键代码部分一定要用锁。
对于库开发者,使用多线程时才用锁是不明智的。只要有可能会用锁,在库中要尽早的使用锁,最好在初始化库的时候显式的调用。虽然也可以用库的静态初始化方法创建锁,尽量在没有其他方式的时候才用。初始化也会花事件的,而且会影响性能。
注意:库中互斥锁的锁和解锁一定要配对。一定要记得加锁,而不是期望使用者在线程安全环境中使用。
如果是开发Cocoa库,你可以注册NSWillBecomeMultiThreadedNotification来知道程序变为多线程的了。但是不能依赖于这个消息,有可能在库代码被调用之前就已经分发了。