ADA笔记:macOS上的异步编程

Mon 25 March 2019 / In categories Notes, Programming

macOS

本文是ADA(Apple Documentation Archive)上的Concurrency Programming Guide相关的阅读笔记。

多任务编程

在编写代码中经常遇到一类需求,就是用异步的方式来处理某个功能。一个常见的例子是:从网上下载一个文件,我们希望把这个任务派给其他执行单元,然后下载完后在返回结果。这么做通常有两个考虑:

  • 如果在当前控制流中执行这个任务,可能需要等待结果的返回,导致控制流挂起。
  • 如果硬件可以支持并发执行多个任务,那么把任务派发给别的执行单元可以提供系统吞吐量。

为了支持异步操作,操作系统需要提供并发机制,也就是说允许上层代码同时提交多个任务。同时为了提供吞吐量,系统常常需要并行执行任务。目前的常见的操作系统中,主要的并发和并行机制是通过进程和线程来提供的。

通过进程进行多任务,存在的问题是进程间相互隔离度比较高,进程间通信比较困难,同时切换成本比较高。基于这几点原因,一般会更多使用线程进行多任务。macOS上现有的几种多任务机制

  • Cocoa threads
  • POSIX threads
  • Multiprocessing Services

从名字可以看出来,前两者是基于线程的,使用比较多,而后者有可能是基于进程的,已经不怎么推荐使用了。

所谓POSIX threads,其实是一套通用的线程API,是通过标准,在在各大Unix/Linux上都能见到。macOS作为Unix血统的操作系统,支持POSIX threads也理所当然。

Cocoa是macOS上的应用程序框架的代称,所以Cocoa threads可以看作是对POSIX threads的封装,为了更方便应用程序使用。比如NSThread这个从NSObject继承而来的类就是Cocoa threads提供了。

采用多任务设计,就要考虑在任务之间的通信和同步,macOS提供下面几种机制::

  • Cocoa相关的
    • Direct Messaging
    • Cocoa distributed objects
  • 普通thread相关的
    • Global variables, share memory and object
    • Conditions
    • Run loop sources
    • Ports and sockets
  • Multiprocessing相关的
    • Message queues

异步操作

对于编写应用程序的开发者而言,可以直接使用底层的线程机制,但多线程编程会带来相当大的复杂度。应用程序中往往需要支持异步操作,也就是操作放到当前的控制流之外。多任务是如何并发和并行的,并不是太需要关心的内容。于是macOS提供了若干种上层机制,来帮助开发者应对需要异步操作的场景:

  • Operation objects
  • Grand Central Dispatch (GCD)
  • Idle-time notifications
  • Asynchronous functions
  • Timers
  • Separate processes

Separate processes是调用其他程序来帮助当前程序完成操作,比如使用C语言中的system()函数。macOS中有一些预设的服务,可以通过Asynchronous functions来访问。Timers是一种中断机制,可以中断主线程来做一些轻量级的操作。Grand Central Dispatch (GCD)可以看成是一个系统管理的线程池,开发者不需要自己去操作和管理线程,而是把任务提交给GCD的操作队列来执行。Operation objects是基于GCD之上的封装,任务必须是从NSOperation继承的子类。 Idle-time notifications跟Run Loop相关,一个线程可以有一个Run Loop,同时有一个 NSNotificationQueue队列,位于此队列的任务会在Run Loop为空的时候执行,

一个线程有多个事件源,这些事件源可能会同时产生事件,所以需要一个类似队列的结构来缓存这些发生的事件,这就是Run Loops的作用。如果Run Loop为空,拥有此Run Loop的线程可以挂起并等待事件到来,到那时候再恢复运行。

Grand Central Dispatch (GCD)

对于应用程序而言,使用GCD往往就能够满足自身的异步操作需求,而不需要去使用底层线程机制。GCD可以看成是一个线程池,当一个任务提交到GCD的队列之后,GCD队列可以从线程池激活一个线程来执行这个任务。

GCD是一个C语言级别的库,不需要Objective-C的runtime。

GCD通过派遣队列(Dispatch Queues)这个慨念来提供一个抽象层给应用程序,使得他们不需要直接管理底下的线程。当应用程序需要执行一个异步操作的时候,就提交一个任务到派遣队列,由队列来决定如何执行这个任务。这里面的分工:

  • 应用程序定义一个任务的内容,但是不决定任务是如何执行的
  • 派遣队列接收提交的任务内容,并且决定任务在哪个线程上执行,然后把结果通知应用程序

