风行的博客

iOS 多线程开发

多线程 - 合理的线程分配能够提高程序的执行效率和资源利用率。

GCD 基于 C API,Operation Queue 是在 GCD 基础上实现的,效率上较 GCD 会差一点点,但代码可读性和易用性较高,简单的任务可用 GCD 去实现,复杂一些的还是推荐使用 Operation Queue 来处理。微小的性能提升远不如写出可维护性高的代码来的实在。

一些概念

  • 进程和线程
    • 进程是一个可执行程序,至少有一个线程,也可以包含多个线程
    • 线程在进程中负责执行任务,每个进程至少有一个线程(主线程)
  • 主线程和子线程
    • 主线程是随着进程的创建而创建的,主线程结束,其他线程也会结束
    • 子线程由其他线程创建,一般子线程退出不会影响主线程
  • 同步和异步
    • 同步任务在执行过程中会阻塞当前线程,直到任务执行完毕
    • 异步任务在执行过程中不会阻塞当前线程
  • 串行队列和并行队列
    • 串行队列中装载的线程是按进队列顺序一个一个执行
    • 并行队列中装载的线程可以一起执行,可以通过设置最大并发数,限制同时最多执行几个线程
  • 并发和并行
    • 并发在多核设备上是真正意义上的多任务同时运行,而在单核设备上是以分时共享的方式同时执行多个任务,需要先运行一个线程,执行一个上下文切换,然后运行另一个线程或进程,因为这个过程很快所以会给我们并发执行的错觉
    • 并行是真正意义上的多任务同时运行
  • 优先级和优先级反转
    • 正常情况下优先级高的任务会比优先级低的先执行
    • 优先级反转指程序在运行时,低优先级的任务由于某种原因不能释放掉锁,所以阻塞了高优先级的任务,而其他不需要该共享资源的低优先级任务会在高优先级任务之前执行,从而反转了任务的优先级。设置任务的优先级会让本来就复杂的并行编程变得更加复杂和不可预见,所以建议只使用默认优先级。
  • 生产者和消费者模式:是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。我们还可以使用多个线程来生产数据,同样可以使用多个消费线程来消费数据。而更复杂的情况是,消费者消费的数据,有可能需要继续处理,于是消费者处理完数据之后,它又要作为生产者把数据放在新的队列里,交给其他消费者继续处理。
  • 为了防止多个线程抢夺同一个资源造成数据安全问题可采取以下措施
    • 锁:在同一时刻,只允许一个线程访问某个特定资源
      • NSLock:最基本的锁,通过 lock 和 unlock 加锁解锁,@synchronize 会自动加锁解锁,但性能稍差
      • NSRecursiveLock:递归锁,可以在一个线程中反复获取锁不会造成死锁,可用在循环或递归操作中
      • NSConditionLock:条件锁,为加锁/解锁设置条件
    • 信号量:和锁很像,区别在于它可以控制同时访问同一资源的线程个数
    • 串行队列:一个一个执行就避免了同步问题
  • 死锁:当多个线程在相互等待对方结束时,就会发生死锁,在下面的例子中,大多数时候 swap 方法都能正常运行,但是当两个线程使用相反的值来同时调用 swap 时,程序就会很可能由于死锁而被终止。线程1 获得了 X 的一个锁,线程2 获得了 Y 的一个锁。接着它们会同时等待另外一把锁,但是永远都不会获得。所以我们要尽量减少线程间资源共享,并确保共享的资源尽量简单。
1
2
3
4
5
6
7
8
9
10
11
12
13
void swap(A, B)
{
    lock(A);
    lock(B);
    
    ...
    
    unlock(B);
    unlock(A);
}

swap(X, Y); // 线程1
swap(Y, X); // 线程2


NSThread

使用 NSThread 创建并操作线程在使用上相对简单一些,但是需要我们自己去管理线程的生命周期。所以总的来说易用性上不如 GCD,功能上不如 Operation Queues。

创建并启动线程

1
2
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run:) object:nil];
[thread start];

线程通讯

1
2
3
4
5
6
- (void)performSelectorOnMainThread:withObject:waitUntilDone:modes:;
- (void)performSelectorOnMainThread:withObject:waitUntilDone:;
- (void)performSelector:onThread:withObject:waitUntilDone:modes:;
- (void)performSelector:onThread:withObject:waitUntilDone:;

// perform 方法只对拥有 RunLoop 的线程有效,如果创建的线程没有添加 RunLoop,perform 的 selector 将无法执行


