OS X 和 iOS 中的多线程技术
2014-08-11
OS X 和 iOS 中的多线程技术
本文梳理了 OS X 和 iOS 系统中提供的多线程技术。并且对这些技术的使用给出了一些实用的建议。
多线程技术
我们为何需要多线程呢?多线程其实是为了实现并发执行,而且线程是并发执行多个代码路径的多种技术之中比较轻量级的一种(对应较重的实现是多进程)。
在单核 CPU 时代,支持多线程的操作系统会通过分配 CPU 计算时间,来实现软件层面的多线程。创建线程,线程间切换都是有成本开销的。但由于多线程可以避免阻塞所造成的 CPU 计算时间浪费,所以多线程所带来的开销成本总体看来是值得的。任务一般都可以被拆分成多个子任务,如果一个子任务发生了阻塞,计算时间就可以分配给其他子任务。这样就提高了 CPU 的利用率。
在多核 CPU 时代,就更好理解了。由于硬件上就支持多线程技术,就可以让多个线程真正同时地运行。如果任务能够被拆分,各个子任务就能并行地在 CPU 上运行,这就能显著加快运行速度。
总结说来,多线程的目的是,通过并发执行提高 CPU 的使用效率,进而提高程序运行效率。
OS X 和 iOS 是多线程操作系统,它们追随 UNIX 系统,使用了 POSIX 线程模型。OS X 和 iOS 都提供了一套底层的 C 语言 POSIX 线程 API 来创建和管理线程。但实际应用开发中,除非需要跨平台,我们并不会经常直接使用 POSIX 线程 API,而是使用系统或语言提供的其他一些更为简单的方案,下一节中会讨论它们。
Objective-C 中实现多线程
performSelectors
NSObject 提供了以 performSelector 为前缀的一系列方法。它们可以让用户在指定线程中,或者立即,或者延迟执行某个方法调用。这个方法给了用户实现多线程编程最简单的方法。下面有一些例子:
在当前线程中执行方法:
- (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay
- (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay inModes:(NSArray *)modes
在指定线程中执行方法:
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thread withObject:(id)arg waitUntilDone:(BOOL)wait
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thread withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array
在主线程中执行方法:
- (void)performSelectorOnMainThread: (SEL)selector withObject:(id)argument waitUntilDone:(BOOL)wait
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array
在后台线程中执行方法:
- (void)performSelectorInBackground:(SEL)aSelector withObject:(id)arg
这一系列方法简单易用,但只提供了有限的几个选择:
- 指定执行的方法(但传入方法的参数数量有限制);
- 指定是在当前线程,还是在主线程,还是在后台线程执行;
- 指定是否需要阻塞当前线程等待结果。
例如,以下代码使得方法 foo: 在一个新的后台线程执行,并传入了 object 参数:
SEL selector = @selector(foo:);
[self performSelectorInBackground:selector withObject:object];
以下代码使得 updateUI 方法在主线程内得到执行,并且当前线程会被阻塞,直到主线程执行完该函数:
[self performSelectorOnMainThread:@selector(updateUI) withObject:nil waitUntilDone:YES];
NSThread
NSThread 是 OS X 和 iOS 都提供的一个线程对象,它是线程的一个轻量级实现。在执行一些轻量级的简单任务时,NSThread 很有用,但用户仍然需要自己管理线程生命周期,进行线程间同步。线程状态,依赖性,线程间同步等线程相关的主题 NSThread 都没有涉及。比如,涉及到线程间同步仍然需要配合使用 NSLock,NSCondition 或者 @synchronized。所以,遇到复杂任务时,轻量级的 NSThread 可能并不合适。
提供一个模拟多线程运作的简单例子:两个人同时一起到烤箱抢面包。我们启动两个线程,来代表两个人。由于烤箱门比较小,同时只能有一个人去拿面包。由于 NSThread 不处理线程同步,所以为了模拟这个过程, 你还需要一把线程锁(即类型为 NSLock 的实例变量 _lock)。在后面的 run 方法中会用到这把线程锁:
_lock = [[NSLock alloc] init];
NSThread *geroge = [[NSThread alloc] initWithTarget:self
selector:@selector(run)
object:nil];
[geroge setName:@"Geroge"];
[geroge start];
NSThread *totty = [[NSThread alloc] initWithTarget:self
selector:@selector(run)
object:nil];
[totty setName:@"Totty"];
[totty start];
受到线程锁保护的拿面包过程可以用下面的 run 方法表示:
- (void)run {
while (TRUE) {
[_lock lock];
if(_cake > 0){
[NSThread sleepForTimeInterval:0.5];
_cake--;
_occupied = kSum - _cake;
NSLog(@"Taken by %@\nCurrent free:%ld, occupied:%ld", [[NSThread currentThread] name], _cake, _occupied);
}
[_lock unlock];
}
}
NSOperation
NSOperation 做的事情比 NSThread 更多一些。通过继承 NSOperation,可以使子类获得一些线程相关的特性,进而可以安全地管理线程生命周期。比如,以线程安全的方式建立状态,取消线程。配合 NSOperationQueue,可以控制线程间的优先级和依赖性。这就给出了一套线程管理的基本方法。
NSOperation 代表了一个独立的计算单元。一般,我们会把计算任务封装进 NSOperation 这个对象。NSOperation 是抽象类,但同时也提供了两个可以直接使用的实体子类:
- NSInvocationOperation 用于将计算任务封装进方法;
- NSBlockOperation 用于将计算任务封装进 block。
NSOperationQueue 则用于执行计算任务,管理计算任务的优先级,处理计算任务之间的依赖性。NSOperation 被添加到 NSOperationQueue 中之后,队列会按优先级和进入顺序调度任务,NSOperation 对象会被自动执行。
仍然使用上一节 NSThread 中的模拟两人抢面包的例子。由于计算任务没有变化,所以 run 方法并不改变。但这里需要使用 NSOperation 和 NSOperationQueue 来代表两个抢面包的人,并给予他们不同的优先级。由于 NSOperation 也不处理线程间同步问题,所以你仍然需要一把在 run 方法中会用到的线程锁:
_lock = [[NSLock alloc] init];
NSInvocationOperation *geroge = [[NSInvocationOperation alloc]initWithTarget:self
selector:@selector(run:)
object:@"Geroge"];
geroge.queuePriority = NSOperationQueuePriorityHigh;
NSInvocationOperation *totty = [[NSInvocationOperation alloc]initWithTarget:self
selector:@selector(run:)
object:@"Totty"];
totty.queuePriority = NSOperationQueuePriorityLow;
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue setMaxConcurrentOperationCount:2];
[queue addOperation:geroge];
[queue addOperation:totty];
NSOperation 提供以下任务优先级,以这些优先级设置变量 queuePriority 即可加快或者推迟操作的执行:
- NSOperationQueuePriorityVeryHigh
- NSOperationQueuePriorityHigh
- NSOperationQueuePriorityNormal
- NSOperationQueuePriorityLow
- NSOperationQueuePriorityVeryLow
NSOperation 使用状态机模型来表示状态。通常,你可以使用 KVO(Key-Value Observing)观察任务的执行状态。这是其他多线程工具所不具备的功能。NSOperation 提供以下状态:
- isReady
- isExecuting
- isFinished
NSOperation 对象之间的依赖性可以用如下代码表示:
[refreshUIOperation addDependency:requestDataOperation];
[operationQueue addOperation:requestDataOperation];
[operationQueue addOperation:refreshUIOperation];
除非 requestDataOperation 的状态 isFinished 返回 YES,不然 refreshUIOperation 这个操作不会开始。
NSOperation 还有一个非常有用功能,就是“取消”。这是其他多线程工具(包括后面要讲到的 GCD)都没有的。调用 NSOperation 的 cancel: 方法即可取消该任务。当你知道这个任务没有必要再执行下去时,尽早安全地取消它将有利于节省系统资源。
GCD
GCD(Grand Central Dispatch)是 Apple 公司为了提高 OS X 和 iOS 系统在多核处理器上运行并行代码的能力而开发的一系列相关技术,它提供了对线程的高级抽象。GCD 是一整套技术,包含了语言级别的新功能,运行时库,系统级别的优化,这些一起为并发代码的执行提供了系统级别的广泛优化。所以,GCD 也是 Apple 推荐的多线程编程工具。
GCD 是系统层面的技术,除了可以被系统级应用使用,也可以被更普通的高级应用使用。使用 GCD 之后,应用就可以轻松地在多核系统上高效运行并发代码,而不用考虑繁琐的底层问题。GCD 在系统层面工作,能很好地满足所有应用的并行运行需求,将可用系统资源平衡地分配给它们。
GCD 提供了一套纯 C API。但是,它提供的 API 简单易用并且有功能强大的任务管理和多线程编程能力。GCD 需要和 blocks(Objective-C 的闭包)配合使用。block 是 GCD 执行单元。GCD 的任务需要被拆解到 block 中。block 被排入 GCD 的分发队列,GCD 会为你排期运行。GCD 创建,重用,销毁线程,基于系统资源以它认为合适的方式运行每个队列。所以,用户需要关心的细节并不多。
GCD 的使用也很简单,假设抢面包是个耗时操作,前面例子中的 Geroge 和 Totty 的工作都可以实现如下:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// 并发队列中做耗时操作
while (TRUE) {
if(_cake > 0) {
// 耗时操作
[NSThread sleepForTimeInterval:0.5];
_cake--;
_occupied = kSum - _cake;
} else {
break;
}
}
// 主队列中刷新界面
dispatch_async(dispatch_get_main_queue(), ^{
[self updateUI];
});
});
GCD 分发队列
GCD 分发队列是执行任务的有力工具。使用分发队列,你可以异步或者阻塞执行任意多个 block 的代码。你可以使用分发队列来执行几乎任何线程任务。GCD 提供了简单易用的接口。
在 GCD 中存在三种队列:
1 串行分发队列(Serial dispatch queue)
串行分发队列又被称为私有分发队列,按顺序执行队列中的任务,且同一时间只执行一个任务。串行分发队列常用于实现同步锁。下面代码创建了一个串行分发队列:
dispatch_queue_t serialQueue = dispatch_queue_create("com.example.MyQueue", NULL);
2 并发分发队列(Concurrent dispatch queue)
并发分发队列又被称为全局分发队列,也按顺序执行队列中的任务,但是顺序开始的多个任务会并发同时执行。并发分发队列常用于管理并发任务。下面代码创建了一个并发分发队列:
dispatch_queue_t concurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
3 主分发队列(Main dispatch queue)
主分发队列是一个全局唯一的特殊的串行分发队列。队列中的任务会被在应用的主线程中执行。主分发队列可以用于执行 UI 相关的操作。取得主分发队列的方法:
dispatch_queue_t mainQueue = dispatch_get_main_queue();
GCD 任务执行方式
GCD 中有两种任务执行方式:
- 异步执行, dispatch_async,意味将任务放入队列之后,主线程不会等待 block 的返回结果,而是立即继续执行下去。
- 阻塞执行, dispatch_sync,意味将任务放入队列之后,主线程被阻塞,需要等待 block 的执行结果返回,才能继续执行下去。
GCD 的其他主题
GCD 有着丰富的功能,比如分发组(dispatch group),信号(semaphores),分发栅栏(dispatch barrier),分发源(dispatch source)等等。这些可以用于完成更复杂的多线程任务。详细可以查阅 Apple 关于 GCD 的文档。
使用建议
在能够使用 GCD 的地方,尽量使用 GCD
Apple 公司宣称其在 GCD 技术中为更好地利用多核硬件系统做了很多的优化。所以,在性能方面 GCD 是不用担心的。而且 GCD 也提供了相当丰富的 API,几乎可以完成绝大部分线程相关的编程任务。所以,在多线程相关主题的编程中,GCD 应该是首选。下面举一些可以推荐使用 GCD 的实际例子:
1 使用 GCD 的 dispatch queue 实现同步锁
同步锁的实现方案有不少,比如,如果仅仅是想对某个实例变量的读写操作加锁,可以使用属性(property)的 atomic 参数,对于一段代码加锁可以使用 @synchronized 块,或者 NSLock。
@synchronized 和 NSLock 实现的同步锁:
// Method 1
- (void)synchronizedMethod {
@synchronized(self) {
// safe
}
}
// Method 2
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
[_lock lock];
// safe
[_lock unlock];
}
@synchronized 一般会以 self 为同步对象。重复调用 @synchronized(self) 是很危险的。如果多个属性这么做,每一个属性将会被和其它所有属性同步,这可能并不是你所希望的,更好的方法是每个属性的锁都是相互独立的。
另一种方法是使用 NSLock 实现同步锁,这个方法不错,但是缺点是在极端环境下同步块可能会导致锁死,而且这种情况下处理锁死状态会有麻烦。
一个替代方法是使用 GCD 的分发队列。将读和写分发到相同并发队列中,这样读操作会是并发的,多个线程可以同时执行读操作;而对于写操作,以分发栅栏(dispatch barrier)保证同时只有一个线程可以执行写操作,并且由于写操作无需返回,写操作还是异步马上返回的。这样,就得到了一个高效且线程安全的锁。代码看起来会像这样:
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSInteger)cake {
__block NSInteger localCake;
dispatch_sync(_syncQueue, ^{
localCake = _cake;
});
return localCake;
}
- (void)setCake:(NSInteger)cake {
dispatch_barrier_async(_syncQueue, ^{
_cake = cake;
});
}
简单而言,上面的代码可以使读操作被竞争执行;写操作被互斥执行,并且异步返回。使用 GCD 实现的这个同步锁应该是效率最优且最安全的。
2 使用 GCD 替代 performSelector 系列方法
NSObject 的 performSelector 系列方法有很多限制。传给要执行的方法的参数的数量是有限制的,也没法方法保证能正确地取得要执行的方法的返回值。这些限制在使用 block 的 GCD 中都不存在。
下面是使用 GCD 替代 performSelector 的例子。使用 performSelector 系列方法:
[self performSelector:@selector(cake)
withObject:nil
afterDelay:5.0];
使用 GCD 完成相同的事情:
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^(void){
[self cake];
});
3 使用 dispatch_once 实现线程安全单一执行要求
线程安全单一执行典型例子是单例,GCD 的 dispatch_once 能够保证传入的 block 被线程安全地唯一执行:
+ (id)sharedInstance {
static AdivseDemoController *sharedInstance = nil;
static dispatch_once_t onceToken = @"token";
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
这是现在 Objective-C 中实现单例较为推荐的一种方法。
在需要更细粒度控制线程时,考虑 NSOperation
GCD 虽然在很多地方值得提倡,但并不是任务管理和多线程地唯一解决方案,并不是说所有的地方都应该使用 GCD。GCD 是一个纯 C API,NSOperation 是 Objective-C 类,在一些地方对象编程是有优势的。NSOperation 也提供了一些 GCD 无法实现,或者 GCD 所没有的功能。
以下是你需要考虑使用 NSOperation 的一些理由:
- 当你需要取消线程任务时,GCD 无法提供取消任务的操作。而 NSOperation 提供了取消任务的操作;
- 当你需要更细的粒度地观察任务改变了状态时,由于 NSOperation 是一个对象,比较 GCD 使用的 block 而言,通过对 NSOperation 对象进行键值观察(KVO)能很容易观察到任务的状态改变;
- 当你需要重用线程任务时,NSOperation 作为一个普通的 Objective-C 对象,可以存储任何信息。对象就是为重用而设计的,这时,NSOperation 比 GCD 使用的 block 要更方便。
总结
OS X 和 iOS 系统提供了丰富的多线程工具。这些工具中,最新最现代的是 GCD(Grand Central Dispatch)。GCD 也是 Apple 公司推荐的多线程解决方案。所以,对于多线程技术的选择,总结下来有这两条建议:
- 能够使用 GCD 的地方,尽量使用 GCD;
- 在需要更细粒度控制线程时,考虑 NSOperation。