程序员人生 网站导航

底层并发 API

栏目:综合技术时间:2016-07-04 16:19:29

这篇文章里,我们将会讨论1些 iOS 和 OS X 都可使用的底层 API。除 dispatch_once ,我们1般不鼓励使用其中的任何1种技术。

但是我们想要揭露出表面之下深层次的1些可利用的方面。这些底层的 API 提供了大量的灵活性,随之而来的是大量的复杂度和更多的责任。在我们的文章常见的后台实践中提到的高层的 API 和模式能够让你专注于手头的任务并且免于大量的问题。通常来讲,高层的 API 会提供更好的性能,除非你能承受起使用底层 API 带来的纠结于调试代码的时间和努力。

虽然如此,了解深层次下的软件堆栈工作原理还是有很有帮助的。我们希望这篇文章能够让你更好的了解这个平台,同时,让你更加感谢这些高层的 API。

首先,我们将会分析大多数组成 Grand Central Dispatch 的部份。它已存在了好几年,并且苹果公司延续添加功能并且改良它。现在苹果已将其开源,这意味着它对其他平台也是可用的了。最后,我们将会看1下原子操作——另外的1种底层代码块的集合。

也许关于并发编程最好的书是 M. Ben-Ari 写的《Principles of Concurrent Programming》,ISBN 0⑴3⑺01078⑻。如果你正在做任何与并发编程有关的事情,你需要读1下这本书。这本书已30多年了,依然非常出色。书中简洁的写法,优秀的例子和练习,带你领略并发编程中代码块的基本原理。这本书现在已绝版了,但是它的1些复印版仍然广为流传。有1个新版书,名字叫《Principles of Concurrent and Distributed Programming》,ISBN 0⑶21⑶1283-X,好像有很多相同的地方,不过我还没有读过。

从前...

也许GCD中使用最多并且被滥用功能的就是 dispatch_once 了。正确的用法看起来是这样的:

+ (UIColor *)boringColor; { static UIColor *color; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ color = [UIColor colorWithRed:0.380f green:0.376f blue:0.376f alpha:1.000f]; }); return color; }

上面的 block 只会运行1次。并且在连续的调用中,这类检查是很高效的。你能使用它来初始化全局数据比如单例。要注意的是,使用 dispatch_once_t 会使得测试变得非常困难(单例和测试不是很好配合)。

要确保 onceToken 被声明为 static ,或有全局作用域。任何其他的情况都会致使没法预知的行动。换句话说,不要把 dispatch_once_t 作为1个对象的成员变量,或类似的情形。

退回到远古时期(其实也就是几年前),人们会使用 pthread_once ,由于 dispatch_once_t 更容易使用并且不容易出错,所以你永久都不会再用到 pthread_once 了。

延后履行

另外一个常见的小火伴就是 dispatch_after 了。它使工作延后履行。它是很强大的,但是要注意:你很容易就堕入到1堆麻烦中。1般用法是这样的:

- (void)foo { double delayInSeconds = 2.0; dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t) (delayInSeconds * NSEC_PER_SEC)); dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ [self bar]; }); }

第1眼看上去这段代码是极好的。但是这里存在1些缺点。我们不能(直接)取消我们已提交到 dispatch_after 的代码,它将会运行。

另外1个需要注意的事情就是,当人们使用 dispatch_after 去处理他们代码中存在的时序 bug 时,会存在1些有问题的偏向。1些代码履行的过早而你极可能不知道为何会这样,所以你把这段代码放到了 dispatch_after 中,现在1切运行正常了。但是几周以后,之前的工作不起作用了。由于你其实不10分清楚你自己代码的履行次序,调试代码就变成了1场噩梦。所以不要像上面这样做。大多数的情况下,你最好把代码放到正确的位置。如果代码放到 -viewWillAppear 太早,那末也许 -viewDidAppear 就是正确的地方。

通过在自己代码中建立直接调用(类似 -viewDidAppear )而不是依赖于  dispatch_after ,你会为自己省去很多麻烦。

如果你需要1些事情在某个特定的时刻运行,那末 dispatch_after 也许会是个好的选择。确保同时斟酌了 NSTimer,这个API虽然有点笨重,但是它允许你取消定时器的触发。

队列

GCD 中1个基本的代码块就是队列。下面我们会给出1些如何使用它的例子。当使用队列的时候,给它们1个明显的标签会帮自己很多忙。在调试时,这个标签会在 Xcode (和 lldb)中显示,这会帮助你了解你的 app 是由甚么决定的:

