本文是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的事件是也是在主派遣队列中缓冲处理的。所以也可以通过CFRunLoopRef或NSRunLoop来操作主队列。
私有队列
应用程序可以通过以下方式创建私有的派遣队列:
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_context
和dispatch_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_suspend
和dispatch_resume
来暂停和恢复执行。此操作对执行中的任务无影响。 - 队列的执行是由线程来支撑的,队列与队列之间的关系就像线程和线程之间关系一样,彼此由操作系统并发调度的,互相之间没有影响。
- 队列的内存管理模式为引用计数,队列创建时计数为1。可以使用
dispatch_retain
和dispatch_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_context
和dispatch_get_context
来关联和获取一个上下文;都可以使用dispatch_retain
和dispatch_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 - 提供覆盖版的
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
的实现:
- (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的优先级,但是效果只限制在NSOperation
的main
函数的执行期内 - 添加到NSOperationQueue的NSOperation的执行顺序是是这么决定的,先就绪的先运行,以及优先级高的先运行。就绪状态是NSOperation的
isReady
给出的。而优先级的话,对于所有的NSOperation其初始优先级都是“normal”,但是可以通过setQueuePriority:
调整。注意,优先级低但是先就绪的NSOperation可以比优先级高的先运行。 - 最好不要在NSOperation的执行逻辑中去涉及任何和线程有关的操作,比如使用线程的本地存储。NSOperation最好对所在的线程视而不见。
关于苹果技术文档的观感。
苹果的技术文档写得非常好,可以说是业界楷模。如果用一个词来形容苹果的技术文档,那么我选“肥而不腻”。
“肥”是指苹果的文档包含有很多冗余的描述,对于初学者,这是至关重要的。技术文档中包含很多概念,如果不重复描述这些慨念,那么新手就无法在脑子里建立这些慨念。但是对于熟手而已,概念又显得有点冗余,常常在读文档的时候需要跳过这些描述。由于苹果对文档的组织上很清晰,所以熟手可以很轻快跳过这些描述,去阅读他们感兴趣的内容,所以说“不腻”。
苹果的技术文档组织也很清晰。每个文档都是覆盖一定的独立的主题,各个章节围绕特定的功能展开,同时章节有针对自己的描述,而文档又有针对章节的描述,层次清晰,循循善诱。不得不说是少有的佳作。
参考链接
- 关于POSIX threads相关的内容,可以参考苹果的man pages。
- macOS支持使用OpenCL来使用GPU做并行计算,参考OpenCL Programming Guide for Mac
- 关于性能方面的问题,可以参考Performance Overview
- Programming with Objective-C
- Framework Programming Guide
- Parallel programming with Swift: Basics