多任务

用于处理多任务的主流 iOS 框架:

  • 运行循环(run loop)
  • 线程(Thread)
  • 操作(operation)
  • GCD(Grand Central Dispatch)

运行循环

多任务的最基本形式就是运行循环。每个 Cocoa 应用程序都由一个处于阻塞状态的 do/while 循环驱动,当有事件发生时,就把事件分派给合适的监听器,如此反复知道循环停止。处理分派的对象就叫做运行循环(NSRunloop)。

关于运行循环,最重要的是要明白运行循环其实就是一个大的 do/while 循环,它运行在某个线程中,从各种事件队列中取得事件(每次一个),然后把它分派给合适的监听器。这是 iOS 程序的核心。


以操作为中心的多任务的开发

正如我们所知的,设计使用线程的软件一般都会引入少量或者中等数量长期存在的线程,这些线程执行重量级的操作,比如网络操作、数据库操作或者计算。因为这些操作设计面很广,所以需要大量的输入输出,这就意味着要加锁,而加锁的的代价昂贵而且还可能导致大量的 bug。既然这样,就应该将锁的需求尽量降到最低。创建了很多线程最好需要一个线程池来管理。而线程之间的操作一般都会有顺序依赖关系,所以最好创建某种队列,确保它们按照你想要的顺序进行。

基于以上几点,选择 NSOpration ,将线程池和队列的管理交给系统处理,这样你就不用操心信号量和互斥锁机制,可以将精力主要放在业务上。

操作支持优先级、依赖关系和取消,方便统一管理,使用于一些比较复杂的操作场合,如 AFNetWorking、SDWebImage 等。

NSOperation 子类

NSOperation 是一个抽象类,可以使用系统提供的子类或者自己实现它的子类来完成多线程多任务的开发。

  1. NSInvocationOperation 较少使用
  2. NSBlockOperation 最常使用
  3. 自定义子类继承 NSOperation 很少使用

NSInvocationOperation

  • 直接执行操作(同步)

    /// 点击屏幕调用,创建一个操作并执行
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
        NSInvocationOperation *operation = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(demo:) object:@"这是一个参数"];
        [operation start];
    }
    /// 将参数与当前线程打印
    - (void)demo:(NSString *)str {
        NSLog(@"%@--%@",str,[NSThread currentThread]);
    }
    
    /*************************执行结果****************************/
    2015-09-17 15:11:54.030 NSOperationTest[2595:162235] 这是一个参数 <NSThread: 0x7fa759c173a0>{number = 1, name = main}
    

第3行代码创建初始化了一个 NSInvocationOperation 对象,并且根据一个对象(self)和selector 来创建操作,第4行代码执行操作 demo: 且传递了一个参数。默认情况下,调用了 start方法后并不会开一条新线程去执行操作,而是在当前线程同步执行操作。

只有将 operation 放到一个 NSOperationQueue 中,才会异步执行操作。

  • 将操作添加到NSOperationQueue执行

    /// 点击屏幕调用,创建一个操作并执行
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
        [self invocationTest];
    }
    /// 将操作添加到队列
    - (void)invocationTest {
        // 创建操作队列
        NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];
        // 创建操作(最后的object参数是传递给selector方法的参数)
        NSInvocationOperation *operation = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(demo:) object:@"这是一个参数"];
        // 将操作添加到操作队列
        [operationQueue addOperation:operation];
    }
    /// 将参数与当前线程打印
    - (void)demo:(NSString *)str {
        NSLog(@"%@--%@",str,[NSThread currentThread]);
    }
    
    /*************************执行结果****************************/
    2015-09-17 15:36:23.777 NSOperationTest[2943:182362] 这是一个参数--<NSThread: 0x7ff68af15b00>{number = 2, name = (null)}
    

根据打印结果,可以看出开启了一个线程执行操作,而且是异步执行的。

NSBlockOperation

  • 执行一个操作(同步)