- (id)init; { self = [super init]; if (self != nil) { NSString *label = [NSString stringWithFormat:@"%@.isolation.%p", [self class], self]; self.isolationQueue = dispatch_queue_create([label UTF8String], 0); label = [NSString stringWithFormat:@"%@.work.%p", [self class], self]; self.workQueue = dispatch_queue_create([label UTF8String], 0); } return self; }

队列可以是并行也能够是串行的。默许情况下,它们是串行的,也就是说,任何给定的时间内,只能有1个单独的 block 运行。这就是隔离队列(原文:isolation queues。译注)的运行方式。队列也能够是并行的,也就是同1时间内允许多个 block 1起履行。

GCD 队列的内部使用的是线程。GCD 管理这些线程,并且使用 GCD 的时候,你不需要自己创建线程。但是重要的外在部份 GCD 会显现给你,也就是用户 API,1个很大不同的抽象层级。当使用 GCD 来完成并发的工作时,你没必要斟酌线程方面的问题,取而代之的,只需斟酌队列和功能点(提交给队列的 block)。虽然往下深究,仍然都是线程,但是 GCD 的抽象层级为你惯用的编码提供了更好的方式。

队列和功能点同时解决了1个连续不断的扇出的问题:如果我们直接使用线程,并且想要做1些并发的事情,我们极可能将我们的工作分成 100 个小的功能点,然后基于可用的 CPU 内核数量来创建线程,假定是 8。我们把这些功能点送到这 8 个线程中。当我们处理这些功能点时,可能会调用1些函数作为功能的1部份。写那个函数的人也想要使用并发,因此当你调用这个函数的时候,这个函数也会创建 8 个线程。现在,你有了 8 × 8 = 64 个线程,虽然你只有 8 个CPU内核——也就是说任什么时候候只有12%的线程实际在运行而另外88%的线程甚么事情都没做。使用 GCD 你就不会遇到这类问题,当系统关闭 CPU 内核以省电时,GCD 乃至能够相应地调剂线程数量。

GCD 通过创建所谓的线程池来大致匹配 CPU 内核数量。要记住,线程的创建其实不是无代价的。每一个线程都需要占用内存和内核资源。这里也有1个问题:如果你提交了1个 block 给 GCD,但是这段代码阻塞了这个线程,那末这个线程在这段时间内就不能用来完成其他工作——它被阻塞了。为了确保功能点在队列上1直是履行的,GCD 不能不创建1个新的线程,并把它添加到线程池。

如果你的代码阻塞了许多线程,这会带来很大的问题。首先,线程消耗资源,另外,创建线程会变得代价高昂。创建进程需要1些时间。并且在这段时间中,GCD 没法以全速来完成功能点。有很多能够致使线程阻塞的情况,但是最多见的情况与 I/O 有关,也就是从文件或网络中读写数据。正是由于这些缘由,你不应当在GCD队列中以阻塞的方式来做这些操作。看1下下面的输入输出段落去了解1些关于如何以 GCD 运行良好的方式来做 I/O 操作的信息。

目标队列

你能够为你创建的任何1个队列设置1个目标队列。这会是很强大的,并且有助于调试。

为1个类创建它自己的队列而不是使用全局的队列被普遍认为是1种好的风格。这类方式下,你可以设置队列的名字,这让调试变得轻松许多—— Xcode 可让你在 Debug Navigator 中看到所有的队列名字,如果你直接使用 lldb。(lldb) thread list 命令将会在控制台打印出所有队列的名字。1旦你使用大量的异步内容,这会是非常有用的帮助。

使用私有队列一样强调封装性。这时候你自己的队列,你要自己决定如何使用它。

默许情况下,1个新创建的队列转发到默许优先级的全局队列中。我们就将会讨论1些有关优先级的东西。

你可以改变你队列转发到的队列——你可以设置自己队列的目标队列。以这类方式,你可以将不同队列链接在1起。你的 Foo 类有1个队列,该队列转发到 Bar 类的队列,Bar 类的队列又转发到全局队列。

当你为了隔离目的而使用1个队列时,这会非常有用。Foo 有1个隔离队列,并且转发到 Bar 的隔离队列,与 Bar 的隔离队列所保护的有关的资源,会自动成为线程安全的。 

