文章

并发编程指南-操作队列

并发编程指南-操作队列

官方地址:ConcurrencyProgrammingGuide

Cocoa 中的操作(Operation)是对异步任务的面向对象的封装。 操作可以和操作队列一起使用也可以自己触发,因为它是基于 OC 的,所以在 iOS/OS X 开发中更加通用

关于操作对象

operation object 是 NSOperation 的实例。NSOperation 是一个抽象类,它定义了一些重要的基础设施,这样你子类化实现的时候就变得更加精简。当然 Foundation 框架本身也提供了两个实体类。

  • NSInvocationOperation: 基于对象和其 selector。可用于类中已经有对应实现,方便迁移。
  • NSBlockOperation: 一个可以执行多个 block 的操作对象,当所有block执行完成,则认为操作完成
  • NSOperation: 是自定义操作对象的基类,自定义操作可以获取完整的细节控制。包含执行路径修改和其当前执行状态的上报

所有的 operation objects 都支持如下关键功能

  1. 支持在操作对象之间简历基于图形的依赖关系,依赖关系决定了操作的执行顺序.Configuring Interoperation Dependencies
  2. 支持一个可选的完成 block 回调,操作主任务完成的时候会执行该回调.Setting Up a Completion Block
  3. 支持通过 KVO 监听操作的执行状态,KVO(Key-Value Observing Programming Guide)
  4. 支持操作设定优先级(priority),可用于调整其执行顺序.Changing an Operation’s Execution Priority
  5. 支持在操作执行过程中取消操作。取消操作参考Canceling Operations,如果是自定义操作需要支持取消功能参考Responding to Cancellation Events

Operation 是为提高你代码中并发级别而生的,它能很好的将应用行为封装为离散代码块。你应该利用这些优势采用 Operation queue 来提高并发性能,而不是在主程序去执行代码

并发 VS 非并发

NSOpreation 对象可以放入 operation queue 并发执行,也可以自己手动调用其 start 方法,进行非并发执行。

NSOperation 对象的 isConcurrent 属性,返回其任务是否是并发执行。(default is NO)

如果你自己实现异步操作,你需要写额外代码来让操作异步执行,比如:你要创建新线程,调用异步函数(或者使用其他方法保证start方法启动任务并立刻返回)

你应该永远也不要自己实现并发操作对象。正确的做法是创建 Operation 对象并将其添加到 operation queue 中,queue 会自己创建用于执行你任务的线程,所以即使你的非并发操作对象放到队列中也会被异步执行。只有当你需要异步执行操作并且不想将其加入到队列中的时候才有必要自己去实现。

创建 NSInvocationOperation 对象

NSInvocationOperation 是 NSOperation 的实体类,执行的时候会调用指定的 selector, 你可以在代码中直接使用此类,尤其是你修改原来代码已经有相应对象和方法来执行必要的任务。当然你也可以根据环境来决定执行哪个方法,比如可以依赖用户输入确定执行哪个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
@implementation MyCustomClass
- (NSOperation*)taskWithData:(id)data {
    NSInvocationOperation* theOp = [[NSInvocationOperation alloc] initWithTarget:self
                    selector:@selector(myTaskMethod:) object:data];
 
   return theOp;
}
 
// This is the method that does the actual work of the task.
- (void)myTaskMethod:(id)data {
    // Perform the task.
}
@end

创建 NSBlockOperation 对象

NSBlockOperation 是 NSOperation 的实体类,它包装了一个/多个 block 对象,它提供了面相对象的封装,你仍然可以利用其操作依赖/KVO监听状态。或者其他 GCD 所不具备的优势。

NSBlockOperation 创建之初,需指定最少一个 block,随后可以按需要添加更多的 block,当它被执行的时候,它的 blocks 会一次性被提交到默认优先级的并发 dispatch-queue。 它会等待所有 block 任务操作完成。 当最后一个 block 执行完成,整个操作任务也会被标记完成。因此你可以使用 block operation 来追踪一组执行的 blocks,就像使用一个线程来同步多线程的最终结果一样。区别在于 block operation 本身运行在独立线程,其他线程在等待的过程可以继续完成它们的任务。