队列这个词有一个隐含的意思,它会按提交的顺序来执行任务。

全局队列

为了方便使用,GCD为应用程序预设了4个全局队列,对应不同的优先级:

  • DISPATCH_QUEUE_PRIORITY_DEFAULT
  • DISPATCH_QUEUE_PRIORITY_HIGH
  • DISPATCH_QUEUE_PRIORITY_LOW
  • DISPATCH_QUEUE_PRIORITY_BACKGROUND

获取全局队列的例子:

// 第二个参数是系统保留参数,传NULL即可。
dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, NULL);

上面的几个队列都属于并发性派遣队列(Concurrent Dispatch Queue),也就是说它可以并发执行多个提交的任务(当然还是按照提交的顺序来的)。

除了并发性派遣队列以外,GCD还支持序列性派遣队列(Serial Dispatch Queue),只能逐个来执行任务。GCD也提供一个全局性的序列性派遣队列,叫主派遣队列(Main dispatch queue)。主派遣队列运行在主线程中,可以通过dispatch_get_main_queue来获取该队列的handle。应用程序必须通过dispatch_main来提取主派遣队列中的任务,以便执行。

需要注意的是,主派遣队列通常是和Run Loop结合在一起的。Run Loop的事件是也是在主派遣队列中缓冲处理的。所以也可以通过CFRunLoopRefNSRunLoop来操作主队列。

私有队列

应用程序可以通过以下方式创建私有的派遣队列:

dispatch_queue_t queue;

// 第二个参数是系统保留参数,传NULL即可。
queue = dispatch_queue_create("com.example.MyQueue", NULL);

上述创建的队列是序列性的。在macOS 10.7和iOS 4.3之后的系统,可以把第二个参数指定为DISPATCH_QUEUE_CONCURRENT来创建并发性派遣队列。

需要多解释一下dispatch_queue_t,它的定义如下:

typedef NSObject<OS_dispatch_queue> *dispatch_queue_t;

dispatch_queue_t所指向的队列其实是从从支持OS_dispatch_queue协议的NSObject继承出来的,所以一个队列也称为一个派遣对象(Dispatch Object)。而一个派遣对象可以用dispatch_set_contextdispatch_get_context来关联自定义的上下文:

MyDataContext*  data = (MyDataContext*) malloc(sizeof(MyDataContext));
...
dispatch_set_context(serialQueue, data);

提交任务到队列

GCD抽象出了派遣队列,但是并没有对任务进行抽象。所以GCD的任务可以用一个普通的函数来描述,然后使用dispatch_async_f来派遣。相关定义如下:

// 一个任务可以包装为一个带void*参数的无返回值的函数
typedef void (*dispatch_function_t)(void *);

// context在任务执行前会回传给任务作为参数
void dispatch_async_f(dispatch_queue_t queue, void *context, dispatch_function_t work);

你还可以使用dispatch_async(后面不带_f)来派遣任务。但是这种方式需要使用到Blocks。因为C语言本身没有匿名函数,也不支持嵌套函数,所以苹果使用Blocks机制扩展了C语言,在语法上提供了匿名函数的支持,对编写任务代码带来一定的便利性。

Blocks和C++中的lambda有许多相似之处,后者是在C++ 11标准中引入的。

有些场景需要同步的操作,也就是提交任务后等待任务完成。使用dispatch_sync_f可以达到此效果。此操作会阻塞提交任务的线程,直到任务执行结束为止。

如果想获得任务结束通知却不想使用dispatch_sync_f,一般的做法是要所派遣的任务在结束的时候派遣另外一个任务(到主队列或者其他用于接收任务通知的队列)来告知任务的结束。

多任务同步机制

异步的多个任务或多或少在某个时间点上要进行同步。前面提到的dispatch_sync_f就是一种同步机制,不过只是针对单个任务。如果是多个任务,GCD提供任务分组机制,可以把多个任务放到一个组,然后使用dispatch_group_async来派遣这个任务组,并且使用dispatch_group_wait来等待这个组中所有任务的完成:

从文档中摘抄的例子:

dispatch_queue_t queue = dispatch_get_global_queue DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_group_t group = dispatch_group_create();

