编写高质量iOS与OSX代码的52个有效方法-第六章:大中枢派发GCD

41、多用派发队列,少用同步锁

OC中,如果有多个线程执行同一份代码,有时可能会出问题。通常情况下,使用锁来实现某种同步机制。

GCD之前有两种方法

  • 1、内置的同步块(synchronization block)
- (void)synchronizeMethod {
    @synchronized(self) {
        //
    }
}

根据给定对象,自动创建一个锁,并等待块中农代码执行完毕。执行到折断代码结尾处,锁就释放了。

优点:同步行为针对self,保证每个对象实例都能不受干扰地运行方法synchronizeMethod

缺点:滥用会降低代码效率,共用同一个锁的那些同步块,都必须按顺序执行。若是self对象上频繁加锁,程序可能要等另一端无关的代码执行完毕,才能执行当前代码。

  • 2、NSLock/NSRecursiveLock
_lock = [[NSLock alloc] init];

- (void)synchronizeMethod {    
    [_lock lock];
    //
    [_lock unlock];
}

也可以使用NSRecursiveLock,线程能够多次持有该锁,不会出现死锁(deadlock)现象。

两种方法都很好,也有缺陷。比方说,在极端情况下,同步块会导致死锁,另外效率也不见得很高,而如果直接使用锁对象的话,遇到死锁,就很麻烦。

GCD实现

  • 1、串行同步队列(serial synchronization queue)

将读取操作及写入操作都安排在同一个队列里,保证数据同步。

_syncQueue = dispatch_queue_create("com.effectiveOC.syncQueue", NULL);

- (NSString *)someString {
    __block NSString *localString;
    //为使块代码能够设置局部变量,使用__block语法。
    dispatch_sync(_syncQueue, ^{
        localString = self.someString;
    });
    return localString;
}

- (void)setSomeString:(NSString *)someString {
    dispatch_sync(_syncQueue, ^{
        self.someString = someString;
    });
}

把设置操作和获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作都同步了。

全部加锁任务都在GCD中处理。

继续优化,设置方法并不一定非得同步,设置实例变量所用的块,并不需要向设置方法返回什么值。

- (void)setSomeString:(NSString *)someString {
    dispatch_async(_syncQueue, ^{
        self.someString = someString;
    });
}

同步派发改成异步派发,可以提升设置方法的执行速度,而读取操作与写入操作依然会按顺序执行。

执行异步派发时,需要拷贝块。如果拷贝所用的时间明显超过执行块所用的时间,则这种方法比原来慢。但是,若是派发给队列的块要执行更为繁重的任务,那么仍然可以考虑这种备选方案。

并发队列(concurrent queue)

多个获取方法可以并发执行,但获取方法与设置方法之间不能并发执行。

栅栏(barrier),在队列中,栅栏必须单独执行,不能与其它块并行。下面方法可以像对立中派发块,将其作为栅栏使用。

dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);

dispatch_barrier_sync(dispatch_queue_t queue,
        DISPATCH_NOESCAPE dispatch_block_t block);

只对并发队列有意义,因为串联队列中的块总是安顺序逐个执行的,并发队列如果发现接下来要处理的是栅栏块,那么就一直要等当前的所有并发块都执行完毕,才会单独执行这个栅栏块。待栅栏块执行过后,再按正常方式继续向下处理。

例子中,可以用栅栏块来实现属性的设置方法,在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写入操作就必须单独执行了。

_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

- (NSString *)someString {
    __block NSString *localString;
    dispatch_sync(_syncQueue, ^{
        localString = self.someString;
    });
    return localString;
}

- (void)setSomeString:(NSString *)someString {
    dispatch_barrier_async(_syncQueue, ^{
        self.someString = someString;
    });
}

设置函数也可以改用同步的栅栏块(synchronous barrier)来实现。测试性能之后,选择最适合当前场景的方案。


  • 派发队列可用来表述同步语义(synchronization semantic),这种做法比使用@synchronized或NSLock对象更简单。
  • 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
  • 使用同步队列栅栏,可以令同步队列更加高效。

42、多用GCD,少用performSelector系列方法

OC是一门非常动态的语言,NSObject定义了几个方法,开发者可以随意调用任意方法。

performSelector系列方法

- (id)performSelector:(SEL)aSelector;

如果选择子是在运行期决定的,这种方式就很强大。

SEL selector;
if (index == 2) {
    selector = @selector(newObject);
} else if (index == 1) {
    selector = @selector(copy);
} else {
    selector = @selector(someProperty);
}
id ret = [objct performSelector:selector];

