第六章 block与GCD(下)

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》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容