1
2
3
4
5
6
7
8
9
NSBlockOperation* theOp = [NSBlockOperation blockOperationWithBlock: ^{
      NSLog(@"Beginning operation.\n");
      // Do some work.
   }];
   
// 创建之后,如果添加 block 任务,使用 addExecutionBlock: 
[theOp addExecutionBlock:^{
    // add more block
}];

定义自定义 Operation 对象

除了 NSInvocationOperation 和 NSBlockOperation 你还可以实现自定义操作。 NSOperation 类提供了通用的子类化点,同时它提供了大量基础设施来处理操作间的依赖项和KVO通知所需的大部分工作,但取决于你需要的是否并行操作你还需要做一些额外工作。

定义非并发的操作会更简单,只需要两步:执行主任务和响应取消操作。 定义并发操作你需要手动按需要实现并替换一些自定义基础设施。 下面是关于两种操作实现的具体说明

执行主任务

每种操作,最少要实现下面两种方法

你需要自定义初始化方法来让自己的操作初始化到一个已知状态,并在自定义的 main 函数中执行这个操作。当然你也可以根据需要实现其他操作如:

  • main 函数中如果需要调用其他方法,尽管去实现
  • Accessor methods 用来设置值数据并获取操作结果
  • 如果需要 archive and unarchive 当前操作,可以实现 NSCodeing 协议

下面实现了一个简单的非并发操作类,NSOperationSample

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface MyNonConcurrentOperation : NSOperation
@property id (strong) myData;
-(id)initWithData:(id)data;
@end
 
@implementation MyNonConcurrentOperation
- (id)initWithData:(id)data {
   if (self = [super init])
      myData = data;
   return self;
}
 
-(void)main {
   @try {
      // Do some work on myData and report the results.
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}
@end

响应取消操作的事件

操作(operation)开始执行之后就会持续执行到结束,除非代码显示调用取消事件。取消事件可以随时发生,虽然 NSOperation 类提供了取消方法,但你可以根据需要自愿识别取消事件。如果操作被直接中断,那就没有办法释放操作执行时已经创建的资源。所以操作对象应该自己检测取消操作,并档取消发生的的时候优雅地推出操作。

要支持取消事件,你只需在执行自定义代码之前调用 isCancelled 方法,如果其返回 YES 直接退出即可。不管你是子类化 NSOperation 还是直接使用系统实体类,支持取消操作都是非常必要的。isCancelled 方法是非常轻量的函数,你可以放心调用,自定义的 Operation 对象应该在以下几个地方考虑调用。

  • 在执行任何实际工作之前立刻调用
  • 每次 for 循环迭代中至少调用一次,如果for循环迭代的耗时较长就多调用几次
  • 在任何比较容易结束操作的地方调用 isCancelled 以决定是否取消操作

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)main {
   @try {
      BOOL isDone = NO;
 
      while (![self isCancelled] && !isDone) {
          // Do some work and set isDone to YES when finished
      }
      
      // 如果 isCancelled,需要自己根据需要释放之前申请的资源
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}

配置操作支持并发执行

操作对象默认是同步执行的。由于操作队列为非当前操作提供了线程,因此大多数操作仍然异步运行。如果你想手动执行操作并让其异步执行,就需要调用合适的方法来确保其异步执行。

下面是需要重写来支持并发操作的方法

  • start :(Required),并发操作必须重写此方法,用自定义实现来取代默认实现。start 方法是手动执行操作的入口,在此来配置线程和操作环境,重写此方法不要调用 super 实现
  • main:(Optional) main 函数通常是执行操作的地方,但你可以在 start 函数中执行操作,如果在此实现会让代码更清晰
  • isExecuting/isFinished:(Required)并发操作需要自己设置执行环境,同时也要对外暴露执行状态等信息,这两个函数就是用来获取其执行状态。实现这两个函数需要保证其他线程的数据同步。并且要通过合适 KVO 通知其他监听状态的观察者
  • isConcurrent:(Required) 决定当前操作是否是并发操作,重写此方法并返回 YES

下面Demo示例实现一个 MyOperation

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
// MARK:- 定义一个并发操作,其实现非常简单, isConcurrent 直接返回 YES,isExecuting/isFinished 也分别直接返回了对应的变量。
@interface MyOperation : NSOperation {
    BOOL        executing;
    BOOL        finished;
}
- (void)completeOperation;
@end
 
@implementation MyOperation
- (id)init {
    self = [super init];
    if (self) {
        executing = NO;
        finished = NO;
    }
    return self;
}
 
- (BOOL)isConcurrent {
    return YES;
}
 
- (BOOL)isExecuting {
    return executing;
}
 
- (BOOL)isFinished {
    return finished;
}
@end

下面是操纵的 start 方法,示例了启动任务所必需的方法,其中启动了子线程去执行 main 方法,其中更新了 executing 变量并通过 KVO 消息通知其监听方。操作完成之后,返回就会结束,并留下子线程去独自执行main函数方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)start {
   // Always check for cancellation before launching the task.
   if ([self isCancelled])
   {
      // Must move the operation to the finished state if it is canceled.
      [self willChangeValueForKey:@"isFinished"];
      finished = YES;
      [self didChangeValueForKey:@"isFinished"];
      return;
   }
 
   // If the operation is not canceled, begin executing the task.
   [self willChangeValueForKey:@"isExecuting"];
   [NSThread detachNewThreadSelector:@selector(main) toTarget:self withObject:nil];
   executing = YES;
   [self didChangeValueForKey:@"isExecuting"];
}