如果你希望多个 block 同时运行,那要确保你自己的队列是并发的。同时需要注意,如果1个队列的目标队列是串行的(也就是非并发),那末实际上这个队列也会转换为1个串行队列。

优先级

你可以通过设置目标队列为1个全局队列来改变自己队列的优先级,但是你应当克制这么做的冲动。

在大多数情况下,改变优先级不会使事情照你料想的方向运行。1些看起简单的事情实际上是1个非常复杂的问题。你很容易会碰到1个叫做优先级反转的情况。我们的文章《并发编程:API 及挑战》有更多关于这个问题的信息,这个问题几近致使了NASA的探路者火星漫游器变成砖头。

另外,使用 DISPATCH_QUEUE_PRIORITY_BACKGROUND 队列时,你需要格外谨慎。除非你理解了 throttled I/O 和 background status as per setpriority(2) 的意义,否则不要使用它。不然,系统可能会以难以忍耐的方式终止你的 app 的运行。打算以不干扰系统其他正在做 I/O 操作的方式去做 I/O 操作时,1旦和优先级反转情况结合起来,这会变成1种危险的情况。

隔离

隔离队列是 GCD 队列使用中非常普遍的1种模式。这里有两个变种。

资源保护

多线程编程中,最多见的情形是你有1个资源,每次只有1个线程被允许访问这个资源。

我们在有关多线程技术的文章中讨论了资源在并发编程中意味着甚么,它通常就是1块内存或1个对象,每次只有1个线程可以访问它。

举例来讲,我们需要以多线程(或多个队列)方式访问 NSMutableDictionary 。我们可能会照下面的代码来做:

- (void)setCount:(NSUInteger)count forKey:(NSString *)key { key = [key copy]; dispatch_async(self.isolationQueue, ^(){ if (count == 0) { [self.counts removeObjectForKey:key]; } else { self.counts[key] = @(count); } }); } - (NSUInteger)countForKey:(NSString *)key; { __block NSUInteger count; dispatch_sync(self.isolationQueue, ^(){ NSNumber *n = self.counts[key]; count = [n unsignedIntegerValue]; }); return count; }

通过以上代码,只有1个线程可以访问 NSMutableDictionary 的实例。

注意以下4点:

  1. 不要使用上面的代码,请先浏览多读单写和锁竞争
  2. 我们使用 async 方式来保存值,这很重要。我们不想也没必要阻塞当前线程只是为了等待写操作完成。当读操作时,我们使用 sync 由于我们需要返回值。 
  3. 从函数接口可以看出,-setCount:forKey: 需要1个 NSString 参数,用来传递给 dispatch_async。函数调用者可以自由传递1个 NSMutableString 值并且能够在函数返回后修改它。因此我们必须对传入的字符串使用 copy 操作以确保函数能够正确地工作。如果传入的字符串不是可变的(也就是正常的 NSString 类型),调用copy基本上是个空操作。 
  4. isolationQueue 创建时,参数 dispatch_queue_attr_t 的值必须是DISPATCH_QUEUE_SERIAL(或0)。


单1资源的多读单写

我们能够改良上面的那个例子。GCD 有可让多线程运行的并发队列。我们能够安全地使用多线程来从 NSMutableDictionary中读取只要我们不同时修改它。当我们需要改变这个字典时,我们使用 barrier 来分发这个 block。这样的1个 block 的运行时机是,在它之前所有计划好的 block 完成以后,并且在所有它后面的 block 运行之前。

以以下方式创建队列:

self.isolationQueue = dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_CONCURRENT);

并且用以下代码来改变setter函数:

- (void)setCount:(NSUInteger)count forKey:(NSString *)key { key = [key copy]; dispatch_barrier_async(self.isolationQueue, ^(){ if (count == 0) { [self.counts removeObjectForKey:key]; } else { self.counts[key] = @(count); } }); }

当使用并发队列时,要确保所有的 barrier 调用都是 async 的。如果你使用 dispatch_barrier_sync ,那末你极可能会使你自己(更确切的说是,你的代码)产生死锁。写操作需要 barrier,并且可以是 async 的。


锁竞争

首先,这里有1个正告:上面这个例子中我们保护的资源是1个  NSMutableDictionary,出于这样的目的,这段代码运行地相当不错。但是在真实的代码中,把隔离放到正确的复杂度层级下是很重要的。

