41.多用派发队列,少用同步锁
在Objective-C中,如果有多个线程要执行同一份代码,那么有时可能会出问题。这种情况下,通常要使用锁来实现某种同步机制。在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block):
//第一种方式:@synchronized
-(void)synchronizedMethod{
@synchronized (self) {
//Safe
}
}
这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。在本例中,同步行为所针对的对象是self。这么写通常没错,因为它可以保证每个对象实例都能不受干扰地运行其synchronizedMethod方法。然而,滥用@synchronized(self)则会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行。若是在self对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
另一个办法是直接使用NSLock对象:
//第二种方式:NSLock
-(void)synchronizedMethod{
[_lock lock];
//Safe
[_lock unlock];
}
也可以使用NSRecursiveLock这种“递归锁”,线程能够多次持有该锁,而不会出现死锁现象。
这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。
替代方案就是使用GCD,它能以更简单、更高效的形式为代码加锁。比方说,属性就是开发者经常需要同步的地方,这种属性需要做成“原子的”。用atomic特质来修饰属性,即可实现这一点(第6条)。而开发者如果想自己来编写访问方法的话,那么通常会这样写:
-(NSString *)someString{
@synchronized (self) {
return _someString;
}
}
-(void)setSomeString:(NSString *)someString{
@synchronized (self) {
_someString = someString;
}
}
滥用@syncronized(self)会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完毕才能执行,这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。
这么做虽然能提供某种程度的“线程安全”,但却无法保证访问该对象时绝对是线程安全的。当然,访问属性的操作确实是“原子的”。使用属性时,必定能从中获取到有效值,然而在同一个线程上多次调用获取方法,每次获取到的结果却未必相同。在两次访问操作之间,其他线程可能会写入新的属性值。
有种简单而高效的办法可以代替同步块或锁对象,那就是使用“串行同步队列”(serial synchronization queue)。将读取操作及写入操作都安排在同一个队列里,即可保证数据同步。其用法如下:
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_sync(_syncQueue, ^{
_someString = someString;
});
}
此模式的思路是:把设置操作与获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作就都同步了。为了使块代码能够设置局部变量,获取方法中用到了__block语法。全部加锁任务都在GCD中处理,而GCD是在相当深的底层来实现的,于是能够做许多优化。
还可以进一步优化,设置方法并不一定非得是同步的。设置实例变量所用的块,并不需要向设置方法返回什么值。也就是说,设置方法的代码可以改成这样:
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
这次只是把同步派发改成了异步派发,从调用者的角度来看,这个小改动可以提升设置方法的执行速度,而读取操作与写入操作依然会按顺序执行。但这么改有个坏处:如果你测一下程序性能,那么可能会发现这种写法比原来慢,因为执行异步派发时,需要拷贝块。若拷贝块所用的时间明显超过执行块所花的时间,则这种做法比原来慢。由于这里所举的例子很简单,所以改完之后很可能会变慢。然而,若是派发给队列的块要执行更为繁重的任务,那么仍然可以考虑这种备选方案。
多个获取方法可以并发执行,而获取方法与设置方法之间不能并发执行,利用这个特点,还能写出更快一些的代码来。此时正可以体现出GCD写法的好处。用同步块或锁对象,是无法轻易实现出下面这种方案的。这次不用串行队列,而改用并发队列(concurrent queue):
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
像现在这样写代码,还无法正确实现同步。所有读取操作与写入操作都会在同一个队列上执行,不过由于是并发队列,所以读取与写入操作可以随时执行。而我们恰恰不想让这些操作随意执行。此问题用一个简单的GCD功能即可解决。它就是栅栏(barrier)。下列函数可以向队列中派发块,将其作为栅栏使用:
void dispatch_barrier_async(dispatch_queue_t queue,
dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue,
dispatch_block_t block);
在队列中,栅栏块必须单独执行,不能与其他块并行。这只对并发队列有意义,因为串行队列中的块总是按顺序逐个来执行的。并发队列如果发现接下来要处理的块是个栅栏块(barrier block),那么就一直要等当前所有并发块都执行完毕,才会单独执行这个栅栏。待栅栏执行过后,再按正常方式继续向下处理。
在本例中,可以用栅栏块来实现属性的设置方法。在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写入操作却必须单独执行了。下图演示的这个队列中,有许多读取操作,而且还有一个写入操作:
实现代码很简单:
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}
这种做法肯定比使用串行队列要快。注意,设置函数也可以改用同步的栅栏块来实现,那样做可能会更高效。最好还是测一测每种做法的性能,然后从中选出最适合当前场景的方案。
要点:
- 派发队列可用来表述同步语义,这种做法要比使用@synchronized块或NSLock对象更简单。
- 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
- 使用同步队列及栅栏块,可以令同步行为更加高效。
42.多用GCD,少用preformSelector系列方法
Objective-C本质上是一门非常动态的语言,NSObject定义了几个方法,令开发者可以随意调用任何方法。这几个方法可以推迟执行方法调用,也可以指定运行方法所用的线程。这些功能原来很有用,但是在出现了大中枢派发及块这样的新技术之后,就显得不那么必要了。虽然有些代码还是会经常用到它们,但是尽量避开为好。
这其中最简单的是“performSelector:”。该方法的签名如下,它接受一个参数,就是要执行的那个选择子:
- (id)performSelector:(SEL)aSelector;
该方法与直接调用选择子等效。所以下面两行代码的执行效果相同:
[object performSelector:@selector(selectorName)];
[object selectorName];
这种方式看上去似乎多余。如果某个方法只是这么来调用的话,那么此方式确实多余。然而,如果选择子是在运行期决定的,那么就能体现出此方法的强大之处了。这就等于在动态绑定之上再次使用动态绑定,因而可以实现出下面这种功能:
SEL selecotr;
if(/*some condition*/){
selecotr = @selector(foo);
}else if (/*some other condition*/){
selecotr = @selector(bar);
}else{
selecotr = @selector(baz);
}
[object performSelector:selecotr];
这种编程方式极为灵活,经常可用来简化复杂的代码。还有一种用法,就是先把选择子保存起来,等某个事件发生之后再调用。不管哪种用法,编译器都不知道要执行的选择子是什么,这必须到了运行期才能确定。然而,使用此特性的代价是,如果在ARC下编译代码,那么编译器就会发出如下警告信息:
warning:performSelector may cause a leak because its selector
is unknown [-Warc-performSelector-leaks]
原因在于:编译器并不知道将要调用的选择子是什么,因此,也就不了解其方法签名及返回值,甚至连是否有返回值都不清楚。而且,由于编译器不知道方法名,所以就没办法运用ARC的内存管理规则来判定返回值是不是应该释放。鉴于此,ARC采用了比较谨慎的做法,就是不添加释放操作。然而这么做可能导致内存泄露,因为方法在返回对象时可能已经将其保留了。
考虑下面这段代码:
SEL selecotr;
if(/*some condition*/){
selecotr = @selector(newObject);
}else if (/*some other condition*/){
selecotr = @selector(copy);
}else{
selecotr = @selector(someProperty);
}
id ret = [object performSelector:selecotr];
如果调用的是前两个选择子之一,那么ret对象应由这段代码来释放,而如果是第三个选择子,则无须释放。不仅在ARC环境下应该如此,而在在非ARC环境下也应该这么做,这样才算严格遵循了方法的命名规范。如果不使用ARC(此时编译器就不发警告信息了),那么在前两种情况下需要手动释放ret对象,而在后一种情况下则不需要释放。这个问题很容易忽视,而且就算用静态分析器,也很难侦测到随后的内存泄露。performSelector系列的方法之所以要谨慎使用,这就是其中一个原因。
这些方法不甚理想,另一个原因在于:返回值只能是void或对象类型。尽管所要执行的选择子也可以返回void,但是performSelector方法的返回值类型毕竟是id。如果想返回整数或浮点等类型的值,那么就需要执行一些复杂的转换操作了,而这种转换很容易出错。由于id类型表示指向任意Objective-C对象的指针,所以从技术上来讲,只要返回值的大小和指针所占大小相同就行,也就是说:在32位架构的计算机上,可以返回任意32位大小的类型;而在64位架构的计算机上,则可返回任意64位大小的类型。若返回值的类型为C语言结构退,则不可使用performSelector方法。
performSelector还有如下几个版本,可以在发消息时顺便传递参数:
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
比方说,可以用下面这个版本来设置对象中名为value的属性值:
id object = /* an object with a property called value */;
id newValue = /* new value for the property */;
[object performSelector:@selector(setValue:) withObject:newValue];
这些方法貌似有用,但其实局限颇多。由于参数类型是id,所以传入的参数必须是对象才行。如果选择子所接受的参数是整数或浮点数,那就不能采用这些方法了。此外,选择子最多只能接受两个参数,也就是调用“performSelector: withObject: withObject:”这个版本。而在参数不止两个的情况下,则没有对应的performSelector方法能够执行此种选择子。
performSelector系列方法haunted有个功能,就是可以延后执行选择子,或将其放在另一个线程上执行。下面列出了此方法中一些更为常用的版本:
- (void)performSelector:(SEL)aSelector
withObject:(nullable id)anArgument
afterDelay:(NSTimeInterval)delay;
- (void)performSelector:(SEL)aSelector
onThread:(NSThread *)thr
withObject:(nullable id)arg
waitUntilDone:(BOOL)wait;
- (void)performSelectorOnMainThread:(SEL)aSelector
withObject:(nullable id)arg
waitUntilDone:(BOOL)wait;
然而很快就会发觉,这些方法太过局限了。例如,具备延后执行功能的那些方法都无法处理带有两个参数的选择子。而能够指定执行线程的那些方法,则与之类似,所以也不是特别通用。如果要用这些方法,就得把许多参数都打包到字典中,然后在受调用的方法里将其提取出来,这样会增加开销,而且还可能出bug。
如果改用其他替代方案,那就不受这些限制了。最主要的替代方案就是使用块。而且,performSelector系列方法所提供的线程功能,都可以通过在大中枢派发机制中使用块来实现。延后执行可以用dispatch_after来实现,在另一个线程上执行任务则可以通过dispatch_sync及dispatch_async来实现。
例如,要延后执行某项任务,可以有下面两种实现方式,而我们应该优先考虑第二种:
//Using performSelector:withObject:afterDelay:
[self performSelector:@selector(doSomething) withObject:nil afterDelay:5.0];
//Using dispatch_after
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0*NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
[self doSomething];
});
想把任务放在主线程上执行,也可以有下面两种方式,而我们还是应该优选后者:
//Using performSelectorOnMainThread: withObject: waitUntilDone:
[self performSelectorOnMainThread:@selector(doSomething) withObject:nil waitUntilDone:NO];
//Using dispatch_async
//(or if waitUntilDone is YES, then dispatch_sync)
dispatch_async(dispatch_get_main_queue(), ^{
[self doSomething];
});
waitUntilDone为NO时相当于使用dispatch_async;waitUntilDone为YES时相当于使用dispatch_sync。
要点:
- performSelector系列方法在内存管理方面容易有疏失。它无法确定将要执行的选择子具体是什么,因而ARC编译器也就无法插入适当的内存管理方法。
- performSelector系列方法所能处理的选择子太过局限了,选择子的返回值类型及发送给方法的参数个数都受到限制。
- 如果想把任务放在另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。
43.掌握GCD及操作队列的使用时机
GCD技术确实很棒,不过有时候采用标准系统库的组件,效果会更好。一定要了解每项技巧的使用时机,如果选错了工具,那么编出来的代码就会难于维护。
很少有其他技术能与GCD的同步机制想媲美。对于那些只需执行一次的代码来说,也是如此,使用GCD的dispatch_once最为方便。然而,在执行后台任务时,GCD并不一定是最佳方式。还有一种技术叫做NSOperationQueue,它虽然与GCD不同,但是却与之相关,开发者可以把操作以NSOperation子类的形式放在队列中,而这些操作也能够并发执行。其与GCD派发队列有相似之处,这并非巧合。“操作队列”(operation queue)在GCD之前就有了,其中某些设计原理操作队列而流行,GCD就是基于这些原理构建的。实际上,从iOS4与Mac OS X 10.6开始,操作队列在底层是用GCD来实现的。
在两者的诸多差别中,首先要注意:GCD是纯C的API,而操作队列则是Objective-C的对象。在GCD中,任务用块来表示,而块是个轻量级数据结构。与之相反,“操作”(operation)则是个更为重量级的Objective-C对象。虽说如此,但GCD并不总是最佳方案。有时候采用对象所带来的开销微乎其微,使用完整对象所带来的好处反而大大超过其缺点。
使用NSOperationQueue类的“addOperationWithBlock:”方法搭配NSBlockOperation类来使用操作队列,其语法与纯GCD方式非常相似。使用NSOperation及NSOperationQueue的好处如下:
- 取消某个操作。如果使用操作队列,那么想要取消操作是很容易的。运行任务之前,可以在NSOperation对象上调用cancel方法,该方法会设置对象内的标志位,用以表明此任务不需执行,不过,已经启动的任务无法取消。若是不是使用操作队列,而是把块安排到GCD队列,那就无法取消了。那套架构是“安排好任务之后就不管了”(fire and forget)。开发者可以在应用程序层自己来实现取消功能,不过这样做需要编写很多代码,而那些代码其实已经由操作队列实现好了。
- 指定操作间的依赖关系。一个操作可以依赖其他多个操作。开发者能够指定操作之间的依赖体系,使特定的操作必须在另外一个操作顺利执行完毕后方可执行。比方说,从服务器端下载并处理文件的动作,可以用操作来表示,而在处理其他文件之前,必须先下载“清单文件”(manifest file)。后续的下载操作,都要依赖于先下载清单文件这一操作。如果操作队列允许并发的话,那么后续的多个下载操作就可以同时执行,但前提是它们所依赖的那个清单文件下载操作已经执行完毕。
- 通过键值观测机制监控NSOperation对象的属性。NSOperation对象有许多属性都适合通过键值观测机制(KVO)来监听,比如可以通过isCancelled属性来判断任务是否已取消,又比如可以通过isFinished属性来判断任务是否已完成。如果想在某个任务变更其状态时得到通知,或是想用比GCD更为精细的方式来控制所要执行的任务,那么键值观测机制会很有用。
- 指定操作的优先级。操作的优先级表示此操作与队列中的其他操作之间的优先级关系。优先级高的操作先执行,优先级低的后执行。操作队列的调度算法虽“不透明”,但必然是经过一番深思熟虑才写成的。反之,GCD则没有直接实现此功能的办法。GCD的队列确实有优先级,不过那是针对整个队列来说的,而不是针对每个块来说的。而令开发者在GCD之上自己来编写调度算法,又不太合适。因此,在优先级这一点上,操作队列所提供的功能要比GCD更为便利。NSOperation对象也有“线程优先级”(thread priority),这决定了运行此操作的线程处在何种优先级上。用GCD也可以实现此功能,然而采用操作队列更简单,只需设置一个属性。
- 重用NSOperation对象。系统内置了一些NSOperation的子类(比附NSBlockOperation)供开发者调用,要是不想用这些固有子类的话,那就得自己来创建了。这些类就是普通的Objective-C对象,能够存放任何信息。对象在执行时可以充分利用存放于其中的信息,而且还可以随意调用定义在类中的方法。这就比派发队列中那些简单的块要强大许多。这些NSOperation类可以在代码中多次使用,它们符合软件开发中的“不重复”(Don’t Repeat Yourself,DRY)原则。
操作队列有很多地方胜过派发队列。操作队列提供了多种执行任务的方式,而且都是写好了的,直接就能使用。开发者不用再编写复杂的调度器,也不用自己来实现取消操作或者指定操作优先级的功能,这些事情操作队列都已经实现好了。
有一个API选用了操作队列而非派发队列,这就是NSNotificationCenter,开发者可通过其中的方法来注册监听器,以便在发生相关事件时得到通知,而这个方法接受的参数是块,不是选择子。方法原型如下:
- (id <NSObject>)addObserverForName:(nullable NSString *)name
object:(nullable id)obj
queue:(nullable NSOperationQueue *)queue
usingBlock:(void (^)(NSNotification *note))block ;
某些功能确实可以用高层的Objective-C方法来做,但这并不等于说它就一定比底层实现方案好。要想确定哪种方案更佳,最好还是测试一下性能。
要点:
- 在解决多线程与任务管理问题时,派发队列并非唯一方案。
- 操作队列提供了一套高层的Objective-C API,能实现纯GCD所具备的绝大部分功能,而且还能完成一些更为复杂的操作,那些操作若改用GCD来实现,则需另外编写代码。
44.通过Dispatch Group机制,根据系统资源状况来执行任务
dispatch group(派发分组,调度组)是GCD的一项特性,能够把任务分组。调用者可以等待这组任务执行完毕,也可以在提供回调函数之后继续往下执行,这组任务完成时,调用者会得到通知。这个功能有许多用途,其中最重要、最值得注意的用法,就是把将要并发执行的多个任务合为一个组,于是调用者就可以知道这些任务何时才能全部执行完毕。比方说,可以把压缩一系列文件的任务表示成dispatch group。
下面这个函数可以创建dispatch group:
dispatch_group_t
dispatch_group_create(void);
dispatch group就是一个简单的数据结构,这种数据结构彼此之间没什么区别,它不像派发队列,后者还有个用来区别身份的标识符。想把任务编组,有两种办法。第一种是用下面这个函数:
void
dispatch_group_async(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
它是普通dispatch_async函数的变体,比原来多一个参数,用于表示待执行的块所属的组。还有种办法能够指定任务所属的dispatch group,那就是使用下面这一对函数:
void
dispatch_group_enter(dispatch_group_t group);
void
dispatch_group_leave(dispatch_group_t group);
前者能够使分组里正要执行的任务数递增,而后者则使之递减。由此可知,调用了dispatch_group_enter以后,必须有与之对应的dispatch_group_leave才行。这与引用计数相似,要使用引用计数,就必须令保留操作与释放操作彼此对应,以防内存泄露。而在使用dispatch_group时,如果调用enter之后,没有相应的leave操作,那么这一组任务就永远执行不完。
下面这个函数可用于等待dispatch group执行完毕:
long
dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
此函数接受两个参数,一个是要等待的group,另一个是代表等待时间的timeout值。timeout参数表示函数在等待dispatch group执行完毕时,应该阻塞多久。如果执行dispatch group所需的时间小于timeout,则返回0,否则返回非0值。此函数也可以取常量DISPATCH_TIME_FOREVER,这表示函数会一直等着dispatch group执行完,而不会超时。
除了可以用上面那个函数等待dispatch group执行完毕之外,也可以换个办法,使用下列函数:
void
dispatch_group_notify(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
与wait函数略有不同的是:开发者可以向此函数传入块,等dispatch group执行完毕之后,块会在特定的线程上执行。假如当前线程不应阻塞,而开发者又想在那些任务全部完成时得到通知,那么此做法就很有必要了。比方说,在Mac OS X与iOS系统中,都不应阻塞主线程,因为所有UI绘制及事件处理都要在主线程上执行。
如果想令数组中的每个对象都执行某项任务,并且想等待所有任务执行完毕,那么就可以使用这个GCD特性来实现。代码如下:
dispatch_queue_t queue =
dispatch_queue_create(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for(id object in collection){
dispatch_group_async(dispatchGroup,
queue, ^{
[object performTask];
});
}
dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER);
//Continue processing after completing tasks
若当前线程不应阻塞,则可以用notify函数来取代wait:
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue, ^{
//Continue processing after completing tasks
});
notify回调时所选用的队列,完全应该根据具体情况来定。这里使用了主队列,这是种常见写法,也可以用自定义的串行队列或全局并发队列。
本例中,所有任务都派发到同一个队列之中。但实际上未必一定要这样做。也可以把某些任务放在优先级高的线程上执行,同时仍然把所有任务都归入同一个dispatch group,并在执行完毕时获得通知:
dispatch_queue_t lowPriorityQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t highPriorityQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for(id object in lowPriorityObjects){
dispatch_group_async(dispatchGroup,
lowPriorityQueue,
^{
[object performTask];
});
}
for(id object in highPriorityObjects){
dispatch_group_async(dispatchGroup,
highPriorityQueue,
^{
[object performTask];
});
}
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue,
^{
//Countinue processing after completing tasks
});
除了像上面这样把任务提交到并发队列之外,也可以把任务提交至各个串行队列中,并用dispatch group跟踪其执行状况。然而,如果所有任务都排在同一个串行队列里面,那么dispatch group就用处不大了。因为此时任务总要逐个执行,所以只需在提交完全部任务之后再提交一个块即可,这样做与通过notify函数等待dispatch group执行完毕然后再回调块是等效的:
dispatch_queue_t queue =
dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
for(id object in collections){
dispatch_async(queue, ^{
[object performTask];
});
}
dispatch_async(queue, ^{
//Continue processing after completing tasks
});
上面这段代码表明,开发者未必总是需要使用dispatch group。有时候采用单个队列搭配标准的异步派发,也可以实现同样效果。
为了执行队列中的块,GCD会在适当的时机自动创建新线程或复用旧线程。如果使用并发队列,那么其中有可能会有多个线程,这也就意味着多个块可以并发执行。在并发队列中,执行任务所用的并发线程数量,取决于各种因素,而GCD主要是根据系统资源状况来判断这些因素的。加入CPU有多个核心,并发队列中有大批任务等待执行,那么GCD就可能会给该队列配置多个线程。通过dispatch group所提供的这种简便方式,既可以并发执行一系列给定的任务,又能在全部任务结束时得到通知。由于GCD有并发队列机制,所以能够根据可用的系统资源状况来并发执行任务。而开发者则可用专注于业务逻辑代码,无须再为了处理并发任务而编写复杂的调度器。
在前面的例子中,我们遍历某个collection,并在其每个元素上执行任务,而这也可以用另外一个GCD函数来实现:
void
dispatch_apply(size_t iterations, dispatch_queue_t queue,
void (^block)(size_t));
此函数会将块反复执行一定的次数,每次传给块的参数值都会递增,从0开始,直至”iterations-1“。其用法如下:
dispatch_queue_t queue =
dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
dispatch_apply(10, queue, ^(size_t i) {
//Perform task
});
采用简单的for循环,从0递增至9,也能实现同样的效果:
for(int i=0;i<10;i++){
//Perform task
}
注意:dispatch_apply所用的队列可以是并发队列。如果采用并发队列,那么系统就可以根据资源状况来并行执行这些块了,这与使用dispatch group的那段代码一样。上面这个for循环要处理的collection若是数组,则可以用dispatch_apply改写成:
dispatch_queue_t queue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(array.count, queue, ^(size_t i) {
id object = array[i];
[object performTask];
});
这个例子再次表明:未必总要使用dispatch_group。然而,dispatch_apply会持续阻塞,直到所有任务都执行完毕未知。由此可见:假如把块派发给了当前队列(或者体系中高于当前队列的某个串行队列),将导致死锁。若想在后台执行任务,则应使用dispatch group。
** 要点:**
- 一系列任务可归入一个dispatch group之中。开发者可以在这组任务执行完毕时获得通知。
- 通过dispatch group,可以在并发式派发队列里同时执行多项任务。此时GCD会根据系统资源状况来调度这些并发执行的任务。
45.使用dispatch_once来执行只需执行一次的线程安全代码
单例模式(singleton)对Objective-C开发者来说并不陌生,常见的实现方式为:在类中编写名为sharedInstance的方法,该方法只会返回全类共用的单例实例,而不会在每次调用时都创建新的实例。假设有个类叫EOCClass,那么这个共享实例的方法一般都会这样写:
+(instancetype)sharedInstance{
static EOCClass *sharedInstance = nil;
@synchronized (self) {
if(!sharedInstance){
sharedInstance = [[self alloc]init];
}
}
return sharedInstance;
}
为保证线程安全,上述代码将创建单例实例的代码包裹在同步块里。
不过,GCD引入了一项特性,能使单例实现起来更为容易。所用的函数是:
void
dispatch_once(dispatch_once_t *predicate,
dispatch_block_t block);
此函数接受类型为dispatch_once_t的特殊参数,作者称其为“标记”(token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必定会执行,其仅执行一次。首次调用该函数时,必然会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。请注意,对于只需执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在static或global作用域里。
刚才实现单例模式所用的sharedInstance方法,可以用此函数来改写:
+(instancetype)sharedInstance{
static EOCClass *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc]init];
});
return sharedInstance;
}
使用dispatch_once可以简化代码并且彻底保证线程安全,开发者根本无须担心加锁或同步。所有问题都由GCD在底层处理。由于每次调用时都必须使用完全相同的标记,所以标记要声明称static。把该变量定义在static作用域里,可以保证编译器在每次执行sharedInstance方法时都会复用这个变量,而不会创建新变量。
此外,dispatch_once更高效。它没有使用重量级的同步机制,若是那样的话,每次运行代码钱都要获取锁,相反,此函数采用“原子访问”(atomic access)来查询标记,以判断其所对应的代码原来是否已经执行过。
要点:
- 经常需要编写“只需执行一次的线程安全代码”(thread-safe single-code execution)。通过GCD所提供的dispatch_once函数,很容易就能实现此功能。
- 标记应该声明在static或global作用域中,这样的话,在把只需执行一次的块传给dispatch_once函数时,传进去的标记也是相同的。
46.不要使用dispatch_get_current_queue
使用GCD时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时,更是如此。
dispatch_queue_t
dispatch_get_current_queue(void);
此函数返回当前正在执行代码的队列,不过用的时候要小心。从iOS系统6.0版本起,已经将其废弃了。
该函数有种典型的错误用法(antipattern,“反模式”),就是用它检测当前队列是不是某个特定的队列,试图以此来避免执行同步派发时可能遭遇的死锁问题。考虑下面这两个存取方法,其代码用队列来保证对实例变量的访问操作是同步的:
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
这种写法的问题在于,获取方法可能会死锁,假如调用获取方法的队列恰好是同步操作所针对的队列(本例中是_syncQueue),那么dispatch_sync就一直不会返回,直到块执行完毕为止。可是,应该执行块的那个目标队列却是当前队列,而当前队列的dispatch_sync又一直阻塞着,它在等待目标队列把这个块执行完,这样一来,块就永远没机会执行了。像someString这种方法,就是“不可重入的”。
看了dispatch_get_current_queue的文档后,你也许会觉得可以用它改写这个方法,令其变得“可重入”,只需检测当前队列是否为同步操作所针对的队列,如果是,就不派发了,直接执行块即可:
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_block_t accessorBlock = ^{
localSomeString = _someString;
};
if(dispatch_get_current_queue()==_syncQueue){
accessorBlock();
}else{
dispatch_sync(_syncQueue, accessorBlock);
}
return localSomeString;
}
这种做法可以处理一些简单情况。不过仍然有死锁的危险。为说明其原因,考虑下面这段代码,其中有两个串行派发队列:
dispatch_queue_t queueA =
dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB =
dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_sync(queueA, ^{
//Deadlock
});
});
});
这段代码执行到最内层的派发操作时,总会死锁,因为此操作是针对queueA队列的,所以必须等最外层的dispatch_sync执行完毕才行,而最外层的那个dispatch_sync又不可能执行完毕,因为它要等最内层的dispatch_sync执行完,于是就死锁了。现在按照刚才的办法,使用dispatch_get_current_queue来检测:
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{/*...*/};
if(dispatch_get_current_queue()==queueA){
block();
}else{
dispatch_sync(queueA, block);
}
});
});
然而这样做依然死锁,因为dispatch_get_current_queue返回的是当前队列,在本例中就是queueB。这样的话,针对queueA的同步派发操作依然会执行,于是和刚才一样,还是死锁了。
在这种情况下,正确的做法是:不要把存取方法做成可重入的,而是应该确保同步操作所用的队列绝不会访问属性,也就是绝不会调用someString方法。这种队列只应该用来同步属性。由于派发队列是一种极为轻量级的机制,所以,为了确保每项属性都有专用的同步队列,我们不妨创建多个队列。
使用队列时还要注意另外一个问题,而那个问题会在你意想不到的地方导致死锁。队列之间会形成一套层级体系,这意味着排在某条队列中的块,会在其上级队列(parent queue,也叫“父队列”)里执行。层级里地位较高的那个队列总是“全局并发队列”。下图描述了一套简单的队列体系:
排在队列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),此功能可以把任意数据以键值对的形式关联到队列里。最重要之处在于,假如根据指定的键获取不到关联数据,那么系统就会沿着层级体系向上查找,直至找到数据或到达根队列为止。看下面这个例子:
dispatch_queue_t queueA =
dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB =
dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
static int kQueueSpecific;
CFStringRef queueSpecificValue = CFSTR("queueA");
dispatch_queue_set_specific(queueA,
&kQueueSpecific,
(void*)queueSpecificValue,
(dispatch_function_t)CFRelease);
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{NSLog(@"No deadlock!");};
CFStringRef retrievedValue =
dispatch_get_specific(&kQueueSpecific);
if(retrievedValue){
block();
}else{
dispatch_sync(queueA, block);
}
});
本例创建了两个队列。代码中将队列B的目标队列设为队列A,而队列A的目标队列仍然是默认优先级的全局并发队列。然后使用下列函数,在队列A上设置“队列特定值”:
void
dispatch_queue_set_specific(dispatch_queue_t queue,
const void *key,
void *context,
dispatch_function_t destructor);
此函数的首个参数表示待设置数据的队列,其后两个参数是键与值。键与值都是不透明的void指针。对于键来说,有个问题一定要注意:函数是按指针值来比较键的,而不是按照其内容。所以,“队列特定数据”更像是“关联引用”。值(在函数中原型里叫context)也是不透明的void指针,于是可以在其中存放任意数据。然而,必须管理该对象的内存。这使得在ARC环境下很难使用Objective-C对象作为值。范例代码使用CoreFoundation字符串作为值,因为ARC并不会自动管理CoreFoundation对象的内存。所以说,这种对象非常适合充当“队列特定数据”,它们可以根据需要与相关的Objective-C Foundation类无缝衔接。
函数最后一个参数是“析构函数”,对于给定的键来说,当队列所占内存为系统所回收,或者有新的值与键相关联时,原有的值对象就会移除,而析构函数也会与于此时执行。dispatch_function_t类的定义如下:
typedef void (*dispatch_function_t)(void *);
由此可知,析构函数只能带有一个指针参数且返回值必须为void。范例代码采用CFRelease做析构函数,此函数符合要求,不过也可以采用开发者自定义的函数,在其中调用CFRelease以清理旧值,并完成其他必要的清理工作。
于是,“队列特定数据”所提供的这套简单易用的机制,就避免了使用dispatch_get_current_queue时经常遭遇的一个陷阱。此外,调试程序时也许会经常用到dispatch_get_current_queue。在此情况下,可以放心使用这个已经废弃的方法,只是别把它编译到发行版的程序里就行。
要点:
- dispatch_get_current_queue函数的行为常常与开发者所预期的不同。此函数已经废弃,只应做调试之用。
- 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
- dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用“队列特定数据”来解决。
转载请注明出处:第六章 block与GCD(下)
参考:《Effective Objective-C 2.0》