// Add a task to the group
dispatch_group_async(group, queue, ^{
   // Some asynchronous work
});

// Do some other work while the tasks execute.
// When you cannot make any more forward progress,
// wait on the group to block the current thread.
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

// Release the group when it is no longer needed.
dispatch_release(group);

另一种同步方式是使用信号量,用来控制多个任务对资源的访问。使用dispatch_semaphore_create可以创建一个信号量,使用该函数需要指定一个数值,指明多少个任务可以同时访问该资源。当一个任务需要访问信号量控制的资源时,必须先调用dispatch_semaphore_wait来获取信号量;当结束使用该资源时,必须使用dispatch_semaphore_signal来释放信号量。

来自文档中的例子:

// Create the semaphore, specifying the initial pool size
dispatch_semaphore_t fd_sema = dispatch_semaphore_create(getdtablesize() / 2);

// Wait for a free file descriptor
dispatch_semaphore_wait(fd_sema, DISPATCH_TIME_FOREVER);

fd = open("/etc/services", O_RDONLY);

// Release the file descriptor when done
close(fd);

dispatch_semaphore_signal(fd_sema);

GCD的其他细节

  • 在任务代码中调用dispatch_get_current_queue可以获取该任务所在的派遣队列
  • 派遣队列作为可管理的对象,可以使用dispatch_suspenddispatch_resume来暂停和恢复执行。此操作对执行中的任务无影响。
  • 队列的执行是由线程来支撑的,队列与队列之间的关系就像线程和线程之间关系一样,彼此由操作系统并发调度的,互相之间没有影响。
  • 队列的内存管理模式为引用计数,队列创建时计数为1。可以使用dispatch_retaindispatch_release来增加或者削减引用计数。如果不是当前创建的队列,而是从别处传来的队列,记得在使用该队列前调用dispatch_retain、以及在使用完调用dispatch_release,避免对象被提前释放或者留存太久。对于全局队列,它们的生命周期和应用程序的生命周期相同,不需要执行此操作。dispatch_set_finalizer_f可以把一个清理函数关联到一个队列,当队列被释放的时候会调用此清理函数。
  • 可以使用dispatch_apply或者dispatch_apply_f来系列化派遣多个任务。类似OpenMP中的for指令。
  • GCD基于线程机制,如果使用不小心,也有可能导致死锁。比如在当前线程执行的任务中使用dispatch_sync来向当前线程派遣一个新的任务。被派遣的任务永远不会被执行,因为线程被当前执行的任务占据;而当前任务在等待被派遣的任务执行,所以永远不会结束。
  • 在GCD的任务中,开发者依然可以直接使用底层接口去控制线程。这会是一个很危险的行为,如果你不知道自己在干什么的话。
  • 派遣队列支持ARC(自动引用计数),每个派遣队列有自己的autorelease pool,最终会自动释放所使用的内存。但是你不知道派遣队列具体什么时候会时候释放这些内存。如果你想对内存管理进行一定的控制,可以自己建立autorelease pool来管理内存的释放,更多参考:Advanced Memory Management Programming Guide

Dispatch Sources

应用程序通常需要关注一些系统事件,最常见的是处理系统和其他进程发来的信号。这些系统事件往往需要应用程序立即响应,从而打断当前的控制流。引入了派遣队列之后,应用程序可以把这些系统事件导入到派遣队列中,等待必要的时候再处理。

macOS提供一个抽象机制叫做派遣源([Dispatch Source])用来表示系统事件的来源。就像往水龙头上接水管一样,可以往派遣源上接一个派遣队列,让这个队列就可以用来接收来自派遣源的事件。

传统上,处理这些系统事件的时候,你需要提供一个回调函数,在事件发生的时候,系统会调用这个回调函数。使用派遣源的时候,因为是异步操作,所以除了回调函数以外,还需要指定一个派遣队列。

可以被封装的系统事件包括以下类别:

  • Timers,时钟通知
  • Signal handlers,类UNIX的信号中断
  • Descriptor-related events,文件描述符相关的事件,可以用来协调数据读写,以及侦测文件改动。
  • Process-related events,进程相关的事件,比如进程退出、fork或者exec、以及进程收到信号
  • Mach port events,macOS所基于的Mach内核相关的事件
  • Custom events that you trigger,自定义事件

