关于多线程编程
由于单核处理器的速度限制,逐渐达到了速度的极限。转而由单核转向多核处理器,就是为了让计算机在同时能够执行多项任务。因此我们可以在程序使用多线程技术来充分的利用计算机中多核处理器。
1.1 线程是什么
⚠️我的总结:一个线程其实就是一条代码执行的路径,多线程就是有多个路径来执行程序代码(正是因为这个原因会造成资源竞争的问题)。
线程是在一个程序中实现多个执行路径相对来说比较轻量的方式。程序在系统中并行执行,系统会根据每个程序的不同需求分配不同的执行时间。每个程序内部都存在一个或多个线程,这些线程可以同时执行多个不同的任务。这些线程的管理工作实际上由系统完成,主要是调度他们在内核上的运行和中断状态等等(在1.4.1会介绍线程的三个状态)。
从技术角度来看,线程是包含了内核和程序执行所需数据的代码组合。内核协调线程事件的分发以及线程的调度。程序(application)用来管理函数调用(栈相关的操作,之前看过的一篇C中的函数调用),管理和操作线程相关的属性和状态。
在非并行的程序中,只有一个线程来执行代码。该线程的开始地点是在程序中的main函数,然后一步一步调用不同方法或者函数来实现程序的目的,到最后线程停止。相比之下,支持并行的程序从主线程开始,根据需求创建其他的代码执行路径。每个新的执行路径都需要有自己主入口点函数,独立于程序主线程的代码。在程序拥有多个线程会有两个优点:
- 多线程可以提高程序的反应速度;
- 多线程可以提高程序在多核操作系统上的性能;
如果程序中只有一个线程,那么这个线程就需要做所有的事儿。它需要响应相关事件,并更新程序的UI,执行程序所需的相关计算。只有一个线程的问题在于该程序一次只能执行一件事情。那么问题来了,如果当前在处理一个长时间的耗时任务怎么办?这时候程序需要进行长时间计算,程序就会停止和用户的交互。这可能会让用户觉得程序已经卡死了,最后的结果就是用户强制性杀死程序。但是如果我们将这个耗时工作放在一个辅助线程上,主线程便能够继续和用户进行交互,从而避免这些问题。
随着多核心的计算机普及,线程技术成了一些程序提高性能的方法。执行不同任务的线程在不同的处理器核上运行,可以让程序在给定的时间内增加处理工作的量。
当然,线程不是修复应用程序性能问题的灵丹妙药。凡事都有好坏,线程能带来好处那肯定就有它的坏处。在程序中多个路径来执行代码显然会增加程序的复杂性。每一个线程都需要和其他线程协调工作,以防不同线程破坏了程序的状态信息。因为在单个程序中不同线程可以共享同一块内存空间,如果当两个线程同时操作同一个数据时,一个线程可能会覆盖其他线程对该数据的更改。
1.2 线程术语
在讨论线程及其技术之前,我们需要定义一些基本术语。如果你熟悉UNIX系统,那么在用"task"术语时会不同。 在UNIX系统上,术语“task”有时用于指向正在运行的进程。
本文档采用以下术语:
- thread:指代码的单独执行路径;
- process:指多个运行的可执行文件(进程),可以包含多个线程;
- task:需要执行的的抽象概念。
1.3 线程的替代方案
自己创建线程的问题在于会增加代码的不确定性。为程序增加并发特性的方法中,线程技术是相对比较底层和相对复杂的方法。如果我们在使用线程技术中不能完全理解自己设计的含义,那就有可能会遭遇同步和耗时问题,造成问题的严重性从细小的问题到程序崩溃,最糟糕的是会造成用户数据损坏等等。
另一个需要考虑的因素是我们是否真的需要线程或者并发程序。线程在解决同一个进程中针对特定的问题提供多个并行执行路径有相当大的用处。在有的情况下,我们所做的工作并不需要并发执行。线程会给进程在内存消耗和CPU耗时上增加巨大的开销,所以就出现了下面一些替代方法。
下面列出了一些线程的替代方法,该表包括了线程的替代技术(NSOperation对象和GCD)以及为已有的单线程给出替代方案。
| 技术 | 描述 |
|---|---|
| Operation对象 | OS X v10.5引入,operation对象是辅助线程执行任务的封装。该对象封装在执行任务的线程里面, 以达到专注于任务本身。使用这些对象时都是和操作队列(operation queue)结合使用,它管理了一个或多个线程上的数据。更多关于使用operation对象的信息见Concurrency Programming Guide。 |
| GCD | Mac OS x v10.6引入,GCD是另一种专注于任务而无需关心线程相关的东西。在GCD中,我们将任务添加到一个工作队列中,该队列在适当的线程上来调度任务的执行。工作队列会根据可用的内核数和负载情况来调度任务的执行,比我们自己来管理线程有效的多。更多信息见Concurrency Programming Guide |
| Idle-time 通知 | 针对较短和优先级较低的任务,Idle-time 通知会在程序不繁忙的时候执行任务。Cocoa提供了对Idle-time通知的支持(通过使用NSNotificationQueue对象)。请求idle-time通知时,我们使用NSPostWhenIdle选项向NSNotificationQueue对象发出一个通知。该队列会等到Runloop经过idle时执行通知对象。更多信息见Notification Programming Topics |
| 异步功能 | 系统提供了许多自动并行的异步函数。这些API会使用系统相关函数或者创建自动的的线程来指定任务,在任务完成之后将结果返回给我们。在设计程序时,找到提供异步功能的函数,并使用它们(而不是在自定义线程上使用等效的同步功能)。 |
| Timers | 可以在主线程上使用定时器定期的来执行相关任务,这些任务的需求针对线程的成本而言要相对较小。 |
| 进程分离(Separate processes) | 尽管它的重量级相对于线程更高一点,但是在任务只与程序相关的时候我们可以创建一个单独的进程。如果任务需要大量内存或者是需要使用root权限(root privileges,还能使用root权限?)才能执行时,我们可以使用该技术。比如使用64位的服务程序来计算数据,然后用32位程序来显示的情形下。 |
当使用
fork函数启用分离进程时,我们尽量在fork函数后面调用exec或者similar函数。依赖于Core Foundation,Cocoa,或者Core Data的程序后续都应该要调用exec函数,否则这些框架就有可能出现错误的情况。
1.4 线程支持
如果我们需要在现有的代码中使用线程,OS X 和 iOS提供了几种在程序中创建线程的技术。此外,这两个系统都为这些线程提供了管理和同步方法。接下来几个部分来介绍一下这些技术。
1.4.1 线程管理
虽然线程的底层实现是用Mach线程实现的,但是我们也可以在Mach层使用线程。相反,我们通常还是使用的POSIX的api及其衍生出来的接口。Mach实现了线程所需的基本功能,连抢占模型,线程调度等等功能都有实现。下表是在程序中能够使用的线程技术:
| 技术 | 描述 |
|---|---|
| Cocoa线程 | Cocoa通过NSThread类实现线程功能,也可以使用NSObject来生成一个线程,NSObject还可以在已存在的线程上执行代码。 |
| POSIX线程 | POSIX线程提供了基于C的线程接口。用POSIX创建线程是作为写非Cocoa程序最好的选择。接口使用起来相对比较简单,能够在线程方面提供充足的灵活性。 |
| 多处理服务 | 这是老版本基于Mac OS的C接口,现在不推荐使用。尽量使用NSThread和POSIX。 |
在程序中,所有线程的行为和其他平台的类似。线程启动之后,线程就进入三个状态中的任何一个:运行(running)、就绪(ready)、阻塞(blocked)。如果一个线程当前没有运行,那么它可能处于阻塞状态,或者处于等待外部的输入,或者已经准备就绪等待分配CPU。
当新创建一个线程,必须指定该线程的入口点函数(entry-point function)。该入口点函数由你想要在该线程上面执行的代码组成。但在函数返回的时候(或者你主动的中断线程的时候),线程将永久停止,然后被系统回收。因为线程创建需要的内存和时间消耗都比较大,因此建议入口点函数做一定数量的工作,或者设置Runloop来重复执行工作。
1.4.2 Runloop
Runloop是用来管理线程上异步事件的基础组件,它帮助线程监管一个或多个事件源而工作,当事件到达的时候,系统唤醒线程并调度事件到Runloop,然后分配到相应的程序。如果没有事件出现和准备处理的事件,Runloop会把线程置于休眠状态。
我们并非必须要在创建的线程中使用RunLoop,但是使用它的话可以给用户提供更好的体验。Runloop可以用最小的资源来创建长时间运行的线程。这是由于Runloop在没有任何事件处理的时候会把它的线程置于休眠状态,这个操作消除了CPU的轮询(这会浪费CPU的周期)以达到节省功率的目的,并防止处理器本身休眠。
为了配置RunLoop,我们需要启动线程来配置Runloop,然后获取Runloop的引用对象, 初始化事件处理程序(event handlers),最后让其运行起来。Cocoa和Carbon提供的基础组件会自动为主线程配置相应的Runloop。
如果要创建长时间运行的线程, 那么你必须为你的线程配置相应的Runloop。
1.4.3 同步工具
线程编程的危害之一是多个线程之间的资源竞争。如果多个线程在同一个时间试图使用或者修改同一个资源时就会出现问题。缓解这个问题的一个方法是不让它们共享同一资源,并确保每个线程操作都是自己独立的资源副本。然而保持完全独立的资源是不可行的,所以我们可能就需要使用锁(locks),条件(conditions),原子操作(atomic operations)和其他资源同步的技术。
锁提供了一次只有一个线程可以执行代码的有效保护形式。最普遍的一种锁是互斥锁(mutual exclusion lock),也就是“mutex”。当一个线程尝试去获取此时被另一个线程持有的互斥锁时,它将会被一直阻塞到另一个线程上该锁被释放之后。iOS和Mac OSX都提供了互斥锁。此外Cocoa提供了 几个互斥锁的变种来支持不同的行为类型(递归)。
除了锁之外,系统还提供了对条件的支持,“条件“确保任务在应用程序中能够按照正确的顺序执行。条件充当了一个阀门(gatekeeper ,我将其翻译为阀门,想象一下线程的任务就是水管里面的水,条件就是一个阀门)的作用,它会阻塞当前线程(也就是水管里面水的流动)直到条件变为true(阀门打开)。POSIX和基础框架都直接提供了对条件的支持(如果使用了Operation Object,可以通过设置Object的依赖关系来确定任务执行的顺序addDependency:)。
原子操作是保护和同步数据访问的另一种方法,在对标量数据类型执行数学或逻辑运算的情况下,原子操作作为更轻量级方案来替代锁,原子操作使用特殊的硬件指令来确保在其他线程访问变量之前完成对其的修改。
1.4.4 线程间通信(Inter-thread Communication)
虽然一个良好的设计应该最大限度地减少线程间通信,但在某些时候线程之间的通信显得很有必要。(线程的作用就是为程序工作,如果工作的结果从来就不使用,那存在的意义是什么?)线程可能会处理请求的新任务,也有可能向主线程汇报该线程处理的结果。在这些情况下,我们一个线程需要从另一个线程获取相关信息。
线程是共享相同的进程空间的。
下表根据复杂度的不同,列出了线程间通信的方法;
| 机制 | 描述 |
|---|---|
| Direct messaging(直接消息) | Cocoa应用程序可以在其他线程Perform Selector(执行选择器,这个翻译很别扭,故采用原文),意思就是一个线程可以在其他任何线程上面执行方法(Method)。由于这些Method是在目标线程(也就是前面说的Other threads)的上下文中执行,所以该线程会自动序列化这些消息。 |
| 全局变量,共享内存,共享对象 | 共享变量是比较快速和简单的方式,但是在使用共享变量的方式的时候,需要格外的注意:必须使用锁或其他的方式来保证代码的正确性以防止因为竞争导致数据损坏或程序崩溃。 |
| Conditions | Coditions作为一个同步工具可以用来控制一个线程执行特定代码的时机(只有在线程满足特定的条件时才运行)。 |
| Runloop Sources | 自定义一个能够在线程上接收应用程序特定消息(Application-Specific Message)的Runloop Source。由于Runloop是事件驱动(Event Driven)的,所以当前线程如果没有任何操作时Runloop会让其进入休眠状态。 |
| Ports and Sockets(端口和Sockets) | 基于端口的线程间通信是更复杂同时也是更可靠的技术。更重要的是Ports和Socket可以用于和外部实体通信(比如其他进程和服务),由于Ports是基于Runloop Source实现的,所以当端口上没有数据等待时当前线程会进入休眠状态。 |
| 消息队列(Message Queues) | 用于管理输入输出的FIFO队列 |
| Cocoa分布式对象(Cocoa distributed objects) | 它是一种基于端口通信的高级实现方式。因为它在线程间通信会造成不小的开销,所以分布式对象更适合用于和其他进程间通信 |
1.5 设计技巧
1.5.1 避免显式创建线程
手动编写线程时的代码是很冗长且乏味的,而且还很容易出错。因此我们在任何时候都要尽可能的避免自己手动编写线程。系统提供了其他的并发编程API,我们可以使用异步API,GCD,或操作对象(Operation Object)来实现并发,而不是自己来创建一个线程。这些技术能够保证线程的正确执行。
见并发编程指南。
1.5.2 保持线程合理的忙(Keep Your Threads Reasonably Busy)
在手动创建和管理线程时,一定要记住线程会消耗宝贵的系统资源。我们应该尽最大努力确保分配给线程的任务都是高效的,有合理的生存周期(long-live我姑且这么翻译)。而且应该终止长时间处于空闲状态的线程。
释放空闲线程不仅有助于减少应用程序的内存占用,还释放更多的物理内存,供其他系统进程使用。
1.5.3 避免共享数据结构
为了避免不同线程之间共享数据结构,最简单的办法就是给应用程序的每个线程分配一份数据副本。线程之间通信和资源竞争最小的时候,并行的程序可以达到最佳的效果。
创建多线程程序是很困难的,尽管在代码中锁定共享数据时非常小心,代码仍然可能是不安全的。比如,在修改共享数据时就有可能出现问题。所以我们应该消除资源竞争以达到让程序有更好的性能。
1.5.4 多线程和用户界面
针对包含图形界面程序,我们应该使用主线程来更新相关界面。这样有助于避免在处理用户事件和界面绘制等等的同步问题。在Cocoa中要求我们这样(使用主线程更新界面)做。
使用辅助线程来创建和处理图片和其他图片相关的计算。使用辅助线程来执行这些操作可以极大提高性能。
1.5.5 了解线程退出的行为
进程要在所有非独立(也叫可连接)线程都退出之后才会结束。默认情况下,主线程才是非独立的。当程序退出时,需要立即终止掉所有的独立线程(因为独立线程的工作被认为是可选的,不是必须的)。
如果我们让应用程序在使用后台线程来保存数据到硬盘或者其他周期性的操作时,那么需要把这些线程创建为非独立的来保证程序退出的时候不丢失数据。
非独立线程也叫可连接线程,由于高级线程技术默认是不会创建可连接线程,创建非独立线程的方式需要通过POSIX API,而且还需要在主线程添加“在退出时连接到非独立线程”的代码。
如果在编写Cocoa程序时,我们可以使用applicationShouldTerminate:代理方法来延迟程序的终止时间,或者取消程序终止。当延迟终止时,程序会等到线程执行任务完成,然后调用replyToApplicationShouldTerminate:方法。
1.5.6 异常处理
当抛出异常时,异常处理机制会根据当前调用栈来执行清理操作。因为每一个线程都有自己的调用栈,所以各个线程需要自己捕获自己的异常。当拥有的进程终止之后,次级线程和主线程都会出现捕获异常失败的情况(它们是一样的)。
不能将一个未捕获的异常抛给不同的线程进行处理。
当我们需要通知另一个线程(例如主线程)当前线程中的异常情况的时候,正确的做法是捕获这个异常并简单的发送一个消息告知另一个线程当前线程发生了什么。根据你的mode或目的,该线程可以在捕获异常之后进行其他操作,比如等待指示,或者干脆直接退出。在某些情况下,异常处理可能是自动创建的。比如,Objective-C中的@synchronized包含了一个隐式的异常处理。
1.5.7 干净利落的中断线程
线程最自然的退出方法就是让其运行到主要输入事务结束(虽然可以主动终止当前线程结束,但是最好把它当作最后的解决方案)。当为创建的这个线程分配了内存(在处理文件打开、图像处理或者其他操作的时候),如果这时主动终止当前线程则会造成内存泄漏。
本人总结TIPS
- 只有一个线程的问题在于该程序一次只能执行一件事情。
- 线程会有三个状态:运行(running)、就绪(ready)、阻塞(blocked);
- Runloop可以用最小的资源来创建长时间运行的线程;
- 原子操作:在对标量数据类型执行数学或逻辑运算的情况下;
- 线程是共享相同的进程空间的。
- 使用主线程更新界面;
- 进程要在所有非独立(也叫可连接)线程都退出之后才会结束;
- 正确的做法是捕获这个异常并简单的发送一个消息告知另一个线程当前线程发生了什么;