第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

posted @ 2017-08-24 01:11  鳄鱼不怕牙医不怕  阅读(1194)  评论(0编辑  收藏  举报