第46条:不要使用 dispatch_get_current_queue
本条要点:(作者总结)
- dispatch_get_current_queue 函数对行为常常与开发者所预期的不同。此函数已经废弃、止应做调试之用。
- 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
- dispatch_get_current_queue 函数用于解决由不可重入的代码所引发的死锁,然而能用函数解决的问题,通常也能改用“队列特定数据”来解决。
使用 GCD 时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时,更是如此。例如,Mac OS X 与 iOS 的 UI 事务都需要在主线程上执行,而这个线程就相当于 GCD 中的主队列。有时似乎需要判断出当前代码是不是在主队列上执行。阅读开发文档时,大家会发现下面这个函数:
1 dispatch_queue_t dispatch_get_current_queue()
文档中说,此函数返回当前正在执行代码的队列。确实是这样,不过用的时候要小心。实际上,iOS 系统从 6.0 版本起,已经正式弃用此函数了。不过 Mac OS X 系统直到 10.8 版本也尚未将其废弃。虽说如此,但在 Mac OS X 系统里还是要避免使用它。
该函数有种典型的错误用法(antipattern, “反模式”),就是用它检测当前队列是不是某个特定的队列,试图以此来避免执行同步派发时可能遭遇的死锁问题。考虑下面这两个存取方法,其代码用队列来证实对实例变量的访问操作是同步的:
1 - (NSString *)someString { 2 3 __block NSString *localSomeString; 4 dispatch_sync(_syncQueue, ^{ 5 6 localSomeString = _someString; 7 }); 8 return localSomeString; 9 } 10 11 12 13 - (void)setSomeString:(NSString *)someString { 14 15 dispatch_async(_syncQueue, ^{ 16 17 _someString = someString; 18 }); 19 }
这种写法的问题在于,获取方法(getter)可能会死锁,假如调用获取方法的队列恰好是同步操作所针对的队列(本例中是 _syncQueue),那么 dispatch_sync 就一直不会返回,直到块执行完毕为止。可是,应该执行块的那个目标队列却是当前队列,而当前队列的 dispatch_sync 又一直阻塞着,它在等待目标队列把这个块执行完,这样一来,块就永远没机会执行了。像 someString 这种方法,就是 “不可重入的”。
看了 dispatch_get_current_queue 的文档后,你也许觉得可以用它改写这个方法,令其变得“可重入”,只需检测当前队列是否为同步操作所针对的队列,如果是,就不派发了,直接执行块即可:
1 - (NSString *)someString { 2 3 __block NSString *localSomeString; 4 dispatch_block_t accessorBlock = ^ { 5 localSomeString = _someString; 6 }; 7 if (dispatch_get_current_queue() == _syncQueue) { 8 accessorBlock(); 9 } else { 10 dispatch_sync(_syncQueue, accessorBlock); 11 } 12 13 return localSomeString; 14 }
这些做法可以处理一些简单的情况。不过仍然有死锁的危险。为说明原因,请读者考虑下面这段代码,其中有两个串行派发队列:
1 dispatch_queue_t queueA = dispatch_queue_create("com.efftiveobjectivec.queueA", NULL); 2 3 dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL); 4 5 dispatch_sync(queueA, ^{ 6 7 dispatch_sync(queueB, ^{ 8 9 dispatch_sync(queueA, ^{ 10 11 // Deadlock 12 }); 13 }); 14 15 });
这段代码执行到最内层的派发操作时,总会死锁,因为此操作是针对 queueA 队列的,所以必须等最外层的 dispatch_sync 执行完毕才行(因为最外层的派发操作与最内层一样,也是针对 queueA 的),而最外层的那个 dispatch_sync 又不可能执行完毕,因为它要等最内层的 dispatch_sync 执行完,于是就死锁了。现在按照刚才的办法,使用 dispatch_get_current_queue 来检测:
1 dispatch_sync(queueA, ^{ 2 3 dispatch_sync(queueB, ^{ 4 5 dispatch_block_t block = ^{/*...*/}; 6 if (dispatch_get_current_queue() == queueA) { 7 block(); 8 } else { 9 dispatch_sync(queueA, block); 10 } 11 12 }); 13 14 });
然而这样做依然死锁,因为 dispatch_get_current_queue 返回的是当前队列,在本例中就是 queueB。这样的话,针对queueA 的同步派发操作依然会执行,于是和刚才一样,还是死锁了。
在这种情况下,正确做法是:不要把存取方法做成可重入的,而是应该确保操作同步操作所用的队列绝不会访问属性,也就是绝对不会调用 someString 方法。这种队列只应该用来同步属性。由于派发队列是一种极为轻量的机制,所以,为了确保每项属性都有专用的同步队列,我们不妨创建多个队列。
刚才那个例子似乎稍显做作,但是使用队列时还要注意另外一个问题,而那个问题会在你意想不到的地方导致死锁。队列之间会形成一套层级体系,这意味着排在某条队列中的块,会在其上级队列(parent queue,也叫“父队列”)里执行。层级里地位最高的那个队列总是 “全局并发队列”(global concurrentqueue)图描绘了一套简单的队列体系。
排在队列B或队列C中的块,稍后会在队列A里依序执行。于是,排在队列A、B、C 中的块总是要彼此错开执行。然而,安排在队列D 中的块,则有可能与队列A 里的块(也包括队列B 与 队列C 里的块)并行,因为A 与 D 的目标队列是个并发队列。若有必要,并发队列可以用多个线程并行执行多个块,而是否会这样做,则需要根据 CPU 的核心数量等系统资源状况来定。
由于队列间有层级关系,所以 “检查当前队列是否为执行同步派发所用的队列”这种办法,并不总是奏效。比方说,排在队列C里的块,会认为当前队列就是队列C,而开发者可能据此认定:在队列A上能够安全的执行同步派发操作。但实际上,这么做依然会像前面那样导致死锁。
有的 API 可令开发者指定运行回调块时所用的队列,但实际上却会把回调块安排在内部的串行队列上,而内部队列的目标队列又是开发者所提供的那个队列,在此情况下,也许就要出现刚才说的那种问题了。使用这种 API 的开发者可能误以为:在回调块里调用 dispatch_get_current_queue 所返回的 “当前队列”,总是其调用API时指定的那个。但实际上返回的却是API内部的那个同步队列。
要解决这个问题,最好的办法就是通过 GCD 所提供的功能来设定“队列特有数据”(queue-specific data),此功能可以把任意数据以键值对的形式关联到队列里。最重要之处在于,假如根据指定的键获取不到关联数据,那么系统就会沿着层级体系向上查找,直至找到数据或到达根队列为止。笔者这么说,大家也许还不太明白其用法,所以看下面这个例子:
1 dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL); 2 3 dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL); 4 5 dispatch_set_target_queue(queueB, queueA); 6 7 8 9 static int kQueueSpecific; 10 11 CFStringRef queueSpecificValue = CFSTR("queueA"); 12 13 dispatch_queue_set_specific(queueA, &kQueueSpecific, (void*)queueSpecificValue,(dispatch_function_t)CFRelease); 14 15 dispatch_sync(queueB, ^{ 16 17 dispatch_block_t block = ^{ NSLog(@"No deadlock!");}; 18 CFStringRef retrievedValue = dispatch_get_specific(&kQueueSpecific); 19 if (retrievedValue) { 20 block(); 21 } else { 22 23 dispatch_sync(queueA, block); 24 } 25 26 });
本例创建了两个队列。代码中将队列B的目标队列设为队列A,而队列A的目标队列仍然是默认优先级的全局并发队列。热后使用下列函数,在队列A上设置“队列特定值”:
1 void dispatch_queue_set_specific(dispatch_queue_t queue, const void *key, void *context, dispatch_function_t destructor);
此函数的首个参数表示待设置数据队列,其后面两个参数是键与值。键与值都是不透明的void 指针。对于键来说,有个问题一定要注意:函数是按指针值来比较键的,而不是按照其内容。所以,“队列特定数据”的行为与 NSDictionary 对象不同,后者是比较键的 “对象等同性”。“队列特定数据”更像是关联引用。值(在函数原型里叫做 “context”(中文称为“上下文”、“语境”、“环境参数”等))也是不透明的void 指针,于是可以在其中存放任意数据。然而,必须管理该对象的内存。这使得在ARC 环境下很难使用Objective-C 对象作为值。范例代码使用 coreFoundation 字符串作为值,因为ARC 并不会自动管理CoreFoundation 对象的内存。所以说,这种对象非常适合充当“队列特定数据”,它们可以根据需要与相关的Objective-C Foundation 类无缝衔接。
函数的最后一个参数是“析构函数”(destructor function),对于给定的键来说,当队列所占内存为系统所回收,或者有新的值与键相关联时,原有的值对象就会移除,而析构函数也会于此时运行。dispatch_function_t 类型的定义如下:
1 typedef void (*dispatch_function_t) (void *)
由此可知,析构函数只能带有一个指针参数且返回值必须为 void。范例代码采用 CFRelease 做析构函数,此函数符合要求,不过也可以采用开发者自定义的函数,在其中调用 CFRelease 以清理旧值,并完成其他必要的清理工作。
于是,“队列特定数据”所提供的这套简单易用的机制,就避免了使用 dispatch_get_current_queue 时经常遭遇的一个陷阱。此外,调试程序时也许会经常用到 dispatch_get_current_queue。在此情况下,可以放心的使用这个已经废弃的方法,只是别把它编译到发行版本的程序里就行。如果对“访问当前队列” 这项操作有特殊需求,而现有函数又无法满足,那么最好还是联系苹果公司,请求其加入此功能。
END