如果你对 NSMutableDictionary 的访问操作变得非常频繁,你会碰到1个已知的叫做锁竞争的问题。锁竞争其实不是只是在 GCD 和队列下才变得特殊,任何使用了锁机制的程序都会碰到一样的问题——只不过不同的锁机制会以不同的方式碰到。

所有对  dispatch_async,dispatch_sync 等等的调用都需要完成某种情势的锁——以确保唯一1个线程或特定的线程运行指定的代码。GCD 某些程序上可使用时序(译注:原词为 scheduling)来避免使用锁,但在最后,问题只是稍有变化。根本问题依然存在:如果你有大量的线程在相同时间去访问同1个锁或队列,你就会看到性能的变化。性能会严重降落。

你应当从直接复杂层次中隔离开。当你发现了性能降落,这明显表明朝码中存在设计问题。这里有两个开消需要你来平衡。第1个是独占临界区资源太久的开消,以致于别的线程都由于进入临界区的操作而阻塞。第2个是太频繁出入临界区的开消。在 GCD 的世界里,第1种开消的情况就是1个 block 在隔离队列中运行,它可能潜伏的阻塞了其他将要在这个隔离队列中运行的代码。第2种开消对应的就是调用 dispatch_async 和 dispatch_sync 。不管再怎样优化,这两个操作都不是无代价的。

使人哀伤的,不存在通用的标准来指点如何正确的平衡,你需要自己评测和调剂。启动 Instruments 视察你的 app 忙于甚么操作。

如果你看上面例子中的代码,我们的临界区代码仅仅做了很简单的事情。这多是也可能不是好的方式,依赖于它怎样被使用。

在你自己的代码中,要斟酌自己是不是在更高的层次保护了隔离队列。举个例子,类 Foo 有1个隔离队列并且它本身保护着对 NSMutableDictionary 的访问,代替的,可以有1个用到了 Foo 类的 Bar 类有1个隔离队列保护所有对类 Foo 的使用。换句话说,你可以把类 Foo 变成非线程安全的(没有隔离队列),并在 Bar 中,使用1个隔离队列来确保任什么时候刻只能有1个线程使用 Foo 。


全都使用异步分发

我们在这稍稍转变以下话题。正如你在上面看到的,你可以同步和异步地分发1个 block,1个工作单元。我们在《并发编程:API 及挑战》)中讨论的1个非常普遍的问题就是死锁。在 GCD 中,以同步分发的方式非常容易出现这类情况。见下面的代码:

dispatch_queue_t queueA; // assume we have this dispatch_sync(queueA, ^(){ dispatch_sync(queueA, ^(){ foo(); }); });

1旦我们进入到第2个 dispatch_sync 就会产生死锁。我们不能分发到queueA,由于有人(当前线程)正在队列中并且永久不会离开。但是有更隐晦的产生死锁方式:

dispatch_queue_t queueA; // assume we have this dispatch_queue_t queueB; // assume we have this dispatch_sync(queueA, ^(){ foo(); }); void foo(void) { dispatch_sync(queueB, ^(){ bar(); }); } void bar(void) { dispatch_sync(queueA, ^(){ baz(); }); }

单独的每次调用 dispatch_sync() 看起来都没有问题,但是1旦组合起来,就会产生死锁。

这是使用同步分发存在的固有问题,如果我们使用异步分发,比如:

dispatch_queue_t queueA; // assume we have this dispatch_async(queueA, ^(){ dispatch_async(queueA, ^(){ foo(); }); });

1切运行正常。异步调用不会产生死锁。因此值得我们在任何可能的时候都使用异步分发。我们使用1个异步调用结果 block 的函数,来代替编写1个返回值(必须要用同步)的方法或函数。这类方式,我们会有更少产生死锁的可能性。

异步调用的副作用就是它们很难调试。当我们在调试器里中断代码运行,回溯并查看已变得没成心义了。

要牢记这些。死锁通常是最难处理的问题。

如何写出好的异步 API

如果你正在给设计1个给他人(或是给自己)使用的 API,你需要记住几种好的实践。

正如我们刚刚提到的,你需要偏向于异步 API。当你创建1个 API,它会在你的控制以外以各种方式调用,如果你的代码能产生死锁,那末死锁就会产生。

如果你需要写的函数或方法,那末让它们调用 dispatch_async() 。不要让你的函数调用者来这么做,这个调用应当在你的方法或函数中来做。

