多线程
线程的基本概念
线程 (thread)是进程(process)A 内假想的持有 CPU 使用权的执行单位。一般情况下,一个进程 只有一个线程,但也可以创建多个线程并在进程中并行执行。应用在执行某一处理的同时,还可以 接收 GUI 的输入。
使用多线程的程序称为 多线程 (multithread)运行。从程序开始执行时就运行的线程称为 主线程 , 除此之外,之后生成的线程称为次线程(secondary thread)或子线程(subthread)。
创建线程时,创建方的线程为父线程,被创建方的线程为子线程。父线程和子线程并行执行各
自的处理,但父线程可以等到子线程执行终止后与其会合(join)。而另一方面,在线程被创建后, 也可以切断父子关系指定它们不进行会合。该操作称为 分离 (detach)。这里所说的 NSThread 就是在 分离状态下创建线程。
一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:1012951431, 分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!希望帮助开发者少走弯路。
由于被创建的线程共享进程的地址空间,所以能够自由访问进程的空间变量。多线程访问的变量称为 共享变量 (shared variable) 。共享变量大多为全局变量或静态变量,但因为地址空间是共享的, 所以理论上所有内存区域都可以称为共享变量。
如果多线程胡乱访问共享变量,那么就不能保证变量值的正确性。所以有时就需要按照一定的 规则使多线程可以协调动作。此时就必须执行线程间 互斥 (或者排他控制,mutual exclusion)(见 。 各个线程都分配有栈且独立进行管理。基本上不能访问其他线程的栈内的变量(自动变量)。通 过遵守这样的编程方式,就可以自由访问方法或函数的自动变量,而且不用担心互斥。
使用引用计数管理方式时,为了使对象之间解耦合,子线程方需要创建与父线程不同的自动释
放池来管理。使用垃圾回收时不需要这样。
A 任务(task)这一名称也被用来表示与进程同样的概念,在苹果公司的文档“Multithreading Programming Topics”中, 可以包含多线程的程序的执行单元称为进程,而任务则被用来抽象地表示应该进行的作业。
线程安全
多个线程同时操作某个实例时,如果没有得到错误结果或实例没有包含不正确的状态,那么该 类就称为 线程安全 (thread-safe)。结果不能保证时,则称为非线程安全或线程不安全(thread-unsafe)。
一般情况下,常数对象是线程安全的,变量对象不是线程安全的。常数对象可以在线程间安全
地传递,但对变量对象共享时,需要恰当地执行互斥或同步切换。
需要注意的是 C 语言的函数。就现状来看,BSD 函数的大部分,例如 printf() 等,都不是线程安 全的。
注意点
在某些情况下,使用多线程可以使处理高速化、实现易于使用的接口、使实现更简单等。但并 不是说使用多线程后就一定会得到这些优点。
要想使多线程程序不出错且高效执行,并行编程的知识必不可少。线程间的任务分配和信息交 换、共享资源的互斥、与 GUI 的交互及动画显示等,在使用时都要特别小心。
一般情况下,自己实现多线程程序是很困难的,而且也容易埋下高隐患。稍有差错或设计失误,
多 线 程 便 不 能 发 挥 效 果, 甚 至 还 会 导 致 未 知 原 因 的 释 放 或 异 常 终 止。 使 用 19.3 节 中 介 绍 的 NSOperation,虽然可以较容易地实现多线程程序,但是也必须掌握线程动作、互斥等相关知识。不 能适应这些的读者建议去参考一下并行编程的相关书籍。
而且,很多多线程中遇见的问题都可以通过 NSTimer 类或延迟消息发送(参考 15.1 节)来解决。 大家也不妨尝试一下用这些方法来解决相关问题。
使用 NSThread创建线程
Foundation 框架中提供了 NSThread 类来创建并控制线程。该类的接口在 Foundation/NSThread.h 中声明。
创建新线程需要执行下面的类方法。
+ (void) detachNewThreadSelector: (SEL) aSelector
toTarget: (id) aTarget
withObject: (id) anArgument
对 对象 aTarget 调用方法创建新线程并执行。选择器 aSelector 必须是仅获取一个 id 类型参数且返回值 为 void 的执行方法(参考 8.2 节)。
指定的方法执行结束后,线程也随之终止。线程从最初就被执行了分离,所以终止时没有和父 线程会合。当主线程终止时,包含子线程的程序也全部随之终止。
使用引用计数管理(手动及 ARC)时,有时需要执行的方法自身来管理自动释放池。此外,参 数 aTarget 和 anArgument 中指定的对象也与线程同时存在,即在创建线程时被保存,在线程终止时 被释放。 使用下述的 NSApplication 类中的方法也能创建线程。该方法使用上面的方法,而且在使用引用 计数管理时还会创建线程的自动释放池。
+ (void) detachDrawingThread: (SEL) selector
toTarget: (id) target
withObject: (id) argument
创建新线程并执行的方法除了上述方法还有很多,本书中不再一一介绍。其他方法请参考 NSThread、NSObject 的参考文档。
程序可以调用 NSThread 类方法来确认是否是多线程运行。 + (BOOL) isMultiThreaded 多个线程并行执行时或者只有主线程在执行时,只要在此之前已经创建了线程,则返回 YES。
当前线程
一个线程称自身为 当前线程 (current thread),区别于其他线程。
子线程将创建时指定的方法执行完后也会随之终止,但也可以中途终止。为此,可以使用当前 线程(线程自身)来执行下一个 NSThread 类方法。但是,使用引用计数管理时,终止前一定要释放 自动释放池。 + (void) exit
使用下述方法获得表示线程的 NSThread 实例。
+ (NSThread *) currentThread
获 得表示当前线程的 NSThread 实例。
+ (NSThread *) mainThread
获 得表示主线程的NSThread实例。查看当前线程是否为主线程时,可以使用类方法isMainThread 。 每个线程都可以持有一个该线程固有的 NSMutableDictionary 类型的字典。向 NSThread 实例发 送下面的消息类就可以取得字典。
- (NSMutableDictionary *) threadDictionary
可以使当前线程仅被中断几秒。为此,可在当前线程中执行下面的类方法。参数为实数。 + (void) sleepForTimeInterval: (NSTimeInterval) ti
也可以使线程在某一时刻前中断,这时可采用下面的类方法。参数是表示日期的类 NSDate 实例。 + (void) sleepUntilDate:(NSDate *) aDate
如果要使线程到某个条件成立前一直保持休眠状态,则要使用下一章节介绍的锁。
GUI应用和线程
在使用 GUI 的应用中,事件处理和绘图等大部分处理中线程都发挥了重要作用。也可以在子线 程中创建窗体,或分担部分绘图功能,但要注意避免竞争或内存泄漏。详情请参考相关文档。
GUI 应用中有较容易的方法来使用线程,即将 GUI 相关的时间处理或绘图集中在主线程中进行。
使用下面的方法,就可以从子线程依赖主线程中的方法处理。该方法为 NSOjbect 的范畴,在头文件 Foundation/NSThread.h 中声明。
- (void) performSelectorOnMainThread: (SEL) aSelector
withObject: (id) arg
waitUntilDone: (BOOL) wait
选 择器 aSelector 和参数 arg 中指定的方法的执行依赖于主线程。wait 为 YES 时,当前线程会一直等待 至执行结束。主线程中必须有事件循环(运行回路)。
互斥
需要互斥的例子
在多线程环境中,无论哪个函数或方法都可以在多线程中同时执行。但是,在使用共享变量时, 或者在执行文件输出或绘图等的情况下,多线程同时执行就可能得到奇怪的结果。
例如,使用整数全局变量 totalNumber 来累加所处理的数据的个数。为了执行下面的加法计算,
在多线程环境中执行该方法会得到什么结果呢?
- (void)addNumber:(NSIngeger)n
{
totalNumber += n;
} 在 OS 功能支持下,线程在运行的过程中会时而得到 CPU 的执行权,时而被挂起执行权,2 个 方法的执行情况如图 19-1 中所示。在该图中,线程 1 将新计算的值保存在寄存器时挂起 CPU 执行 权,同时线程 2 开始执行方法。即使 CPU 的执行权被挂起,寄存器的值也仍然可以被保存,所以各 线程都能正常处理。但是,由于线程 2 写入的值消失了,因此整体上看,这偏离了我们期待的结果。 原因是值的读取、更新、写入操作被多线程同时执行了。
在图 19-1 的例子中,我们将同时只可以由一个线程占有并执行的代码部分称为临界区(critical section),或称为危险区。互斥的目的就是限制可以在临界区执行的线程。
锁
为了使多个线程间可以相互排斥地使用全局变量等共享资源,可以使用NSLock 类。该类的实例 也就是可以调整多线程行为的 信号量 (semaphore)或者 互斥型信号量 (mutual exclusion semaphore)。 Cocoa 环境中也称为 锁 (lock)。
锁具有每次只允许单个线程获得并使用的性质。获得锁称为“加锁”,释放锁称为“解锁”。
锁和普通的实例一样,使用类方法alloc 和初始化器init 来创建并初始化。但是,锁应该在程 序开始在多线程执行前创建。
NSLock *countLock = [[NSLock alloc] init];
获得锁的方法和释放(unlock)锁的方法都在协议 NSLocking 中定义。
- (void) lock 如果锁正被使用,则线程进入休眠状态。
如果锁没有被使用,则将锁的状态变为正被使用,线程继续执行。
- (void) unlock 将 锁置为没有在被使用,此时如果有等待该锁资源的正在休眠的线程,则将其唤醒。
在上例中,使用锁后会产生如下效果。但需要预先创建 NSLock 的实例 aLock。在该代码中,从 某线程执行 A 取得锁到该线程执行 B 释放锁期间,其他线程在执行 A 时将进入休眠状态,不能执 行临界区代码。锁被释放后,在执行 A 时休眠的线程中选择一个线程,该线程在取得锁后进入临界 区执行。
- (void)addNumber:(NSIngeger)n { [aLock lock]; ───────────────────────────────────────── A totalNumber += n; // 临界区 [aLock unlock]; ──────────────────────────────────────── B }
某个锁被lock 后,必须执行一次unlock 。而且lock 和unlock 必须在同一个线程执行 A。
下面来看另外一个使用锁的例子。考虑一下全局变量值自增时返回其结果的方法。多线程执行 时,全局变量 theCount 若想正确地自增,就需要使用锁 countLock 来管理。
可以采用如下定义。
A lock 和 unlock 必须在同一个线程中执行,因为 NSLock 是基于 POSIX 线程实现的。
死锁
线程和锁的关系必须在设计之初就经过仔细的考虑。如果错误地使用锁,不但不能按照预期执 行互斥,还可能使多个线程陷入到不能执行的状态,即死锁(deadlock)状态。
死锁就是多线程(或进程)永远在等待一个不可能实现的条件而无法继续执行,如图 19-2 所示。
线程 1 占有文件 A 并正在进行处理,途中又需要占有文件 B。而另一方面,线程 2 占有着文件 B,途中又需要占有文件 A。大家不妨设想一下,如果线程 1 和线程 2 同时执行到了图中的箭头位置 会怎么样呢?线程 1 为了处理文件 B 想要获得锁 lockForB,但是它已经被线程 2 获得。同样,线程 2 想要获得的锁 lockForA 也被线程 1 占有着。这种情况下,线程 1 和线程 2 就会同时进入休眠状态, 而且双方都不能跳出该状态。
像这样,当多个线程互相等待资源的释放时,就非常容易出现死锁现象。有时是多个线程相干预,有时则是一个线程因为自己需要获得锁而进入休眠状态。此外,由于多数情况下各个线程本身 并没有错误处理,而且死锁又随时可能发生,因此追究原因就非常困难,也不能排除导致程序 bug 的可能。
尝试获得锁
NSLock 类不仅能获得锁和释放锁,还有检查是否能获得锁的功能。利用这些功能,就可以在不 能获得锁时进行其他处理。
- (BOOL) tryLock
用 接收器尝试获得某个锁,如果可以取得该锁则返回 YES。不能获得时,与lock 处理不同,线程没 有进入休眠状态,而是直接返回 NO 并继续执行。
该方法十分便利,但要确保只能在可以获得锁时才执行 unlock,创建程序时必须注意这一点。
条件锁
类 NSConditionLock 称为 条件锁 (condition lock)。该锁持有整数值,根据该值可以获得锁或者 等待。
- (id) initWithCondition: (NSInteger) condition
NSConditionLock 实例初始化,设置参数 condition 指定的值。
NSCondtionLock 的指定初始化器。
- (NSInteger) condition
此时返回锁中设定的值。
- (void) lockWhenCondition: (NSInteger) condition
如果锁正在被使用,则线程进入休眠状态。
锁不在被使用时,如果锁值和参数 condition 的值一致,则将锁状态修改为正在被使用,然后继续执 行,如果不一致,则线程进入休眠状态。
- (void) unlockWithCondition: (NSInteger) condition
在锁中设置参数 condition 指定的值。将锁设置为不在被使用,此时如果有等待获得该锁且处于休眠 状态的线程,则将其唤醒。
- (BOOL) tryLockWhenCondition: (NSInteger) condition
尚未使用锁且锁值与参数 condition 相同时,获得锁并返回 YES。不能获得锁时也不进入休眠状态, 而是返回 NO,线程继续执行。
使用方法 lock 、 unlock 或 tryLock 都可以获得锁和释放锁,而且无需关心锁的值。
然而,由于 NSConditionLock 实例可以持有的状态为整数型,所以事先用枚举常数或宏定义就可 以了。如果只使用 0 或 1,不仅不容易理解,也可能造成错误。
NSRecursiveLock
某线程获得锁后,到该线程释放锁期间,想要获得该锁的线程就会进入休眠。使用类 NSLock 的 锁时,如果已经获得锁的线程在没有释放它的情况下还想再次获得该锁,该线程也会进入休眠状态。 但是,由于没有从休眠状态唤醒的线程,所以这就是死锁。下面是一个简单的例子,这段代码不会 执行。
[aLock lock];
[aLock lock]; // 这里发生死锁
[aLock unlock];
[aLock unlock];
解决这种情况可以使用 NSRecursiveLock 类的锁,拥有锁的线程即使多次获得同一个锁也不会 进入死锁。但是,其他线程当然也不能获得该锁。获得次数和释放次数一致时,锁就会被释放。
NSRecursiveLock 类的锁使用起来十分方便,但排除被重复加锁的情况,用 NSLock 来重新记述
的话,性能则会更好。
@synchronized
程序内的块可以指定为不被多线程同时使用。为此可以使用 @synchronized 编译符,如下所示。
通过使用该段代码,运行时系统就可以创建排斥地执行该代码块的锁(mutex)。参数 obj 通常指 定为该互斥锁要保护的对象。obj 自己不需要是锁对象。
线程如果要执行该代码块,首先会尝试获得锁,如果能获得锁则可以执行块内代码。块执行结 束时一并释放锁。使用 break 或 return 从块内跳出到块外时也被视作块执行终止。而且,在块内发生 异常时,运行时系统会捕捉异常并释放块。
@synchronized 的参数对象决定对应的块。所以, 同一个对象参数的 @synchronized 块如果有多 个,则不可以同时执行。
根据参数的选择方法的不同,@synchronized 会在并行执行的受限对象和可以执行的普通对象之 间动态切换。下面展示 @synchronized 参数的使用示例。
(a) 是指定只能单独存在的对象时的情景。同一个对象在其他地方也作为 @synchronized 的参数 使用时,所有这些块不能同时执行。(b) 也是一样,因为限制了参数的使用范围,互斥对象显然只能 是该方法内的块。
(c) 是各个实例互斥的例子。一个实例一次只能执行一个线程,同一类别的其他实例则多个线程可以同时存在。(d) 在参数对象可能在多个地方更改的情况下有效,但以同样方式使用该对象的所有 场所中都需要按照该方式书写,否则就没有任何意义。
而且,也可以按照 (e) 的方式书写。此外还可以指定类对象,或者使用消息选择器(隐藏参数的 _cmd)来指定方法等。不过一般情况下,为互斥的对象使用专门的锁对象是比较可靠的方法。
使用 @synchronized 块时,加锁和解锁必须成对进行,因此可以防止加锁后忘记解锁这种问题的 发生。和普通的锁相比,复杂的并行算法的书写会较为复杂,但多数情况下都会使互斥更容易理解。
另外,如果你想一起进阶,不妨添加一下交流群1012951431,选择加入一起交流,一起学习。期待你的加入!