1
2
3
4
5
6
7
8
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^(){
NSLog(@"%@",[NSThread currentThread]);
}];
// 开始执行任务
[operation start];

/*************************执行结果****************************/
2015-09-17 15:47:58.791 NSOperationTest[3015:191317] <NSThread: 0x7fe6abd02b70>{number = 1, name = main}

可以看到这种方法非常简单,有点类似于 GCD 的写法,是同步执行的。

  • 添加多个操作执行(异步)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 初始化一个对象
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^(){
NSLog(@"1:%@",[NSThread currentThread]);
}];
// 再添加3操作
[operation addExecutionBlock:^() {
NSLog(@"2:%@", [NSThread currentThread]);
}];
[operation addExecutionBlock:^() {
NSLog(@"3:%@", [NSThread currentThread]);
}];
[operation addExecutionBlock:^() {
NSLog(@"4:%@", [NSThread currentThread]);
}];
// 开始执行任务
[operation start];

/*************************执行结果****************************/
2015-09-17 15:55:48.372 NSOperationTest[3113:198447] 1:<NSThread: 0x7f9282f04e10>{number = 1, name = main}
2015-09-17 15:55:48.372 NSOperationTest[3113:198530] 2:<NSThread: 0x7f9282e081c0>{number = 2, name = (null)}
2015-09-17 15:55:48.372 NSOperationTest[3113:198532] 4:<NSThread: 0x7f9282c1a380>{number = 4, name = (null)}
2015-09-17 15:55:48.372 NSOperationTest[3113:198533] 3:<NSThread: 0x7f9282e0ec90>{number = 3, name = (null)}

当添加多个操作时,开启新线程异步执行。

自定义 NSOperation

自定义 NSOperation 最主要的就是重载 -(void)main 这一方法,在这个方法里面添加需要执行的操作。当执行这个操作时,系统会自动调用 -(void)main 这一方法。

#import "CustomOpertaionTest.h"

@implementation CustomOpertaionTest
- (void)main {
    // 新建一个自动释放池,避免内存泄露
    @autoreleasepool {
        // 执行的代码
        NSLog(@"这是一个测试:%@",[NSThread currentThread]);
    }
}
@end

可分为异步和同步调用

/********************1.直接执行,同步***************/
CustomOpertaionTest *operation = [[CustomOpertaionTest alloc] init];
// 开始执行任务
[operation start];

/*************************执行结果****************************/
2015-09-17 16:24:27.620 NSOperationTest[3368:222036] 这是一个测试:<NSThread: 0x7ff420d28000>{number = 1, name = main}

/*------------------------------------------------------*/

/********************2.添加到队列,异步***************/
NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];
CustomOpertaionTest *operation = [[CustomOpertaionTest alloc] init];
[operationQueue addOperation:operation];

/*************************执行结果****************************/
2015-09-17 16:27:13.594 NSOperationTest[3401:225178] 这是一个测试:<NSThread: 0x7ff2d0539d70>{number = 2, name = (null)}

其他常用方法

  • 取消操作,operation 开始执行之后,默认会一直执行操作直到完成为止,我们也可以调用 cancel 方法中途取消操作。

    [operation cancel];
    

所以在我们的自定义 operation 子类中,如果支持取消的话,则应该在重载的 main 函数中定期检查 isCancelled 属性,看看用户是否取消了操作,以便在收到退出请求的时候能够及时退出。特别是在 mian 函数中又有循环的时候一定要检查,这很重要!

- (void) mian {
  for(....){
    if (operation.isCancelled) {
        return ;
    }
  }
}

当 cell 和 operation 组合到一起用到的时候,因为 cell 有重用机制,所以在重用时要取消当前的所有 operation 操作。

- (void)prepareForReuse {
  [self.operations makeObjectsPerformSelector:@selector(cancel)];
  [self.operations removeAllObjects];
  self.imageView.image = nil;
  self.label.text = @"";
}