派遣源是可以对事件做一些处理的。如果一个事件源产生多个类似的事件,那么派遣源可以把多个事件合并成一个,这样出现在队列中的事件只有一个,最终需要应用程序处理的事件也只有一个。只有等待中的事件才可以被合并,已经处理中的事件不会被合并。

派遣源也是从NSObjecti派生出来的,所以它也是一个派遣对象:

typedef NSObject<OS_dispatch_source> *dispatch_source_t;

通过dispatch_source_create可以创建一个派遣源。刚创建的派遣源是没有接上事件源的,处于静止状态,需要使用dispatch_source_set_event_handler_f来配置一个事件源。可以使用dispatch_source_get_handle来获取配置的事件源。配置好的派遣源可以使用dispatch_resume来激活。

对于常用的Timer,可以使用dispatch_source_set_timer来配置

当处理一个事件源中的事件时,可以使用以下函数来获取额外的信息:

  • dispatch_source_get_handle
  • dispatch_source_get_data
  • dispatch_source_get_mask

这三个函数的返回值以及使用形式依赖于具体的事件源的类别。

可以使用dispatch_source_cancel来终止一个派遣源。终止以后的派遣源没有什么用,最好尽快调用dispatch_release释放其引用。你可以用dispatch_source_set_cancel_handler_f来注册一个处理函数,当一个派遣源终止的时候,会调用这个函数来释放一些资源,比如释放系统分配给文件描述符的资源。

派遣源的其他细节

  • 派遣源关联的队列是可以使用dispatch_set_target_queue来更改的,这个操作不会影响正在处理中的事件
  • 派遣源和派遣队列一样,都是派遣对象,有些行为是共通的,比如都可以使用dispatch_set_contextdispatch_get_context来关联和获取一个上下文;都可以使用dispatch_retaindispatch_release来处理引用计数。
  • 一个派遣源通常是归属于外部对象,由外部对象来帮助释放引用计数。但是个别的案例中,需要派遣源进行自管理,在事件中释放自己的引用计数。
  • 前面提到的,使用dispatch_resume可以激活队列。那挂起队列可以使用dispatch_suspend。这两个操作也是带计数的,多次调用dispatch_suspend会累计相应的计数,需要多次调用dispatch_resume才可以激活队列。被挂起的派遣源会暂停处理事件,事件会累积起来,直到派遣源重新被激活,那时派遣源会合并既有事件,然后提交队列运行。
  • 派遣源的使用基本上是比较简单的,只要按Dispatch Sources中的例子照本宣科就可以了。

Cocoa Operation Objects

GCD只是提供了派遣队列,但是GCD并没对任务进行抽象。Cocoa则用NSOperation类对任务进行了抽象,以此来提供一些额外功能。其中最主要的功能是在NSOperation对象之间提供一套通知机制,这样一个NSOperation对象可以依赖于另一个NSOperation对象。由于这种依赖关系的存在,多个NSOperation对象之间可以形成一个有向无环图。

正确使用NSOperation的姿势是从它派生出一个子类,覆盖其中的某些方法来执行自定义的逻辑。NSOperation提供了一个start方法,调用这个方法可以开启这个任务。然而,调用start后,任务可以是在调用该函数的线程执行,也可以在其他线程执行,取决于派生类是如何操作的。派生类可以通过isConcurrent方法来告知自己是不是同步执行的,默认是NO。

派生一个同步执行的NSOperation子类

定义一个同步执行的NSOperation的派生类比较简单,第一是重载main方法,把执行逻辑放到里面去,前面提到的start方法最终会调用这个main方法;第二是提供一个自定义的初始化函数来接收任务的外部参数。

从文档中摘抄的一个例子:

@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

派生一个异步执行的NSOperation子类

为了使派生的子类能够异步执行,有一些额外的工作需要做

  • 在派生类中覆盖start,在其中把任务挪到非调用线程上执行
  • 可以选择性覆盖main,把任务逻辑主体放置其中(也可以直接放在start中)
  • 覆盖isConcurrent,让其返回YES
  • 提供覆盖版的isExecutingisFinished,用以通知执行状态,并且要保证这两个方法能够安全的在其他线程中被调用

文档中提供了一个例子:

@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的实现:

- (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"];
}

