iOS 开发 - 多线程陷阱

2,407 阅读6分钟

前言

随着手机硬件的升级,多线程技术在应用开发中的地位可以说足以媲美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];
      }
    

线程死锁

线程死锁与锁的死锁是两个概念,但其原因其实是一样的。当我们同步派发任务到当前队列执行的时候。队列堵塞,等待派发任务的执行。但由于队列堵塞,派发任务永远无法执行,形成一个死循环。通过libdispatch的源码我们可以发现实际上sync内部是个信号加锁操作,且 sync对于global_queue和自定义队列来说是直接执行,不会将任务压入栈中。其代码可以表示为:

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());

队列优先级

更高优先级的任务在能更好的抢占CPU资源,这导致了低优先级方案在处理任务加锁时可能导致被抢占执行,从而导致锁无法正常打开,导致另一种特殊的死锁。在不再安全的OSSpinLock中就提到了这一点。在GCD中系统创建了四种常驻并行队列,分别对应不同优先级的任务处理:

#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总是接近最后执行完成的,这种资源抢占被称作 优先级反转

另一个问题是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
    });
}

一旦这时候磁盘响应,开始读取数据,这些线程争夺CPU资源,占用的内存足以让开发者崩溃。解决方案之一是我在GCD封装中封装的串行队列执行方案,采用QoS对线程进行优先级设定,保证紧急任务优先得到处理。此外根据 CPU核心创建的等量串行可以保证CPU核心得到最大利用化以及避免了并发队列过度的线程创建。