另外开启新线程后注意内存管理,最好在 main 函数里面的最外层创建自动释放池,保证每一个消息循环都能释放内存。

- (void)main {
    // 新建一个自动释放池,避免内存泄露
    @autoreleasepool {
        // 执行的代码
        NSLog(@"这是一个测试:%@",[NSThread currentThread]);
    }
}
  • 如果想在一个 NSOperation 完成之后做一些事情

    operation.completionBlock = ^() {
        // 所有操作执行完成后执行
    };
    
    • 设置最大并发数,默认情况设为 NSOperationQueueDefaultMaxConcurrentOperationCount 就好了,它会根据你系统的条件去设置相应的值。

      // 最大并发数为3
      [operationQueue setMaxConcurrentOperationCount:NSOperationQueueDefaultMaxConcurrentOperationCount];

  • 可以设置依赖来保证执行顺序,比如一定要让操作A执行完后,才能执行操作B,可以像下面这么写:

    [operationB addDependency:operationA];
    

但是一定要注意不要A依赖B,然后B又依赖A,这样A和B相互依赖造成都不能得到执行。

如果A和B处于不同的操作队列,也是可以设置依赖关系的。


用 GCD 实现多任务

目标队列和优先级

GCD 是比操作队列更底层的。NSOperationQueue 是在 GCD 的基础上实现的,基本的队列原理都差不多,只是你把块添加到分派队列而不是把 NSOperation 添加到 NSOperationQueue。

块添加到分派队列后就无法取消了(暂停还是可以的)。分派队列是严格的先进先出(FIFO)结构,所有无法在队列中使用优先级或者调整块的次序。如果需要这类特性的,一定要用 NSOperationQueue ,而不是用 GCD 重新发明轮子。

需要强调的一点是:分派队列就是队列,不是线程。不要认为队列是接受块的东西,队列是组织块的,调用 dispatch_async 不会让块运行,而只是把块添加到队列中。

GCD 队列的优先级:

  • DISPATCH_QUEUE_PRIORITY_HIGH
  • DISPATCH_QUEUE_PRIORITY_DEFAULT
  • DISPATCH_QUEUE_PRIORITY_LOW
  • DISPATCH_QUEUE_PRIORITY_BACKGROUND

这些队列都是并行的。

自己创建队列的时候会被附加到某一全局队列上。用 dispatch_set_target_queue 可以改变目标队列。

块被添加到队列后,就会按照添加的顺序执行,无法取消,也无法改变相对于队列中其他块的顺序。但还是可以插队的(←_←,不文明哦!)

