前言
随着手机硬件的升级,多线程技术在应用开发中的地位可以说足以媲美UITableView
了。然而,多线程技术在提供我们生产力的同时,也不可避免的带来了陷阱,正如著名计算机学者所言:能力越大,bug越大
本文尝试从多个角度聊聊这些陷阱。
内存占用
线程的创建需要占用一定的内核物理内存以及CPU
处理时间,具体消耗参见下表。
类型 | 消耗估算 | 详情 |
---|---|---|
内核结构体 | 1KB | 存储线程数据结构和属性 |
栈空间 | 子线程(512KB) Mac主线程(8MB) iOS主线程(1MB) |
堆栈大小必须为4KB的倍数 子线程的最小内存为16KB |
创建时间 | 90微秒 | 1G内存 Intel 2GHz CPU Mac OS X v10.5 |
此外在CPU
上切换线程上下文的花销也是不廉价的,这些花销体现在切换线程上下文时更新寄存器、寻址搜索等。这两种花销在并发编程时,可能会出现非常明显的性能下降。
共享资源
对于使用共享资源的陷阱主要发生在两点:线程竞争以及锁
-
线程竞争
多个线程同时对共有的资源进行写操作时,会产生数据错误,这种错误难以被发现,可能会导致应用无法继续正常运行。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
for (int idx = 0; idx < 100; idx++) {
_flag--;
}
});dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ for (int idx = 0; idx < 100; idx++) { _flag++; } });
-
锁的开销
锁是为了解决线程竞争错误设计的方案,提供了不同的方式保证多个线程对共享资源的访问限制。iOS
提供了多种线程锁供我们使用,具体的锁在这里就不再阐述。锁的操作不当会导致死锁出现,从而使得整个线程无法继续执行。
- (int)recursiveToCalculateSum: (int)number {
[_lock lock];
_sum += (number <= 1 ? 1 : [self recursiveToCalculateSum: number - 1]);
[_lock unlock];
}
线程死锁
do_task_in_target_queue(target, ^{
shared = SEM_GET_SHARED(sem);
sem_wait(shared);
task();
sem_post(shared);
});
事实上sync
操作是个无限等待的加锁操作,所以当sync
到当前线程的时候引发的是死锁问题。这也是为什么线程死锁实际上并非同步队列的问题,只是一个简单的死锁。
线程保活
线程的释放是个不容易被注重到的细节,我们都知道NSTimer
的准确度在很多时候不尽人意,为了提高精确度,很多人会在子线程启动RunLoop
保活(全局线程不存在释放上的问题)。比如著名的AFNetworking
启用了一个空的NSPort
端口保证回调线程保活:
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
[[NSThread currentThread] setName:@"AFNetworking"];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
在苹果官方文档中,启动RunLoop
的有三种方式:
- [NSRunLoop run];
- [NSRunLoop runUntilDate: [NSDate date]];
- [NSRunLoop runMode: NSRUnloopDefaultModes beforeDate: [NSDate date]];
除了后面两者之外,第一种方式必须调用kill
的方式杀死它才能结束,这也是不当使用RunLoop
的陷阱之一。采用CFRunLoopRef
的相关方法完成启动和停止是一种更好的做法。
CFRunLoopRun();
CFRunLoopStop(CFRunLoopGetCurrent());
队列优先级
#define DISPATCH_QUEUE_PRIORITY_HIGH 2
#define DISPATCH_QUEUE_PRIORITY_DEFAULT 0
#define DISPATCH_QUEUE_PRIORITY_LOW (-2)
#define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN
如果按照从低到高
的顺序向这四个队列里面派发大量的日志输出任务,可以看到在运行没有多久的时间后,DISPATCH_QUEUE_PRIORITY_HIGH
的任务会比提前于调用次序运行,而DISPATCH_QUEUE_PRIORITY_BACKGROUND
总是接近最后执行完成的,这种资源抢占被称作设计良好的多线程方案会优先执行高优先级的任务,在遇到多种优先级任务处理的时候,可能会发生优先级反转
。优先级反转
(详见评论处),这时候OSSpinLock
自旋锁会因为这种反转变得不安全。
另一个问题是Custom Queue
的线程优先级总是为DISPATCH_QUEUE_PRIORITY_DEFAULT
,这意味着在某些时刻可能我们在创建的串行队列上执行的任务也不一定是安全的。iOS8
之后为自定义线程提供了QualityOfServer
用来标志线程优先级。
typedef NS_ENUM(NSInteger, NSQualityOfService) {
NSQualityOfServiceUserInteractive = 0x21,
NSQualityOfServiceUserInitiated = 0x19,
NSQualityOfServiceDefault = -1
NSQualityOfServiceUtility = 0x11,
NSQualityOfServiceBackground = 0x09,
}
LXD_INLINE dispatch_queue_attr_t __LXDQoSToQueueAttributes(LXDQualityOfService qos) {
dispatch_qos_class_t qosClass = __LXDQualityOfServiceToQOSClass(qos);
return dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, qosClass, 0);
};
这意味着开发者对于多线程通过使用搭配不同优先级的自定义串行队列来更灵活的完成任务。
并发噩梦
系统本身提供了四种优先级的并行队列
给开发者使用,这意味着当我们async
任务到这些全局线程中执行的时候,为了充分的发挥CPU
的执行效率,GCD
可能会多次创建线程来执行新的任务。
方便意味着隐藏的代价。试想一下这个场景,当前CPU
核心正在执行一个IO
操作,然后进入等待磁盘响应的状态。在这个时间点上,CPU
核心是处在未利用的状态下的。这时候GCD
一看:丫的偷懒?然后创建一个新的线程执行任务。假如派发的任务总是耗时的,且需要等待响应。那么GCD
会不断的创建新的线程来充分利用CPU
。当线程创建的足够多的时候,GCD
会尝试释放线程来减少压力。但是由于线程中的IO
操作并没有执行完成,因此导致大量的线程无法释放,占据了大量的内存使用。
for (NSInteger idx = 0; idx < N; idx++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString * filePath = [self filePathWithFileName: fileName];
NSData * data = [NSData dataWithContentsOfFile: filePath];
/// do something
});
}