几个地方值得关注:

  • [NSThread detachNewThreadSelector:@selector(main) toTarget:self withObject:nil];把任务(也就是main方法)甩到一个新的线程执行
  • [self willChangeValueForKey:@"isFinished"];[self didChangeValueForKey:@"isFinished"];都是跟KVO通知相关的,在下面的章节会介绍。

接下来看看main方法以及completeOperation方法的实现:

- (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"];
}

KVO通知

NSOperation采用了KVO( key-value observing)通知机制,一个NSOperation支持下列KVO通知:

  • isCancelled
  • isConcurrent
  • isExecuting
  • isFinished
  • isReady
  • dependencies
  • queuePriority
  • completionBlock

KVO通知用以在不同的NSOperation间传递消息。NSOperation之间的依赖关系就是基于KVO通知的。如果任务A依赖于任务B,那么任务A要等到接收到任务B的isFinished通知之后,任务A的isReady才有可能变为TRUE,任务A才可以被执行。

NSOperationQueue

NSOperation可以放进一个NSOperationQueue类型的队列中管理。NSOperationQueue基于GCD的队列,但是和GCD的队列先进先出的方式有差别的一点是,NSOperationQueue能够处理NSOperation之间的依赖关系。

创建一个队列:

NSOperationQueue* aQueue = [[NSOperationQueue alloc] init];

可以使用addOperation:往NSOperationQueue添加任务;可以使用addOperations:waitUntilFinished往NSOperationQueue添加一组任务。如果被添加的NSOperation只支持同步执行的,那么NSOperationQueue则会启动一个单独的线程来执行这个NSOperation,这相当于NSOperation也变成了异步执行的了。

当一个NSOperation添加到队列之后,它就属于这个队列了的(所以要注意保留NSOperation的引用)。想从队列中删除这个NSOperation只有一个办法,就是取消它。可以调用NSOperation自身的cancel方法来取消,也可以调用队列的cancelAllOperations来取消队列中的所有对象。

NSOperation的其他细节

  • NSOperation可以通过setCompletionBlock:来指定一个Block,在任务结束的时候执行。
  • 可以通过方法addDependency:removeDependency:来添加和删除任务间的依赖,具有循环依赖的NSOperation对象会被拒绝运行。
  • macOS提供了两个默认的NSOperation的派生类,分别是NSBlockOperation和NSInvocationOperation。NSBlockOperation用于同时执行一组Block,而NSInvocationOperation根据selector来判断所需要执行的操作。
  • 可以调用NSOperation的waitUntilFinished方法来等待其完成,可以调用的NSOperationQueue的waitUntilAllOperationsAreFinished来等待队列中的所有任务。
  • 可以调用NSOperationQueue的setSuspended:来挂起一个队列。
  • 在OS X v10.6以后,可以通过setThreadPriority:方法来设置NSOperation所在的Thread的优先级,但是效果只限制在NSOperationmain函数的执行期内
  • 添加到NSOperationQueue的NSOperation的执行顺序是是这么决定的,先就绪的先运行,以及优先级高的先运行。就绪状态是NSOperation的isReady给出的。而优先级的话,对于所有的NSOperation其初始优先级都是“normal”,但是可以通过setQueuePriority:调整。注意,优先级低但是先就绪的NSOperation可以比优先级高的先运行。
  • 最好不要在NSOperation的执行逻辑中去涉及任何和线程有关的操作,比如使用线程的本地存储。NSOperation最好对所在的线程视而不见。

关于苹果技术文档的观感。

苹果的技术文档写得非常好,可以说是业界楷模。如果用一个词来形容苹果的技术文档,那么我选“肥而不腻”。

“肥”是指苹果的文档包含有很多冗余的描述,对于初学者,这是至关重要的。技术文档中包含很多概念,如果不重复描述这些慨念,那么新手就无法在脑子里建立这些慨念。但是对于熟手而已,概念又显得有点冗余,常常在读文档的时候需要跳过这些描述。由于苹果对文档的组织上很清晰,所以熟手可以很轻快跳过这些描述,去阅读他们感兴趣的内容,所以说“不腻”。

苹果的技术文档组织也很清晰。每个文档都是覆盖一定的独立的主题,各个章节围绕特定的功能展开,同时章节有针对自己的描述,而文档又有针对章节的描述,层次清晰,循循善诱。不得不说是少有的佳作。

参考链接

Load Disqus Comments