GCD(Grand Central Dispatch)

GCD 是苹果为多核的并行计算提出的解决方案,它会自动地利用更多的 CPU 内核(比如双核、四核),最重要的是它会自动管理线程的生命周期(创建线程、调度任务、销毁线程)。同时它基于 C 语言,使用 Block 方式,使用起来更加方便灵活。

最常用的使用方式

1
2
3
4
5
6
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
     // 异步耗时操作
     dispatch_async(dispatch_get_main_queue(), ^{
          // 由于 UIKit 中大部分类都不是线程安全的,所以需要在主队列中处理 UI
     });
});
  • GCD 有 5个不同队列:主队列,3个不同优先级的全局队列,以及一个优先级更低的后台队列(用于 I/O),我们还可以自己创建队列。通常不建议给队列设置优先级,因为多任务在访问共享资源时,可能会造成优先级反转问题。

  • 获取主队列:主队列是串行队列,所以比较适合处理共享资源、更新 UI 等事情

1
dispatch_queue_t queue = dispatch_get_main_queue();
  • 获取不同优先级的全局队列:全局队列是并行队列,支持数百个线程执行
1
2
3
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
  • 如果经常需要在后台队列上执行开销庞大的操作,可通过 dispatch_queue_create 函数创建新队列,它接收两个参数,第一个是标识符,可以在 Instruments 或 lldb 调试程序时查看队列名字,第二个参数用来表示创建的队列是串行还是并行,DISPATCH_QUEUE_SERIAL 或 NULL 表示串行,DISPATCH_QUEUE_CONCURRENT 表示并行。
1
2
3
4
NSString *queueLabel = [NSString stringWithFormat:@"%@.%p.queue1", [self class], self];

dispatch_queue_t queue = dispatch_queue_create([queueLabel UTF8String], DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue = dispatch_queue_create([queueLabel UTF8String], DISPATCH_QUEUE_CONCURRENT);
  • 创建任务并交给队列处理
1
2
3
4
5
6
7
8
9
// 异步任务
dispatch_async(dispatch_get_main_queue(), ^{
  ...
});

// 同步任务
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
  ...
});
  • 同步任务很容易造成死锁,主要发生在串行队列中,并且同步任务所在队列为当前队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// dispatch_sync 会立即阻塞主线程,然后把 Block 中的任务放到主队列中,主队列会将任务取出放到主线程中执行,
// 但主线程这个时候已经被阻塞了,所以 Block 中的任务就不能完成,而 dispatch_sync 也会一直阻塞主线程,
// 从而造成死锁现象,导致主线程一直卡死。
dispatch_sync(dispatch_get_main_queue(), ^{
  ...
});

// 同上
dispatch_queue_t queue = dispatch_queue_create("queue1", NULL);
dispatch_async(queue, ^{
  dispatch_sync(queue, ^{
      ...
    });
});
  • NSTimer 在使用时会受 RunLoop 影响而导致延迟触发,当有更精准的计时需求时,可用 GCD 的计时器
1
2
3
4
5
6
7
8
9
10
11
12
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), 1.0 * NSEC_PER_SEC, 0);// 每秒触发一次

dispatch_source_set_event_handler(timer, ^{
  dispatch_async(dispatch_get_main_queue(), ^{
      if(...) {
              dispatch_source_cancel(timer); // 停止
          }
      });
});

dispatch_resume(timer); // 启动
  • dispatch_once 可以控制 Block 中的代码只被执行一次,通常用于创建单例,如果需要完整的单例,不被人 alloc 还需要进一步处理
1
2
3
4
5
6
7
8
9
10
+ (instancetype) sharedInstance {
    static dispatch_once_t pred = 0;
    static ClassName *sharedObject = nil;

    dispatch_once(&pred, ^{
        sharedObject = [[self alloc] init];
    });

    return sharedObject;
}
  • 延迟处理,类似于 performSelector:withObject:afterDelay:,可指定时间和处理队列
1
2
3
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
  ...
});
  • 当使用 foreach 遍历数组遇到效率问题时,可以考虑用 dispatch_apply 进行快速迭代,dispatch_apply 是 dispatch_sync 和 Dispatch Group 的关联 API,所以它会阻塞当前线程
1
2
3
4
5
dispatch_apply(array.count, dispatch_get_global_queue(0, 0), ^(size_t index) {
  NSLog(@"%zu", index); // 由于任务在并发队列中,所以 index 为乱序
});