- (void)viewDidLoad {
    [super viewDidLoad];

    dispatch_queue_t low = dispatch_queue_create("low", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t high = dispatch_queue_create("high", DISPATCH_QUEUE_SERIAL);

    dispatch_set_target_queue(low, high);

    dispatch_async(low, ^{
        for (int i = 0; i < 10; i++) {
            NSLog(@"i = %d",i);
            usleep(100*1000);
        }
    });


    dispatch_async(high, ^{
        for (int j = 0; j < 10; j++) {
            NSLog(@"j = %d",j);
            usleep(100*1000);
        }
        dispatch_resume(low);
    });

//    usleep(10*1000);
    dispatch_suspend(low);
}

自己可以测试一下上面的代码片段,是先输完 j 的值后再输出 i 的值的,插队成功;但如果你 sleep 个 10 毫秒的话,你会看到先输出完 i 的值然后才输出 j 的值,没错,suspend 不起作用了。原因是你不能停止正在执行的块。

另外有一点需要注意的是,dispatch_suspend 和 dispatch_resume 必须成对出现。

用分派屏障创建同步点

分派屏障(dispatch barrier)主要是用在并发队列上的,可以在并发队列内部创建一个同步锁,当它运行时,即时有并发的条件和空闲的处理核心,队列中的其他块也是不能运行的。听起来就像一个互斥(写入)锁,确实如此。没有屏障的块可以看做是共享(读取)锁。

- (id)objectAtIndex:(NSUInteger)index {
    __block id obj;
    dispatch_sync(self.concurrentQueue, ^{
        obj = [self.array objectAtIndex:index];
    });
    return obj;
}

- (void)insertObject:(id)object atIndex:(NSUInteger)index {
    dispatch_barrier_async(self.concurrentQueue, ^{
        [self.array insertObject:object atIndex:index];
    });
}

在 GCD 中创建并分派块的开销很小,这种方法比互斥锁(@synchronize)快得多。只要有空闲的处理核心,队列就可以同时处理和空闲核心数同样多的读取操作。

在读取代码中,用 dispatch_sync 等待读取结束。对于写入代码,用 dispatch_barrier_async 来确保写入时的互斥访问。通过异步调用,写入代码可以很快返回,但是同一线程上以后发生的读取保证能返回刚才写入的值。

GCD 队列是 FIFO ,所以队列上所有鞋操作之前的请求会先完成,然后写操作单独执行。之后,才会处理队列中写操作之后的请求。这既能反正写操作空等,又能确保写入之后立即读取总能得到正确的结果。

有一点需要非常注意的是:如果你传入的一个串行队列或者一个全局队列的话,它的作用相当于调用了 dispatch_async ,这样就起不到作用了。

The queue you specify should be a concurrent queue that you create yourself using the dispatch_queue_create function. If the queue you pass to this function is a serial queue or one of the global concurrent queues, this function behaves like the dispatch_async function.

作为比较,可以用 @synchronize 管理多线程访问,它会在参数上加一个互斥锁,如下:

- (id)objectAtIndex:(NSUInteger)index {
    @synchronized(self) {
        return [self.array objectAtIndex:index];
    }
}

- (void)insertObject:(id)object atIndex:(NSUInteger)index {
    @synchronized(self) {
        [self.array insertObject:object atIndex:index];
    }
}

@synchronize 很容易用,但是当竞争很少是成本很高。

分派组

分派组类似于 NSOperation 中的依赖关系,首先创建一个组:

dispatch_group_t group = dispatch_group_create();

注意,组本身没有任何配置选项,它们没有绑定到任何队列上,只是一组块。一般通过 dispatch_grounp_async 把块添加到组,类似于 dispatch_async :

dispatch_group_async(group, queue, block);

然后用 dispatch_group_notify 注册一个块,即当组执行完毕后调用它。

dispatch_group_notify(group, queue, block);

组里所有的块执行完毕时,block 就会被调度到 queue 上。可以注册同一个组的多个通知,如果你愿意的话,也可以吧这些通知块调度到不同的队列上。

如果调用 dispatch_group_notify 时队列上没有任何块,那么会马上出发通知。可以在配置组时用 dispatch_suspend 暂停队列来访问这种情况,配置完后用 dispatch_resume 启动队列。

关于控制多线程并发访问的顺序,可以用 :

dispatch_ground_enter(group);
dispatch_ground_leave(leave);

dispatch_ground_wait 

来进行控制。

例如:stackoverflow 上的一个提问

Wait until multiple networking requests have all executed - including their completion blocks

use dispatch groups

dispatch_group_t group = dispatch_group_create();

MyCoreDataObject *coreDataObject;

dispatch_group_enter(group);
AFHTTPRequestOperation *operation1 = [[AFHTTPRequestOperation alloc] initWithRequest:request1];
[operation1 setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation, id responseObject) {
    coreDataObject.attribute1 = responseObject;
    sleep(5);
    dispatch_group_leave(group);
}];
[operation1 start];

dispatch_group_enter(group);
AFHTTPRequestOperation *operation2 = [[AFHTTPRequestOperation alloc] initWithRequest:request1];
[operation2 setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation, id responseObject) {
    coreDataObject.attribute2 = responseObject;
    sleep(10);
    dispatch_group_leave(group);
}];
[operation2 start];

dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_release(group);

[context save:nil];

调用 dispatch_group_wait 会阻塞当前线程,知道整个组执行完毕,当然你也可设置超时时间。

信号量

使用信号量的两个场景:

  1. 管理对资源的并发访问。
  2. 将异步操作转换为同步操作。

管理对资源的并发访问

信号量内部有一个可以原值递增或者递减的值。如果一个动作尝试减少信号量的值,使其小于 0,那么这个动作就会被阻塞,直到有其他的调用者(在其他线程中)增加该信号量的值。在多线程编程中,它非常有用,而且用起来非常灵活。通常,一个信号量会被初始化为一个最大值,这个值表示资源可被同时访问的最大数目。该值通常为 1 ,但也可以是更大的值,用来限制并行任务的上限。在生产-消费模式中,信号量一般被初始化为 0 。

GCD 内置了经典的信号量的实现。信号量允许被初始化为任意值,同时支持递增和递减操作。信号量的当前值不能被读取。

下面的代码示例颜色了如何使用信号量来管理工作“槽”(slot)的受限池。在这个例子中,用户可以通过调用 runProcess: 来启动任意多个任务。

//
//  ViewController.m
//  ProducerConsumer
//
//  Created by Rob Napier on 9/29/13.
//  Copyright (c) 2013 Rob Napier. All rights reserved.
//

#import "ViewController.h"
#import "RNQueue.h"


@interface ViewController ()
@property (strong, nonatomic) IBOutlet UILabel *inQueueLabel;
@property (nonatomic) dispatch_semaphore_t semaphore;
@property (nonatomic) dispatch_queue_t pendingQueue;
@property (nonatomic) dispatch_queue_t workQueue;
@property (strong, nonatomic) IBOutletCollection(UIProgressView) NSArray *progressViews;
@property (nonatomic) NSInteger _pendingJobCount;  // Should only be accessed through adjustPendingJobCountBy:
@end

@implementation ViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  self.semaphore = dispatch_semaphore_create([self.progressViews count]);

  self.pendingQueue = RNQueueCreateTagged("ProducerConsumer.pending", DISPATCH_QUEUE_SERIAL);
  self.workQueue = RNQueueCreateTagged("ProducerConsumer.work", DISPATCH_QUEUE_CONCURRENT);
}

- (void)adjustPendingJobCountBy:(NSInteger)value {
  // Safe on any queue
  dispatch_async(dispatch_get_main_queue(), ^{
    self._pendingJobCount += value;
    self.inQueueLabel.text = [NSString stringWithFormat:@"%ld", (long)self._pendingJobCount];
  });
}

- (UIProgressView *)reserveProgressView {
  // Make we're on the main queue.
  RNAssertQueue(self.pendingQueue);

  __block UIProgressView *availableProgressView;
  dispatch_sync(dispatch_get_main_queue(), ^{
    for (UIProgressView *progressView in self.progressViews) {
      if (progressView.isHidden) {
        availableProgressView = progressView;
        break;
      }
    }
    availableProgressView.hidden = NO;
    availableProgressView.progress = 0;
  });

  NSAssert(availableProgressView, @"There should always be one available here.");
  return availableProgressView;
}

- (void)releaseProgressView:(UIProgressView *)progressView {
  RNAssertQueue(self.workQueue);

  dispatch_async(dispatch_get_main_queue(), ^{
    progressView.hidden = YES;
  });
}

