什么是GCD?
Grand Central Dispatch或者GCD,是一套低层API,提供了一种新的方法来进行并发程序编写。从基本功能上讲,GCD有点像 NSOperationQueue,他们都允许程序将任务切分为多个单一任务然后提交至工作队列来并发地或者串行地执行。GCD比之 NSOpertionQueue更底层更高效,并且它不是Cocoa框架的一部分。
除了代码的平行执行能力,GCD还提供高度集成的事件控制系统。可以设置句柄来响应文件描述符、mach ports(Mach port 用于 OS X上的进程间通讯)、进程、计时器、信号、用户生成事件。这些句柄通过GCD来并发执行。
GCD的API很大程度上基于block,当然,GCD也可以脱离block来使用,比如使用传统c机制提供函数指针和上下文指针。实践证明,当配合block使用时,GCD非常简单易用且能发挥其最大能力。
你可以在Mac上敲命令“man dispatch”来获取GCD的文档。
为何使用?
GCD提供很多超越传统多线程编程的优势:
Dispatch Objects
尽管GCD是纯c语言的,但它被组建成面向对象的风格。GCD对象被称为dispatch object。Dispatch object像Cocoa对象一样是引用计数的。使用dispatch_release和dispatch_retain函数来操作dispatch object的引用计数来进行内存管理。但注意不像Cocoa对象,dispatch object并不参与垃圾回收系统,所以即使开启了GC,你也必须手动管理GCD对象的内存。
Dispatch queues 和 dispatch sources(后面会介绍到)可以被挂起和恢复,可以有一个相关联的任意上下文指针,可以有一个相关联的任务完成触发函数。可以查阅“man dispatch_object”来获取这些功能的更多信息。
Dispatch Queues
GCD的基本概念就是dispatch queue。dispatch queue是一个对象,它可以接受任务,并将任务以先到先执行的顺序来执行。dispatch queue可以是并发的或串行的。并发任务会像NSOperationQueue那样基于系统负载来合适地并发进行,串行队列同一时间只执行单一任务。
GCD中有三种队列类型:
创建队列
要使用用户队列,我们首先得创建一个。调用函数dispatch_queue_create就行了。函数的第一个参数是一个标签,这纯是为了debug。 Apple建议我们使用倒置域名来命名队列,比如“com.dreamingwish.subsystem.task”。这些名字会在崩溃日志中被显示出 来,也可以被调试器调用,这在调试中会很有用。第二个参数目前还不支持,传入NULL就行了。
提交 Job
向一个队列提交Job很简单:调用dispatch_async函数,传入一个队列和一个block。队列会在轮到这个block执行时执行这个block的代码。下面的例子是一个在后台执行一个巨长的任务:
dispatch_async 函数会立即返回, block会在后台异步执行。
当然,通常,任务完成时简单地NSLog个消息不是个事儿。在典型的Cocoa程序中,你很有可能希望在任务完成时更新界面,这就意味着需要在主线程中执 行一些代码。你可以简单地完成这个任务——使用嵌套的dispatch,在外层中执行后台任务,在内层中将任务dispatch到main queue:
还有一个函数叫dispatch_sync,它干的事儿和dispatch_async相同,但是它会等待block中的代码执行完成并返回。结合 __block类型修饰符,可以用来从执行中的block获取一个值。例如,你可能有一段代码在后台执行,而它需要从界面控制层获取一个值。那么你可以使 用dispatch_sync简单办到:
我们还可以使用更好的方法来完成这件事——使用更“异步”的风格。不同于取界面层的值时要阻塞后台线程,你可以使用嵌套的block来中止后台线程,然后从主线程中获取值,然后再将后期处理提交至后台线程:
dispatch_queue_t bgQueue = myQueue; dispatch_async(dispatch_get_main_queue(), ^{ NSString *stringValue = [[[textField stringValue] copy] autorelease]; dispatch_async(bgQueue, ^{ // use stringValue in the background now }); });
取决于你的需求,myQueue可以是用户队列也可以使全局队列。
不再使用锁(Lock)
用户队列可以用于替代锁来完成同步机制。在传统多线程编程中,你可能有一个对象要被多个线程使用,你需要一个锁来保护这个对象:
NSLock *lock;
访问代码会像这样:
使用GCD,可以使用queue来替代:
dispatch_queue_t queue;
要用于同步机制,queue必须是一个用户队列,而非全局队列,所以使用usingdispatch_queue_create初始化一个。然后可以用dispatch_async 或者 dispatch_sync将共享数据的访问代码封装起来:
值得注意的是dispatch queue是非常轻量级的,所以你可以大用特用,就像你以前使用lock一样。
现在你可能要问:“这样很好,但是有意思吗?我就是换了点代码办到了同一件事儿。”
实际上,使用GCD途径有几个好处:
总结
现在你已经知道了GCD的基本概念、怎样创建dispatch queue、怎样提交Job至dispatch queue以及怎样将队列用作线程同步。接下来我会向你展示如何使用GCD来编写平行执行代码来充分利用多核系统的性能^ ^。我还会讨论GCD更深层的东西,包括事件系统和queue targeting。
概念
为了在单一进程中充分发挥多核的优势,我们有必要使用多线程技术(我们没必要去提多进程,这玩意儿和GCD没关系)。在低层,GCD全局dispatch queue仅仅是工作线程池的抽象。这些队列中的Block一旦可用,就会被dispatch到工作线程中。提交至用户队列的Block最终也会通过全局 队列进入相同的工作线程池(除非你的用户队列的目标是主线程,但是为了提高运行速度,我们绝不会这么干)。
有两种途径来通过GCD“榨取”多核心系统的性能:将单一任务或者一组相关任务并发至全局队列中运算;将多个不相关的任务或者关联不紧密的任务并发至用户队列中运算;
全局队列
设想下面的循环:
1
2
|
for(idobj in array)
[selfdoSomethingIntensiveWith:obj];
|
假定 -doSomethingIntensiveWith: 是线程安全的且可以同时执行多个.一个array通常包含多个元素,这样的话,我们可以很简单地使用GCD来平行运算:
1
2
3
4
5
|
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
for(idobj in array)
dispatch_async(queue, ^{
[selfdoSomethingIntensiveWith:obj];
});
|
如此简单,我们已经在多核心上运行这段代码了。
当然这段代码并不完美。有时候我们有一段代码要像这样操作一个数组,但是在操作完成后,我们还需要对操作结果进行其他操作:
1
2
3
|
for(idobj in array)
[selfdoSomethingIntensiveWith:obj];
[selfdoSomethingWith:array];
|
这时候使用GCD的 dispatch_async 就悲剧了.我们还不能简单地使用dispatch_sync来解决这个问题, 因为这将导致每个迭代器阻塞,就完全破坏了平行计算。
解决这个问题的一种方法是使用dispatch group。一个dispatch group可以用来将多个block组成一组以监测这些Block全部完成或者等待全部完成时发出的消息。使用函数 dispatch_group_create来创建,然后使用函数dispatch_group_async来将block提交至一个dispatch queue,同时将它们添加至一个组。所以我们现在可以重新编码:
1
2
3
4
5
6
7
8
9
10
|
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
dispatch_group_t group = dispatch_group_create();
for(idobj in array)
dispatch_group_async(group, queue, ^{
[selfdoSomethingIntensiveWith:obj];
});
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_release(group);
[selfdoSomethingWith:array];
|
如果这些工作可以异步执行,那么我们可以更风骚一点,将函数-doSomethingWith:放在后台执行。我们使用dispatch_group_async函数建立一个block在组完成后执行:
1
2
3
4
5
6
7
8
9
10
|
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
dispatch_group_t group = dispatch_group_create();
for(idobj in array)
dispatch_group_async(group, queue, ^{
[selfdoSomethingIntensiveWith:obj];
});
dispatch_group_notify(group, queue, ^{
[selfdoSomethingWith:array];
});
dispatch_release(group);
|
不仅所有数组元素都会被平行操作,后续的操作也会异步执行,并且这些异步运算都会将程序的其他部分的负载考虑在内。注意如果-doSomethingWith:需要在主线程中执行,比如操作GUI,那么我们只要将main queue而非全局队列传给dispatch_group_notify函数就行了。
对于同步执行,GCD提供了一个简化方法叫做dispatch_apply。这个函数调用单一block多次,并平行运算,然后等待所有运算结束,就像我们想要的那样:
1
2
3
4
5
|
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
dispatch_apply([arraycount], queue, ^(size_t index){
[selfdoSomethingIntensiveWith:[arrayobjectAtIndex:index]];
});
[selfdoSomethingWith:array];
|
这很棒,但是异步咋办?dispatch_apply函数可是没有异步版本的。但是我们使用的可是一个为异步而生的API啊!所以我们只要用dispatch_async函数将所有代码推到后台就行了:
1
2
3
4
5
6
7
|
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
dispatch_async(queue, ^{
dispatch_apply([arraycount], queue, ^(size_t index){
[selfdoSomethingIntensiveWith:[arrayobjectAtIndex:index]];
});
[selfdoSomethingWith:array];
});
|
简单的要死!
这种方法的关键在于确定我们的代码是在一次对不同的数据片段进行相似的操作。如果你确定你的任务是线程安全的(不在本篇讨论范围内)那么你可以使用GCD来重写你的循环了,更平行更风骚。
要看到性能提升,你还得进行一大堆工作。比之线程,GCD是轻量和低负载的,但是将block提交至queue还是很消耗资源的——block需要被拷贝 和入队,同时适当的工作线程需要被通知。不要将一张图片的每个像素作为一个block提交至队列,GCD的优点就半途夭折了。如果你不确定,那么请进行试 验。将程序平行计算化是一种优化措施,在修改代码之前你必须再三思索,确定修改是有益的(还有确保你修改了正确的地方)。
Subsystem并发运算
前面的章节我们讨论了在程序的单个subsystem中发挥多核心的优势。下来我们要跨越多个子系统。
例如,设想一个程序要打开一个包含meta信息的文档。文档数据本身需要解析并转换至模型对象来显示,meta信息也需要解析和转换。但是,文档数据和 meta信息不需要交互。我们可以为文档和meta各创建一个dispatch queue,然后并发执行。文档和meta的解析代码都会各自串行执行,从而不用考虑线程安全(只要没有文档和meta之间共享的数据),但是它们还是并 发执行的。
一旦文档打开了,程序需要响应用户操作。例如,可能需要进行拼写检查、代码高亮、字数统计、自动保存或者其他什么。如果每个任务都被实现为在不同的 dispatch queue中执行,那么这些任务会并发执行,并各自将其他任务的运算考虑在内(respect to each other),从而省去了多线程编程的麻烦。
使用dispatch source(下次我会讲到),我们可以让GCD将事件直接传递给用户队列。例如,程序中监视socket连接的代码可以被置于它自己的dispatch queue中,这样它会异步执行,并且执行时会将程序其他部分的运算考虑在内。另外,如果使用用户队列的话,这个模块会串行执行,简化程序。
结论
我们讨论了如何使用GCD来提升程序性能以及发挥多核系统的优势。尽管我们需要比较谨慎地编写并发程序,GCD还是使得我们能更简单地发挥系统的可用计算资源。
下一篇中,我们将讨论dispatch source,也就是GCD的监视内部、外部事件的机制。
何为Dispatch Sources
简单来说,dispatch source是一个监视某些类型事件的对象。当这些事件发生时,它自动将一个block放入一个dispatch queue的执行例程中。
说的貌似有点不清不楚。我们到底讨论哪些事件类型?
下面是GCD 10.6.0版本支持的事件:
这是一堆很有用的东西,它支持所有kqueue所支持的事件(kqueue是什么?见http://en.wikipedia.org/wiki/Kqueue)以及mach(mach是什么?见http://en.wikipedia.org/wiki/Mach_(kernel))端口、内建计时器支持(这样我们就不用使用超时参数来创建自己的计时器)和用户事件。
用户事件
这些事件里面多数都可以从名字中看出含义,但是你可能想知道啥叫用户事件。简单地说,这种事件是由你调用dispatch_source_merge_data函数来向自己发出的信号。
这个名字对于一个发出事件信号的函数来说,太怪异了。这个名字的来由是GCD会在事件句柄被执行之前自动将多个事件进行联结。你可以将数据“拼接”至 dispatch source中任意次,并且如果dispatch queue在这期间繁忙的话,GCD只会调用该句柄一次(不要觉得这样会有问题,看完下面的内容你就明白了)。
用户事件有两种: DISPATCH_SOURCE_TYPE_DATA_ADD 和 DISPATCH_SOURCE_TYPE_DATA_OR.用户事件源有个 unsigned long data属性,我们将一个 unsigned long传入 dispatch_source_merge_data。当使用 _ADD版本时,事件在联结时会把这些数字相加。当使用 _OR版本时,事件在联结时会把这些数字逻辑与运算。当事件句柄执行时,我们可以使用dispatch_source_get_data函数访问当前值,然后这个值会被重置为0。
让我假设一种情况。假设一些异步执行的代码会更新一个进度条。因为主线程只不过是GCD的另一个dispatch queue而已,所以我们可以将GUI更新工作push到主线程中。然而,这些事件可能会有一大堆,我们不想对GUI进行频繁而累赘的更新,理想的情况是 当主线程繁忙时将所有的改变联结起来。
用dispatch source就完美了,使用DISPATCH_SOURCE_TYPE_DATA_ADD,我们可以将工作拼接起来,然后主线程可以知道从上一次处理完事件到现在一共发生了多少改变,然后将这一整段改变一次更新至进度条。
啥也不说了,上代码:
(对于这段代码,我很想说点什么,我第一次用dispatch source时,我纠结了很久很久,真让人蛋疼:Dispatch source启动时默认状态是挂起的,我们创建完毕之后得主动恢复,否则事件不会被传递,也不会被执行)
假设你已经将进度条的min/max值设置好了,那么这段代码就完美了。数据会被并发处理。当每一段数据完成后,会通知dispatch source并将dispatch source data加1,这样我们就认为一个单元的工作完成了。事件句柄根据已完成的工作单元来更新进度条。若主线程比较空闲并且这些工作单元进行的比较慢,那么事 件句柄会在每个工作单元完成的时候被调用,实时更新。如果主线程忙于其他工作,或者工作单元完成速度很快,那么完成事件会被联结起来,导致进度条只在主线 程变得可用时才被更新,并且一次将积累的改变更新至GUI。
现在你可能会想,听起来倒是不错,但是要是我不想让事件被联结呢?有时候你可能想让每一次信号都会引起响应,什么后台的智能玩意儿统统不要。啊。。其实很 简单的,别把自己绕进去了。如果你想让每一个信号都得到响应,那使用dispatch_async函数不就行了。实际上,使用的dispatch source而不使用dispatch_async的唯一原因就是利用联结的优势。
内建事件
上面就是怎样使用用户事件,那么内建事件呢?看看下面这个例子,用GCD读取标准输入:
简单的要死!因为我们使用的是全局队列,句柄自动在后台执行,与程序的其他部分并行,这意味着对这种情况的提速:事件进入程序时,程序正在处理其他事务。
这是标准的UNIX方式来处理事务的好处,不用去写loop。如果使用经典的 read调用,我们还得万分留神,因为返回的数据可能比请求的少,还得忍受无厘头的“errors”,比如 EINTR (系统调用中断)。使用GCD,我们啥都不用管,就从这些蛋疼的情况里解脱了。如果我们在文件描述符中留下了未读取的数据,GCD会再次调用我们的句柄。
对于标准输入,这没什么问题,但是对于其他文件描述符,我们必须考虑在完成读写之后怎样清除描述符。对于dispatch source还处于活跃状态时,我们决不能关闭描述符。如果另一个文件描述符被创建了(可能是另一个线程创建的)并且新的描述符刚好被分配了相同的数字, 那么你的dispatch source可能会在不应该的时候突然进入读写状态。de这个bug可不是什么好玩的事儿。
适当的清除方式是使用 dispatch_source_set_cancel_handler,并传入一个block来关闭文件描述符。然后我们使用 dispatch_source_cancel来取消dispatch source,使得句柄被调用,然后文件描述符被关闭。
使用其他dispatch source类型也差不多。总的来说,你提供一个source(mach port、文件描述符、进程ID等等)的区分符来作为diapatch source的句柄。mask参数通常不会被使用,但是对于 DISPATCH_SOURCE_TYPE_PROC 来说mask指的是我们想要接受哪一种进程事件。然后我们提供一个句柄,然后恢复这个source(前面我加粗字体所说的,得先恢复),搞定。dispatch source也提供一个特定于source的data,我们使用 dispatch_source_get_data函数来访问它。例如,文件描述符会给出大致可用的字节数。进程source会给出上次调用之后发生的事件的mask。具体每种source给出的data的含义,看man page吧。
计时器
计时器事件稍有不同。它们不使用handle/mask参数,计时器事件使用另外一个函数 dispatch_source_set_timer 来配置计时器。这个函数使用三个参数来控制计时器触发:
start参数控制计时器第一次触发的时刻。参数类型是 dispatch_time_t,这是一个opaque类型,我们不能直接操作它。我们得需要dispatch_time 和 dispatch_walltime 函数来创建它们。另外,常量 DISPATCH_TIME_NOW 和 DISPATCH_TIME_FOREVER 通常很有用。
interval参数没什么好解释的。
leeway参 数比较有意思。这个参数告诉系统我们需要计时器触发的精准程度。所有的计时器都不会保证100%精准,这个参数用来告诉系统你希望系统保证精准的努力程 度。如果你希望一个计时器没五秒触发一次,并且越准越好,那么你传递0为参数。另外,如果是一个周期性任务,比如检查email,那么你会希望每十分钟检 查一次,但是不用那么精准。所以你可以传入60,告诉系统60秒的误差是可接受的。
这样有什么意义呢?简单来说,就是降低资源消耗。如果系统可以让cpu休息足够长的时间,并在每次醒来的时候执行一个任务集合,而不是不断的醒来睡去以执 行任务,那么系统会更高效。如果传入一个比较大的leeway给你的计时器,意味着你允许系统拖延你的计时器来将计时器任务与其他任务联合起来一起执行。
总结
现在你知道怎样使用GCD的dispatch source功能来监视文件描述符、计时器、联结的用户事件以及其他类似的行为。由于dispatch source完全与dispatch queue相集成,所以你可以使用任意的dispatch queue。你可以将一个dispatch source的句柄在主线程中执行、在全局队列中并发执行、或者在用户队列中串行执行(执行时会将程序的其他模块的运算考虑在内)。
下一篇我会讨论如何对dispatch queue进行挂起、恢复、重定目标操作;如何使用dispatch semaphore;如何使用GCD的一次性初始化功能。
Dispatch Queue挂起
dispatch queue可以被挂起和恢复。使用 dispatch_suspend函数来挂起,使用 dispatch_resume 函数来恢复。这两个函数的行为是如你所愿的。另外,这两个函数也可以用于dispatch source。
一个要注意的地方是,dispatch queue的挂起是block粒度的。换句话说,挂起一个queue并不会将当前正在执行的block挂起。它会允许当前执行的block执行完毕,然后后续的block不再会被执行,直至queue被恢复。
还有一个注意点:从man页上得来的:如果你挂起了一个queue或者source,那么销毁它之前,必须先对其进行恢复。
Dispatch Queue目标指定
所有的用户队列都有一个目标队列概念。从本质上讲,一个用户队列实际上是不执行任何任务的,但是它会将任务传递给它的目标队列来执行。通常,目标队列是默认优先级的全局队列。
用户队列的目标队列可以用函数 dispatch_set_target_queue来 修改。我们可以将任意dispatch queue传递给这个函数,甚至可以是另一个用户队列,只要别构成循环就行。这个函数可以用来设定用户队列的优先级。比如我们可以将用户队列的目标队列设 定为低优先级的全局队列,那么我们的用户队列中的任务都会以低优先级执行。高优先级也是一样道理。
有一个用途,是将用户队列的目标定为main queue。这会导致所有提交到该用户队列的block在主线程中执行。这样做来替代直接在主线程中执行代码的好处在于,我们的用户队列可以单独地被挂起 和恢复,还可以被重定目标至一个全局队列,然后所有的block会变成在全局队列上执行(只要你确保你的代码离开主线程不会有问题)。
还有一个用途,是将一个用户队列的目标队列指定为另一个用户队列。这样做可以强制多个队列相互协调地串行执行,这样足以构建一组队列,通过挂起和暂停那个 目标队列,我们可以挂起和暂停整个组。想象这样一个程序:它扫描一组目录并且加载目录中的内容。为了避免磁盘竞争,我们要确定在同一个物理磁盘上同时只有 一个文件加载任务在执行。而希望可以同时从不同的物理磁盘上读取多个文件。要实现这个,我们要做的就是创建一个dispatch queue结构,该结构为磁盘结构的镜像。
首先,我们会扫描系统并找到各个磁盘,为每个磁盘创建一个用户队列。然后扫描文件系统,并为每个文件系统创建一个用户队列,将这些用户队列的目标队列指向 合适的磁盘用户队列。最后,每个目录扫描器有自己的队列,其目标队列指向目录所在的文件系统的队列。目录扫描器枚举自己的目录并为每个文件向自己的队列提 交一个block。由于整个系统的建立方式,就使得每个物理磁盘被串行访问,而多个物理磁盘被并行访问。除了队列初始化过程,我们根本不需要手动干预什么 东西。
信号量
dispatch的信号量是像其他的信号量一样的,如果你熟悉其他多线程系统中的信号量,那么这一节的东西再好理解不过了。
信号量是一个整形值并且具有一个初始计数值,并且支持两个操作:信号通知和等待。当一个信号量被信号通知,其计数会被增加。当一个线程在一个信号量上等待时,线程会被阻塞(如果有必要的话),直至计数器大于零,然后线程会减少这个计数。
我们使用函数 dispatch_semaphore_create 来创建dispatch信号量,使用函数 dispatch_semaphore_signal 来信号通知,使用函数dispatch_semaphore_wait 来等待。这些函数的man页有两个很好的例子,展示了怎样使用信号量来同步任务和有限资源访问控制。
单次初始化
GCD还提供单次初始化支持,这个与pthread中的函数 pthread_once 很相似。GCD提供的方式的优点在于它使用block而非函数指针,这就允许更自然的代码方式:
这个特性的主要用途是惰性单例初始化或者其他的线程安全数据共享。典型的单例初始化技术看起来像这样(线程安全的):
这挺好的,但是代价比较昂贵;每次调用 +sharedWhatever 函数都会付出取锁的代价,即使这个锁只需要进行一次。确实有更风骚的方式来实现这个,使用类似双向锁或者是原子操作的东西,但是这样挺难弄而且容易出错。
使用GCD,我们可以这样重写上面的方法,使用函数 dispatch_once:
这个稍微比 @synchronized方法简单些,并且GCD确保以更快的方式完成这些检测,它保证block中的代码在任何线程通过 dispatch_once 调用之前被执行,但它不会强制每次调用这个函数都让代码进行同步控制。实际上,如果你去看这个函数所在的头文件,你会发现目前它的实现其实是一个宏,进行了内联的初始化测试,这意味着通常情况下,你不用付出函数调用的负载代价,并且会有更少的同步控制负载。
结论
这一章,我们介绍了dispatch queue的挂起、恢复和目标重定,以及这些功能的一些用途。另外,我们还介绍了如何使用dispatch 信号量和单次初始化功能。到此,我已经完成了GCD如何运作以及如何使用的介绍。
其主要思路是使用gcd创建串行队列,然后在此队列中先后执行两个任务:1.预加载一个viewController 2.将这个viewController推入
代码如下:
概述
我将分四步来带大家研究研究程序的并发计算。第一步是基本的串行程序,然后使用GCD把它并行计算化。如果你想顺着步骤来尝试这些程序的话,可以下载源码。注意,别运行imagegcd2.m,这是个反面教材。。
原始程序
我们的程序只是简单地遍历~/Pictures然后生成缩略图。这个程序是个命令行程序,没有图形界面(尽管是使用Cocoa开发库的),主函数如下:
如果你要看到所有的副主函数的话,到文章顶部下载源代码吧。当前这个程序是imagegcd1.m。程序中重要的部分都在这里了。. Start 函数和 End 函数只是简单的计时函数(内部实现是使用的gettimeofday函数)。ThumbnailDataForData函数使用Cocoa库来加载图片数据生成Image对象,然后将图片缩小到320×320大小,最后将其编码为JPEG格式。
简单而天真的并发
乍一看,我们感觉将这个程序并发计算化,很容易。循环中的每个迭代器都可以放入GCD global queue中。我们可以使用dispatch queue来等待它们完成。为了保证每次迭代都会得到唯一的文件名数字,我们使用OSAtomicIncrement32来原子操作级别的增加count 数:
这个就是imagegcd2.m,但是,注意,别运行这个程序,有很大的问题。
如果你无视我的警告还是运行这个imagegcd2.m了,你现在很有可能是在重启了电脑后,又打开了我的页面。。如果你乖乖地没有运行这个程序的话,运行这个程序发生的情况就是(如果你有很多很多图片在~/Pictures中):电脑没反应,好久好久都不动,假死了。。
问题在哪
问题出在哪?就在于GCD的智能上。GCD将任务放到全局线程池中运行,这个线程池的大小根据系统负载来随时改变。例如,我的电脑有四核,所以如果我使用 GCD加载任务,GCD会为我每个cpu核创建一个线程,也就是四个线程。如果电脑上其他任务需要进行的话,GCD会减少线程数来使其他任务得以占用 cpu资源来完成。
但是,GCD也可以增加活动线程数。它会在其他某个线程阻塞时增加活动线程数。假设现在有四个线程正在运行,突然某个线程要做一个操作,比如,读文件,这 个线程就会等待磁盘响应,此时cpu核心会处于未充分利用的状态。这是GCD就会发现这个状态,然后创建另一个线程来填补这个资源浪费空缺。
现在,想想上面的程序发生了啥?主线程非常迅速地将任务不断放入global queue中。GCD以一个少量工作线程的状态开始,然后开始执行任务。这些任务执行了一些很轻量的工作后,就开始等待磁盘资源,慢得不像话的磁盘资源。
我们别忘记磁盘资源的特性,除非你使用的是SSD或者牛逼的RAID,否则磁盘资源会在竞争的时候变得异常的慢。。
刚开始的四个任务很轻松地就同时访问到了磁盘资源,然后开始等待磁盘资源返回。这时GCD发现CPU开始空闲了,它继续增加工作线程。然后,这些线程执行更多的磁盘读取任务,然后GCD再创建更多的工资线程。。。
可能在某个时间文件读取任务有完成的了。现在,线程池中可不止有四个线程,相反,有成百上千个。。。GCD又会尝试将工作线程减少(太多使用CPU资源的 线程),但是减少线程是由条件的,GCD不可以将一个正在执行任务的线程杀掉,并且也不能将这样的任务暂停。它必须等待这个任务完成。所有这些情况都导致 GCD无法减少工作线程数。
然后所有这上百个线程开始一个个完成了他们的磁盘读取工作。它们开始竞争CPU资源,当然CPU在处理竞争上比磁盘先进多了。问题在于,这些线程读完文件 后开始编码这些图片,如果你有很多很多图片,那么你的内存将开始爆仓。。然后内存耗尽咋办?虚拟内存啊,虚拟内存是啥,磁盘资源啊。Oh shit!~
然后进入了一个恶性循环,磁盘资源竞争导致更多的线程被创建,这些线程导致更多的内存使用,然后内存爆仓导致虚拟内存交换,直至GCD创建了系统规定的线程数上限(可能是512个),而这些线程又没法被杀掉或暂停。。。
这就是使用GCD时,要注意的。GCD能智能地根据CPU情况来调整工作线程数,但是它却无法监视其他类型的资源状况。如果你的任务牵涉大量IO或者其他会导致线程block的东西,你需要把握好这个问题。
修正
问题的根源来自于磁盘IO,然后导致恶性循环。解决了磁盘资源碰撞,就解决了这个问题。
GCD的custom queue使得这个问题易于解决。Custom queue是串行的。如果我们创建一个custom queue然后将所有的文件读写任务放入这个队列,磁盘资源的同时访问数会大大降低,资源访问碰撞就避免了。
虾米是我们修正后的代码,使用IO queue(也就是我们创建的custom queue专门用来读写磁盘):
这个就是我们的 imagegcd3.m.
GCD使得我们很容易就将任务的不同部分放入相同的队列中去(简单地嵌套一下dispatch)。这次我们的程序将会表现地很好。。。我是说多数情况。。。。
问题在于任务中的不同部分不是同步的,导致了整个程序的不稳定。我们的新程序的整个流程如下:
Main Thread IO Queue Concurrent Queue find paths ------> read -----------> process ... write <----------- process
图中的箭头是非阻塞的,并且会简单地将内存中的对象进行缓冲。
现在假设一个机器的磁盘足够快,快到比CPU处理任务(也就是图片处理)要快。其实不难想象:虽然CPU的动作很快,但是它的工作更繁重,解码、压缩、 编码。从磁盘读取的数据开始填满IO queue,数据会占用内存,很可能越占越多(如果你的~/Pictures中有很多很多图片的话)。
然后你就会内存爆仓,然后开始虚拟内存交换。。。又来了。。
这就会像第一次一样导致恶性循环。一旦任何东西导致工作线程阻塞,GCD就会创建更多的线程,这个线程执行的任务又会占用内存(从磁盘读取的数据),然后又开始交换内存。。
结果:这个程序要么就是运行地很顺畅,要么就是很低效。
注意如果磁盘速度比较慢的话,这个问题依旧会出现,因为缩略图会被缓冲在内存里,不过这个问题导致的低效比较不容易出现,因为缩略图占的内存少得多。
真正的修复
由于上一次我们的尝试出现的问题在于没有同步不同部分的操作,所以让我写出同步的代码。最简单的方法就是使用信号量来限制同时执行的任务数量。
那么,我们需要限制为多少呢?
显然我们需要根据CPU的核数来限制这个量,我们又想马儿好又想马儿不吃草,我们就设置为cpu核数的两倍吧。不过这里只是简单地这样处理,GCD的作用 之一就是让我们不用关心操作系统的内部信息(比如cpu数),现在又来读取cpu核数,确实不太妙。也许我们在实际应用中,可以根据其他需求来定义这个限 制量。
现在我们的主循环代码就是这样了:
最终我们写出了一个能平滑运行且又快速处理的程序。
基准测试
我测试了一些运行时间,对7913张图片:
程序处理时间 (秒)
imagegcd1.m | 984 |
imagegcd2.m | 没运行,这个还是别运行了 |
imagegcd3.m | 300 |
imagegcd4.m | 279 |
注意,因为我比较懒。所以我在运行这些测试的时候,没有关闭电脑上的其他程序。。。严格的进行对照的话,实在是太蛋疼了。。
所以这个数值我们只是参考一下。
比较有意思的是,3和4的执行状况差不多,大概是因为我电脑有15g可用内存吧。。。内存比较小的话,这个imagegcd3应该跑的很吃力,因为我发现它使用最多的时候,占用了10g内存。而4的话,没有占多少内存。
结论
GCD是个比较范特西的技术,可以办到很多事儿,但是它不能为你办所有的事儿。所以,对于进行IO操作并且可能会使用大量内存的任务,我们必须仔细斟酌。当然,即使这样,GCD还是为我们提供了简单有效的方法来进行并发计算。