下面是 main 函数的实现,main 函数是操作子线程执行任务的入口。它具体执行了操作关联的任务,并在完成之后调用了自定义的 completeOperation 方法来生成 KVO 通知对应观察者isExecuting/isFinished属性的改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)main {
   @try {
 
       // Do the main work of the operation here.
 
       [self completeOperation];
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}
 
- (void)completeOperation {
    [self willChangeValueForKey:@"isFinished"];
    [self willChangeValueForKey:@"isExecuting"];
 
    executing = NO;
    finished = YES;
 
    [self didChangeValueForKey:@"isExecuting"];
    [self didChangeValueForKey:@"isFinished"];
}

即使操作被取消,也要通知其观察者对应的操作已经完成,如果有其他 Operation 依赖本操作,它会监听 isFinished 字段,当其依赖操作都发送完成操作的信号之后它才会准备执行。如果不正确生成‘结束’通知,就会阻碍其他操作的执行

维护 KVO 合规性

NSOperation 类符合以下关键路径的键值观测(KVO):

如果你重写了 start 方法来执行重要自定义操作(而不是main方法),你需要确保你的自定义对象要对以上字段符合 KVO。其中 isExecuting 和 isFinished 是使用最为频繁的字段。

如果你要支持操作项之间依赖关系,你可以重写 isReady 方法并强制返回 NO,直到你所需依赖项被满足(重写此方法时候儒如果在自定义依赖之上还需要支持 NSOperation 默认的依赖关系,就调用 super 实现)。当准备状态修改的时候要生成 KVO 通知其监听者。如果你没有重写 addDependency: 和 removeDependency: 两个方法,就不用关心 dependencies 的 KVO 操作

其他字段的 KVO,你大概率不会需要关心,取消操作直接调用现有 cancel 方法即可,队列优先级你应该更少修改,除非你需要动态修改操作是否并发实现,你几乎不会去提供 isConcurrent 的 KVO 通知。

关于 KVO 可参考 Key-Value Observing Programming Guide

自定义操作对象执行行为

操作对象的配置时机在于:操作对象创建完之后,添加到操作队列之前。下面几种配置对于各操作通用。

配置互相依赖关系

设置依赖(Dependencies)是一种顺序执行操作的方法。一个操作只有在它依赖的所有操作执行完成之后才能被执行。由此你可以设置简单的一对一依赖,也可以设置复杂的依赖关系

设置依赖关系,需要调用 NSOperation 的 addDependency: 方法。执行完之后,当前对象就会依赖参数对象的执行完成才能被执行。即使是在同一个操作队列中,也可以指定自己所需依赖。操作本身会自己管理自己的依赖关系,所以可以在不同队列之间随意设置依赖关系。需要注意:禁止设置循环依赖关系,这是错误的,也会影响所有操作都无法执行

只有当操作的依赖项目都执行完它本身才进入 ready 状态并启动,如果你要手动执行它,那就需要你自己执行它的 start 方法。你应该对所执行的操作都设置依赖并加入队列执行。

Dependencies 本身是依赖 operation 对象发出合适的 KVO 通知给其他被依赖项的,如果自定义一个 Operation 对象,你需要维护 KVO 合规性。

修改操作的执行优先级 (Execution Priority)

操作的执行顺序由其依赖项和优先级决定, 依赖由外部确定,优先级则是自己的属性。默认优先级是normal,你可以自己指定:setQueuePriority:

优先级只影响当前操作队列中的操作。如果你有多个操作队列,每个队列中优先级是独立的,所以仍有可能低优先级的操作先于高优先级的操作执行

优先级只作用于已经处于 ready 状态的操作,它并不订阅依赖关系关系。所以可能队列中分别有高低两个操作,高优先级操作依赖其他操作,低优先级则无依赖项,则低优先级操作进入 ready 状态就会先执行

修改底层线程的优先级 (Thread Priority)

OS X v10.6 之后支持设置操作底层线程的优先级,线程策略是由系统内核本身管理的,但通常由高优先级线程会优先执行。你可以设置底层线程优先级 setThreadPriority:. 它优先级是 0.0 - 1.0 的浮点数,由低到高,默认是 0.5。 底层线程的优先级只会影响 main 函数内子线程任务的执行。其他代码运行在默认优先级,如果你自定义操作并重写 start 方法,你必须配置线程优先级

设置完成回调 block

OS X v10.6 之后操作完成之后可以执行一个完成回调。你可以通过 setCompletionBlock: 来设置。

实现操作对象的小 Tip

自定义操作虽然很容易,但有几个点也需要注意。

操作对象的内存管理

关于 Objective-C 的内存管理请参考 Advanced Memory Management Programming Guide

  • 避免单线程的存储 (Avoid Per-Thread Storage) :操作队列底层是使用线程执行你的任务,但是线程是队列本身统一管理的,线程的去留由系统本身和App决定,所以不要自己给线程绑定数据。
  • 保存操作对象的指针,当需要的时候可以使用 : 操作对象被加入到队列后会尽可能快的被执行,执行完之后操作对象就被移除了,如果你后续要使用操作对象获取结果数据,需要自己保存对象引用。

处理错误和异常

因为操作本质上是应用程序中的离散实体,所以需要你自己处理操作中发生的错误或者异常。OS X v10.6 之后 start 方法不再处理异常,需要开发者自己直接处理。尤其是自定义操作,更要自己处理异常,如下

  • Check and handle UNIX errno-style error codes.
  • Check explicit error codes returned by methods and functions.
  • Catch exceptions thrown by your own code or by other system frameworks.
  • Catch exceptions thrown by the NSOperation class itself, which throws exceptions in the following situations:
    • When the operation is not ready to execute but its start method is called
    • When the operation is executing or finished (possibly because it was canceled) and its start method is called again
    • When you try to add a completion block to an operation that is already executing or finished
    • When you try to retrieve the result of an NSInvocationOperation object that was canceled

确定操作对象的合适范围

虽然你可以创建任意多的操作对象并放入到队列执行,但操作本身是对象也要消耗资源。所以创建操作本身也要选择合适范围,如:不要一次创建太多操作,尽量让一个操作执行足够的任务,试图找到一个合适的平衡

执行操作

执行操作有如下几种方法。

把操作添加到操作队列中去

操作队列本身是 NSOperationQueue 实例对象。你需要自己管理其内存,虽然你可任意创建操作队列,但是其底层能否执行是有系统负载和硬件核心数的物理限制的。

执行操作本质就是将操作加入到队列中,有如下三种方法

1
2
3
4
5
6
7
NSOperationQueue* aQueue = [[NSOperationQueue alloc] init];

[aQueue addOperation:anOp]; // Add a single operation
[aQueue addOperations:anArrayOfOps waitUntilFinished:NO]; // Add multiple operations
[aQueue addOperationWithBlock:^{
   /* Do something. */
}];

NSOperationQueue 可以并发执行操作,同样也能顺序执行操作,只需要设置setMaxConcurrentOperationCount: 为1即可顺序执行操作。虽然同一时间执行的操作数量为1,但是其操作顺序仍旧被依赖项/优先级控制。

手动执行操作

操作可以放到队列中自动执行,也可以手动执行。手动执行需要有两点:1. 操作本身进入 ready 状态 2. 必须执行 start 方法去启动操作。示例如下

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
- (BOOL)performOperation:(NSOperation*)anOp
{
   BOOL        ranIt = NO;
 
   if ([anOp isReady] && ![anOp isCancelled])
   {
      if (![anOp isConcurrent])
         [anOp start];
      else
         [NSThread detachNewThreadSelector:@selector(start)
                   toTarget:anOp withObject:nil];
      ranIt = YES;
   }
   else if ([anOp isCancelled])
   {
      // If it was canceled before it was started,
      //  move the operation to the finished state.
      [self willChangeValueForKey:@"isFinished"];
      [self willChangeValueForKey:@"isExecuting"];
      executing = NO;
      finished = YES;
      [self didChangeValueForKey:@"isExecuting"];
      [self didChangeValueForKey:@"isFinished"];
 
      // Set ranIt to YES to prevent the operation from
      // being passed to this method again in the future.
      ranIt = YES;
   }
   return ranIt;
}

取消操作

操作被加入到队列后就会被队列持有,并且不能被移除。唯一的方法就是取消它。虽然可以单独移除某个操作,但其被移除后被标记为canceled状态,也会被当作完成来处理,其他依赖它的操作因此减少依赖,可能就此被执行。所以更通用的方法是直接取消队列所有操作。cancelAllOperations

等待操作完成

从性能角度来说,你应该尽可能让操作异步执行,从而让你的程序能同时轻松的处理其他任务。当然你可以使用NSOperation 对象的 waitUntilFinished 方法来阻塞任务,直到它被完成。你要尽量避免阻塞操作执行,这样虽然能让你更方便的等到其执行结果再做需要的事,但会限制程序的并发性。

永远不要在主线程阻塞操作。阻塞主线程会影响你程序响应用户事件,这会让你的程序看起来没有任何响应

等待线程执行也可以使用 NSOperationQueue 的 waitUntilAllOperationsAreFinished 方法来等待队列中所有任务执行完成。当你等待的时候其他线程仍旧可以在队列中添加执行任务,从而延长等待的时间。

挂起/重启 队列

如果要临时暂停操作队列,可以执行其 setSuspended: 方法。但是它只会阻止尚未执行的操作的执行,并不会中断已经执行的操作。例如当用户请求中断某个操作的时候,你可以挂起队列,当用户想要启动的时候再启动队列。


end

本文由作者按照 CC BY 4.0 进行授权