如果你的方法或函数有1个返回值,异步地将其传递给1个回调解理程序。这个 API 应当是这样的,你的方法或函数同时持有1个结果 block 和1个将结果传递过去的队列。你函数的调用者不需要自己来做分发。这么做的缘由很简单:几近所有时间,函数调用都应当在1个适当的队列中,而且以这类方式编写的代码是很容易浏览的。总之,你的函数将会(必须)调用 dispatch_async() 去运行回调解理程序,所以它同时也可能在需要调用的队列上做这些工作。

如果你写1个类,让你类的使用者设置1个回调解理队列也许会是1个好的选择。你的代码可能像这样:

- (void)processImage:(UIImage *)image completionHandler:(void(^)(BOOL success))handler; { dispatch_async(self.isolationQueue, ^(void){ // do actual processing here dispatch_async(self.resultQueue, ^(void){ handler(YES); }); }); }

如果你以这类方式来写你的类,让类之间协同工作就会变得容易。如果类 A 使用了类 B,它会把自己的隔离队列设置为 B 的回调队列。

迭代履行

如果你正在倒弄1些数字,并且手头上的问题可以拆分出一样性质的部份,那末 dispatch_apply 会很有用。

如果你的代码看起来是这样的:

for (size_t y = 0; y < height; ++y) { for (size_t x = 0; x < width; ++x) { // Do something with x and y here } }

小小的改动也许就能够让它运行的更快:

dispatch_apply(height, dispatch_get_global_queue(0, 0), ^(size_t y) { for (size_t x = 0; x < width; x += 2) { // Do something with x and y here } });

代码运行良好的程度取决于你在循环内部做的操作。

block 中运行的工作必须是非常重要的,否则这个头部信息就显得过于沉重了。除非代码遭到计算带宽的束缚,每一个工作单元为了很好适应缓存大小而读写的内存都是临界的。这会对性能会带来显著的影响。遭到临界区束缚的代码可能不会很好地运行。详细讨论这些问题已超越了这篇文章的范围。使用 dispatch_apply 可能会对性能提升有所帮助,但是性能优化本身就是个很复杂的主题。维基百科上有1篇关于 Memory-bound function 的文章。内存访问速度在 L2,L3 和主存上变化很显著。当你的数据访问模式与缓存大小不匹配时,10倍性能降落的情况其实不少见。

很多时候,你发现需要将异步的 block 组合起来去完成1个给定的任务。这些任务中乃至有些是并行的。现在,如果你想要在这些任务都履行完成后运行1些代码,"groups" 可以完成这项任务。看这里的例子:

dispatch_group_t group = dispatch_group_create(); dispatch_queue_t queue = dispatch_get_global_queue(0, 0); dispatch_group_async(group, queue, ^(){ // Do something that takes a while [self doSomeFoo]; dispatch_group_async(group, dispatch_get_main_queue(), ^(){ self.foo = 42; }); }); dispatch_group_async(group, queue, ^(){ // Do something else that takes a while [self doSomeBar]; dispatch_group_async(group, dispatch_get_main_queue(), ^(){ self.bar = 1; }); }); // This block will run once everything above is done: dispatch_group_notify(group, dispatch_get_main_queue(), ^(){ NSLog(@"foo: %d", self.foo); NSLog(@"bar: %d", self.bar); });

需要注意的重要事情是,所有的这些都是非阻塞的。我们从未让当前的线程1直等待直到别的任务做完。恰恰相反,我们只是简单的将多个 block 放入队列。由于代码不会阻塞,所以就不会产生死锁。

同时需要注意的是,在这个小并且简单的例子中,我们是怎样在不同的队列间进切换的。

对现有API使用 dispatchgroupt

1旦你将 groups 作为你的工具箱中的1部份,你可能会怀疑为何大多数的异步API不把 dispatch_group_t 作为1个可选参数。这没有甚么没法接受的理由,仅仅是由于自己添加这个功能太简单了,但是你还是要谨慎以确保自己使用 groups 的代码是成对出现的。

举例来讲,我们可以给 Core Data 的 -performBlock: API 函数添加上 groups,就像这样:

- (void)withGroup:(dispatch_group_t)group performBlock:(dispatch_block_t)block { if (group == NULL) { [self performBlock:block]; } else { dispatch_group_enter(group); [self performBlock
------分隔线----------------------------
------分隔线----------------------------

最新技术推荐