有两个问题:

  • 1、ARC下可能会有内存泄露问题。编译器不知道将要调用的选择子是什么,不了解其方法签名及返回值,设置不知道是否有返回值。所以,ARC选择不添加释放操作,就可能导致内存泄露,因为方法可能在返回对象时已经将其保留了。
  • 2、返回值只能是void或对象类型。performSelector返回的类型是id,指向任意的OC对象指针。如果想返回一些整数或浮点数等类型的值,就需要执行一些复杂的转换,而这种转换容易出错。若返回值是C语言结构体,则不可使用performSelector方法。

其他可传参数版本:

- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;

传参类型是id,另外选择子最多只能接受两个参数。

可以延后执行选择子,或将其放在另一个线程执行。

- (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 API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;

这些方法太过局限。如具备延后执行的方法无法处理两个参数的选择子。能够指定执行线程的方法,也不能传多个参数。

GCD实现相同功能

performSelector系列方法所提供的线程功能,都可以通过在大中枢派发机制中使用块来实现,延后执行可使用dispatch_after来实现,另一个线程执行任务则可通过dispatch_sync及dispatch_async来实现。

例如:

dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0*NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
    [self doSomethingElse];
});    
dispatch_async(dispatch_get_main_queue(), ^{
    [self doSomethingElse];
});

  • performSelector系列方法在内存管理方面容易有疏失,他无法确定将要执行的选择子具体是什么,因而ARC编译器无法插入适当的内存管理方法。
  • performSelector系列方法所能处理的选择子泰国局限,选择子的返回值类型及发送给方法的参数个数都收到限制。
  • 如果想把任务放到另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。

43、掌握GCD及操作队列的使用时机

很少有其他技术能与GCD的同步机制相媲美,对于那些只需执行一次的代码来说,也是如此,使用GCD的dispatch_once最为方便。然而在执行后台任务时,GCD不一定是最佳方式。

还有一种技术叫做NSOperationQueue,操作队列(operation queue)。它虽然与GCD不同,却与之相关,可以把操作以及NSOperation子类的形式放在队列中,而这些操作也能够并发执行。

区别:GCD是纯C的API,而操作队列则是OC的对象。GCD中,任务用块来表示,而块是个轻量级数据结构,与之相反,操作时更为重量级的OC对象。

优点:

  • 1、取消某个操作。使用操作队列,取消操作很容易。在运行任务之前,在NSOperation对象上调用cancel方法,设置对象内的标志位,表明此任务不需要执行。已经启动的任务无法取消。若是不通过操作队列,而是把块安排到GCD队列,就无法取消了。
  • 2、指定操作间的依赖关系。一个操作可以依赖其他多个操作。能够指定操作之间的依赖关系,使特定的操作必须在另一个操作顺利执行完毕后方可执行。
  • 3、通过键值观察机制监控NSOperation对象的属性。NSOperation许多属性都可以通过KVO来监听,如通过isCancelled属性判断任务是否已经取消,通过isFinished判断是否已经完成。如果想在某个任务变更状态时收到通知,或想要比用GCD更精细的方式控制所要执行的任务,KVO会很有用。
  • 4、指定操作优先级。操作优先级表示此操作与队列中其他操作之间的优先关系。优先级高限制性,优先级低后执行。GCD没有直接实现此功能的办法。GCD有优先级,不过是针对整个队列来说,而不是针对每个块来说的。 NSOperation对象也有线程优先级,决定运行此操作的线程处于何种优先级上。GCD可以实现此功能,采用操作队列更简单,只需设置一个属性。
  • 5、重用NSOperation对象。

操作队列提供了多种执行任务的方式,而且都是写好的,直接就能使用。不需要编写复杂的调度器,也不用自己实现取消操作或者指定操作优先级的功能。

NSNotificationCenter选用操作队列而非派发队列。可以通过其中的方法来注册监听器,一般发生相关事件时得到通知,这个方法接受的参数是块,不是选择子。

- (id <NSObject>)addObserverForName:(nullable NSNotificationName)name
                             object:(nullable id)obj
                              queue:(nullable NSOperationQueue *)queue
                         usingBlock:(void (^)(NSNotification *note))block;

尽可能使用高层API,只有在确有必要时才求助于底层。不过某些功能缺失可以使用高层OC的方法来做,但并不等于它就一定比底层实现方案好,具体看性能。


  • 在解决多线程与任务管理问题时,派发队列并非唯一方案。
  • 操作队列提供了一套高层OC API,能实现纯GCD所具备的绝大部分功能,而且还能完成一些更为复杂的操作,那些操作若改用GCD实现,需要另外编写代码。

44、通过Dispatch Group机制,根据系统资源状况来执行任务

dispatch group是GCD的一项特性,能够把任务分组。这个功能有很多用途,最重要、最值得注意的用法就是把将要执行的多个任务合为一个组,于是调用者就可以知道这些任务何时才能执行完毕。

创建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操作,这一组任务就永远执行不完。多调用leave会崩溃。