NSLog(@"done"); // 由于当前线程被阻塞,所以迭代结束后,此句才会执行
  • iOS8 开始支持取消任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
dispatch_queue_t serialQueue = dispatch_queue_create("queue1", DISPATCH_QUEUE_SERIAL);

dispatch_block_t firstBlock = dispatch_block_create(0, ^{
  ...
});

dispatch_block_t secondBlock = dispatch_block_create(0, ^{
  ...
});

dispatch_async(serialQueue, firstBlock);
dispatch_async(serialQueue, secondBlock);

dispatch_block_cancel(secondBlock);
  • 多任务并发,都完成后触发另一个任务,可通过队列组实现,例如同时下载 2张图片,都下载完成后再将他们拼接起来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 任务1
dispatch_group_async(group, queue, ^{
    // 下载资源1
});

// 任务2
dispatch_group_async(group, queue, ^{
    // 下载资源2
});

// 任务1 和任务2 都完成后会自动通知
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    // 处理资源1和资源2
});
  • dispatch_barrier_async 在执行任务时会确保队列在此过程不会执行其它任务,可以在适当时候用来解决同步问题,它只在自己创建的队列上有这种作用,在全局并发队列和串行队列上,效果和 dispatch_sync 一样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dispatch_queue_t queue = dispatch_queue_create("queue1", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue, ^{
    ...
});
dispatch_async(queue, ^{
    ...
});

// 在 dispatch_barrier_async 前面的任务执行结束后才执行,后面的任务等它执行完成后才会执行
dispatch_barrier_async(queue, ^{
    ...
});

dispatch_async(queue, ^{
    ...
});
dispatch_async(queue, ^{
    ...
});

  • 利用 dispatch_semaphore_t 信号量限制任务并发数,dispatch_semaphore_wait 函数会消耗一次这个可用数,如果可用数已满则开始等待,dispatch_semaphore_signal 函数每次执行都会将该可用计数加 1,以此来表明已经释放了资源,如果此刻有因为等待可用资源而被阻塞的任务,系统会从等待的队列中解锁一个任务来执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);// 只有一个停车位

if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)))) {
    NSLog(@"不等了,开走");
} else {
    NSLog(@"第一辆车来了,有位置直接停"); // 1
}

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)))) {
        NSLog(@"不等了,开走");
    } else {
        NSLog(@"第二辆车,等到有位置后停车"); // 3
    }
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(2);
    NSLog(@"第一辆车开走,这时第二辆车准备停车"); // 2
    dispatch_semaphore_signal(semaphore);
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)))) {
        NSLog(@"第三辆车不等了,开走"); // 4
    } else {
        NSLog(@"有位置,停车");
    }
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(5);
    NSLog(@"第二辆车开走,第三辆车没有继续等"); // 5
    dispatch_semaphore_signal(semaphore);
});


Operation Queues

GCD 提供了更加底层的控制,而操作队列则在 GCD 之上实现了一些方便的功能,这些功能对于我们来说通常是最好最安全的选择,使用时需要将任务封装到一个 NSOperation 对象中,再将它添加到 NSOperationQueue,系统会将 NSOperationQueue 中的 NSOperation 取出并放到线程上执行。

  • NSOperationQueue 默认是串行队列,可通过设置 maxConcurrentOperationCount 实现并发
1
2
3
4
5
// 获取主队列
NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];

// 创建队列
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
  • NSOperation 不能够直接使用,可通过它的两个子类来封装任务,或者自定义 Operation
    • NSInvocationOperation:通过 SEL 方式添加任务
    • NSBlockOperation:通过 Block 方式添加任务
    • 自定义 Operation:需要继承 NSOperation 类,并实现 main 方法
  • NSOperation 有一个非常实用的功能,可以添加和解除依赖。但要注意,不能相互依赖,否则会造成死锁,例如 A 依赖 B,B 依赖 A。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NSBlockOperation *operation1 = [NSBlockOperation blockOperationWithBlock:^{
   // 下载资源1
}];

NSBlockOperation *operation2 = [NSBlockOperation blockOperationWithBlock:^{
    // 下载资源2
}];

NSBlockOperation *operation3 = [NSBlockOperation blockOperationWithBlock:^{
    // 处理资源1和资源2
}];

[operation3 addDependency:operation1];      // 任务三依赖任务一
[operation3 addDependency:operation2];      // 任务三依赖任务二

[[NSOperationQueue mainQueue] addOperations:@[operation3, operation2, operation1] waitUntilFinished:NO];