- (IBAction)runProcess:(UIButton *)button {
  RNAssertMainQueue();

  // Update the UI to display the number of pending jobs
  [self adjustPendingJobCountBy:1];

  // Dispatch a new work unit to the serial pending queue.
  dispatch_async(self.pendingQueue, ^{
    // Wait for an open slot
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);

    // Fetch a an available resource.
    // We're on a serial queue, so we know there is no race condition
    UIProgressView *availableProgressView = [self reserveProgressView];

    // Dispatch actual work to the concurrent work queue
    dispatch_async(self.workQueue, ^{
      // Perform the dummy work
      [self performWorkWithProgressView:availableProgressView];

      // Let go of our resource
      [self releaseProgressView:availableProgressView];

      // Update the UI
      [self adjustPendingJobCountBy:-1];

      // Release our slot so another job can start
      dispatch_semaphore_signal(self.semaphore);
    });
  });
}

- (void)performWorkWithProgressView:(UIProgressView *)progressView {
  RNAssertQueue(self.workQueue);

  for (NSUInteger p = 0; p <= 100; ++p) {
    dispatch_sync(dispatch_get_main_queue(), ^{
      progressView.progress = p/100.0;
    });
    usleep(50000);
  }
}

@end

完整代码在这里

这个例子有点像 [NSOperationQueue maxConcurrentOperationCount]。能在保证灵活性的情况下,通常更好的做法是使用操作队列,而不是通过 GCD 和信号量来构建自己的解决方案。

将异步操作转换为同步操作

在将异步操作转换为同步操作时,信号量是很有用的。这个功能在做异步接口的单元测试时尤其有用,例如测试一个有 completion block 的方法。我们可以在调用次方法后等待该信号量,然后在此方法的 coplettion block 中通知该信号量,如下:

- (void)testDownload {
    NSURL *URL = [NSURL URLWithString:@"http://xxxx.com"];

    // block 变量来保存结果
    __block NSURL *l;
    __block NSError *e;

    //创建同步信号量
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    [[[NSURLSession sharedSession] downloadTaskWithURL:URL completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        //得到数据并测试
        l = location;
        e = error;

        //通知操作已经结束
        dispatch_semaphore_signal(semaphore);
    }] resume];

    //设置等待时间
    double timeoutInSeconds = 2.0;
    dispatch_time_t timeoutResult = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeoutInSeconds * NSEC_PER_SEC));

    //测试一切是否正常
    XCTAssertEqual(timeoutResult, 0L, @"Time out");
    XCTAssertNil(e, @"Received an error:%@",e);
    XCTAssertNotNil(l, @"Did not get a location");
}

信号量属于底层工具。它非常强大,但在多数需要使用它的场合,最好从设计角度重新考虑,看看是否可以不用。应该优先考虑是否可以使用诸如操作队列这样的高级工具。通常可以通过增加一个分派队列配合 dispatch_suspend , 或者通过其他方式分解操作来避免使用信号量。信号量并非不好,只是它本身是锁,能不用锁的地方就不要用。尽量用 Cocoa 框架中的高级抽象,信号量非常接近底层。但是有时候,例如需要把异步任务转换为同步任务时,信号量是最适合的工具。

分派源(待定)

定时器源(待定)

单次分派(待定)

队列关联数据(待定)

分派数据和分派源(待定)

使用 OperationQueue 还是 GCD

OperationQueue:

  • 队列不用自己管理,由操作系统管理处理队列和线程池
  • 设置最大并发数
  • 暂停和恢复
  • 优先级
  • 依赖关系
  • 取消

GCD:

  • 比 Operation 更底层,线程之间的切换时间短,效率高。
  • block 方式调用,使用简单
  • 有分派队列、屏障、分派组、信号量、单例、定时器源等多种特性,较常使用。
  • 块添加到队列后无法取消
  • 暂停和恢复,块开始执行后无法暂停

以上对比,如果业务中需要控制队列的优先级和取消操作的话则选择用 OperationQueue,OperationQueue 一般也会用于一些比较复杂依赖关系比较多的场景,如 AFNetWorking 和 SDWebImage ;

而一般场景的话则选择使用 GCD 可能会更好一些,当然,二者并不是互斥的,也可以结合使用。