dispatch_group_t group = dispatch_group_create();

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
//添加任务1
dispatch_group_enter(group);
    
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(3*NSEC_PER_SEC)), queue, ^{
    NSLog(@"11111");
    dispatch_group_leave(group);
});

//添加任务2
dispatch_group_enter(group);
    
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(4*NSEC_PER_SEC)), queue, ^{
    NSLog(@"2222");
    dispatch_group_leave(group);
});
    
// 添加任务3
dispatch_group_enter(group);
NSLog(@"third");
dispatch_group_leave(group);

// 以不阻塞当前线程方式执行group
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
});

NSLog(@"方法结束");

打印结果:

third
方法结束
11111
2222
执行完所有任务:<NSThread: 0x60800006fb40>{number = 1, name = main}

dispatch group执行函数

  • 1、dispatch_group_wait --阻塞所在线程
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执行完毕,不会超时。

long num = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, (3*NSEC_PER_SEC)));
NSLog(@"%ld",num);
  • 2、dispatch_group_notify --不阻塞所在线程
void
dispatch_group_notify(dispatch_group_t group,
    dispatch_queue_t queue,
    dispatch_block_t block);

这个方法可以向此函数传入块,等待dispatch group执行完毕之后,块会在特定的线程上执行。如果当前线程不应阻塞,又想在任务全部完成时得到通知,那么此做法就很有必要。第二个参数queue即是想要回调的线程。

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
});

示例

创建两个级别线程队列,分别创建任务添加到group,最后并发执行。

dispatch_group_t group = dispatch_group_create();
    
// 创建优先级低的线程队列
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_enter(group);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), lowPriorityQueue, ^{
    NSLog(@">>>任务1-low<<<");
    dispatch_group_leave(group);
});
    
dispatch_group_enter(group);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), lowPriorityQueue, ^{
    NSLog(@">>>任务2-low<<<");
    dispatch_group_leave(group);
});
    
dispatch_group_enter(group);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), highPriorityQueue, ^{
    NSLog(@">>>任务3-high<<<");
    dispatch_group_leave(group);
});
    
dispatch_group_enter(group);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), highPriorityQueue, ^{
    NSLog(@">>>任务4-high<<<");
    dispatch_group_leave(group);
});

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
});
    
NSLog(@"方法结束");

最后中间打印的顺序是不固定的,原因是,虽然设置了线程的优先级别,但是这个顺序是由系统决定的,并不保证首先执行。同时,这里的任务提交到并发队列,优先级问题效果不明显。

除了将任务提交到并发队列之外,还可以把任务提交到串行队列中。但是这种情况下,所有任务都排在同一个串行队列里,dispatch group用处就不大了。因为此时任务总要逐个执行,秩序在提交完玩不任务之后再提交一个块即可。所以未必总需要使用dispatch group,有时采用单个队列搭配标准的异步派发,也可以实现相同效果。

GCD有并发队列机制,所以能够根据可用的系统资源状况来并发执行任务。通过dispatch group,既可以并发执行一系列给定的任务,又能在全部任务结束时得到通知。


  • 一系列任务可归入一个dispatch group中,开发者可以再这组任务执行完毕时获得通知。
  • 通过dispatch group,可以在并发式派发队列里同时执行多项任务。此时GCD会根据系统资源装快来调度这些并发任务。

45、使用dispatch_once执行只需运行一次的线程安全代码

dispatch_once()函数接受类型为dispatch_once_t的特殊参数(标记token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必须执行,切仅执行一次。首次调用该函数时,必要会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。对于只执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在static或global作用域里。

#import "ZYDUserManager.h"

@implementation ZYDUserManager

+ (id)sharedInstance {
    static ZYDUserManager *sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[ZYDUserManager alloc] init];
    });
    return sharedInstance;
}
@end

使用dispatch_once可以简化代码并且彻底保证线程安全,无需但系加锁或同步。所有问题有GCD的底层实现。

另外dispatch_once更高效,它没有使用重量级的同步机制,若是那样做的话,每次运行代码前都要获取锁,想法,此函数采用原子访问来查询标记,以判断其所对应的代码原来是否已经执行过。


  • 经常需要编写只需要执行一次的安全代码(thread-safe single-code execution)。通过GCD提供的dispatch_once函数,很容易实现此功能。
  • 标记应该声明在staticglobal作用域中,这样,在把只需要执行一次的块传递给dispatch_once函数时,传进去的标记也是相同的。

46、不要使用dispatch_get_current_queue

dispatch_queue_t dispatch_get_current_queue(void);此方法已经被弃用。


  • dispatch_get_current_queue函数的行为常常与开发者所预期的不同,此函数已经废弃,只应做调试之用。
  • 由于派发队列是按成绩来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
  • dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用队列特定数据来解决。

推荐阅读更多精彩内容