本文名为《GCD 实现同步锁》,内容不止于锁。文章试图通过 GCD 同步锁的问题,尽量往外延伸扩展,以讲解更多 GCD 同步机制的内容。
引语:线程安全问题
如果一段代码所在的进程中有多个线程在同时运行,那么这些线程就有可能会同时运行这段代码。假如多个线程每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。
由于可读写的全局变量及静态变量可以在不同线程修改,所以这两者也通常是引起线程安全问题的所在。在 Objective-C 中还包括属性和实例变量(实际上属性和实例变量本质上也可以看做类内的全局变量)。
Objective-C 同步锁
在 Objective-C 中,如果有多个线程执行同一份代码,那么有可能会出现线程安全问题。这种情况下,就需要一个同步机制来解决 —— 锁(lock)。在 Objective-C 中,有如下几种可用的锁:
- NSLock 实现锁 NSLock是Cocoa提供给我们最基本的锁对象,这也是我们经常所使用的锁之一。 .
- 关键字构建的锁 synchronized指令实现锁的优点就是我们不需要在代码中显式的创建锁对象,便可以实现锁的机制,但作为一种预防措施,@synchronized块会隐式的添加一个异常处理例程来保护代码,该处理例程会在异常抛出的时候自动的释放互斥锁。所以如果不想让隐式的异常处理例程带来额外的开销,你可以考虑使用锁对象。 .
- 使用 C 语言的 pthread_mutex_t 实现的锁 .
- 使用 GCD 来实现的“锁” 在GCD中也已经提供了一种信号机制,使用它我们也可以来构建一把“锁”。从本质意义上讲,信号量与锁是有区别,具体差异参加信号量与互斥锁之间的区别。 .
- NSRecursiveLock 递归锁 递归锁会跟踪它被多少次lock。每次成功的lock都必须平衡调用unlock操作。只有所有的锁住和解锁操作都平衡的时候,锁才真正被释放给其他线程获得。 .
- NSConditionLock 条件锁 当我们在使用多线程的时候,有时一把只会lock和unlock的锁未必就能完全满足我们的使用。因为普通的锁只能关心锁与不锁,而不在乎用什么钥匙才能开锁,而我们在处理资源共享的时候,多数情况是只有满足一定条件的情况下才能打开这把锁。 .
- NSDistributedLock 分布式锁 从它的类名就知道这是一个分布式的 Lock。NSDistributedLock 的实现是通过文件系统的,所以使用它才可以有效的实现不同进程之间的互斥,但 NSDistributedLock 并非继承于 NSLock,它没有 lock 方法,它只实现了 tryLock,unlock,breakLock,所以如果需要 lock 的话,你就必须自己实现一个 tryLock 的轮询。 补充:简单查了下资料,这个锁主要用于 OS X 的开发。而iOS 较少用到多进程,所以很少在 iOS 上见到过。由于精力有限,查询不够充分,如有错误请指出,谢谢!
常见锁的弊病
在 GCD 之前,解决线程安全通常有两种锁。一是采用内置的同步锁
- (void)synchronizedMethod { @synchronized(self) { // safe code }}
这种写法会根据给定对象,自动创建一个锁,并等待块中的代码执行完毕,才释放锁。这段代码本身没什么问题,但是因为 @synchronized(self) 锁的对象是 self,造成共用此锁的同步块阻塞,降低效率。
// someString 属性 // 当 someString 开始读时,对其的写入阻塞,这是合理的; - (NSString *)someString { @synchronized(self) { return _someString; } } - (NSString *)setSomeString:(NSString *)someString { @synchronized(self) { _someString = someString; } } //otherString 属性 // 当线程在对 someString 进行读写时,与之无关的 otherString 也会受到干扰阻塞,这是不合理的; - (NSString *)otherString { @synchronized(self) { return _otherString; } } - (NSString *)setOtherString:(NSString *)otherString { @synchronized(self) { _otherString = otherString; } }
此例子只用于说明 @synchronized(self) 的问题。聪明的同学应该还会想到直接使用 atomic 来修饰属性,进行同步操作更简单直接。
另一种方法是使用 NSLock 对象
_lock = [[NSLock alloc] init]; - (void)synchronizedMethod { [_lock lock]; //safe code... [_lock unlock]; }
然而 NSLock 有可能在不经意间就造成了死锁
//主线程中 NSLock *theLock = [[NSLock alloc] init]; TestObject *aObject = [[TestObject alloc] init]; //线程1 //线程1 在递归的block内,可能会进行多次的lock,而最后只有一次unlock dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ static void(^TestMethod)(int); TestMethod = ^(int value) { [theLock lock]; if (value > 0) { [aObject method1]; sleep(5); TestMethod(value-1); } [theLock unlock]; }; TestMethod(5); }); //线程2 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ sleep(1); [theLock lock]; [aObject method2]; [theLock unlock]; });
这段代码就是一种典型的死锁情况,可以用递归锁 NSRecursiveLock 来避免这种情况。使用 NSRecursiveLock 类定义的锁会跟踪它被多少次 lock,每次成功的 lock 都必须平衡调用 unlock 操作。只有所有的上锁和解锁操作都平衡的时候,锁才真正被释放给其他线程获得。
用 GCD 实现同步机制
了解GCD 基础
在讲解 GCD 同步机制前,先讲点 GCD 的基础知识。GCD 是异步任务的技术之一,开发者可以用它将自定义的任务(task)追加到适当的派发队列(dispatch queue),就能生成必要的线程并执行任务。
在 GCD 中有三种队列:主队列(main queue)、全局队列(global queue)、用户队列(user-created queue)。全局队列是并发队列,即队列中的任务(task)执行顺序和进入队列的顺序无关;主队列和用户队列是串行队列,队列中的任务按FIFO(first input first output,先进先出)的顺序执行。
GCD 有两种派发方式:同步派发和异步派发。千万注意:这里的同步和异步指的是 “任务派发方式”,而非任务的执行方式。
看个例子:
// 这小段代码有问题,出现了线程死锁,知道为什么吗? // 提示:下面的代码在主线程(main_thread)中执行 - (void)viewDidLoad { dispatch_sync(dispatch_get_main_queue(), block()); }
要理解这题,首先需要了解 dispatch_sync 和 dispatch_async 的工作流程。
dispatch_sync(queue, block) 做了两件事情
- 将 block 添加到 queue 队列;
- 阻塞调用线程,等待 block() 执行结束,回到调用线程。
dispatch_async(queue, block) 也做了两件事情:
- 将 block 添加到 queue 队列;
- 直接回到调用线程(不阻塞调用线程)。
这里也能看到同步派发和异步派发的区别,就是看是否阻塞调用线程。
回到题目,当在 main_thread 中调用 dispatch_sync 时:
- main_thread 被阻塞,无法继续执行;
- 同步派发 sync 导致 block() 需要在 main_thread 中执行结束才会返回;
- 而此时 main_thread 被阻塞,两者互相等待,线程死锁;
所以记住这个教训:不要将 block 同步派发到调用 GCD 所在线程的关联队列中。例如,如果你在主线程(main thread)中调用 GCD,那么在 GCD 内就不要使用同步派发(dispatch_sync)将 block 派发到主线程(main thread)关联的主队列(main queue)中。
除此之外,还有个容易让人忽略而导致死锁的东西:队列的层级体系。
// 因最外层 queueA 已经同步派发,导致内层 queueA 同步派发时会死锁 // 这个例子同时也告诫我们不要相信和使用 dispatch_get_current_queue dispatch_sync (queueA, ^{ dispatch_block_t block = ^{ if (dispatch_get_current_queue() == queueA) { block(); } else { dispatch_sync(queueA, block); } } })
队列层级用图画出来通常长这样,最顶层是全局并发队列(此图和上面例子无关)
GCD 同步锁
有了前面的基础,就可以瞧瞧在 GCD 中更好的同步锁的实现方式。在 GCD 队列中,有个简单直接的方法可以代替同步锁或锁对象,将读写操作都安排在一个串行同步队列里,即可保证数据同步,如下:
_syncQueue = dispatch_queue_create("com.effectiveObjectiveC.syncQueue", NULL); - (NSString *)someString { __weak NSString *localSomeString; dispatch_sync(_syncQueue, ^{ localSomeString = _someString; }); return localSomeString; } - (void)setSomeString:(NSString *)someString { dispatch_sync(_syncQueue, ^{ _someString = someString; }); }
使用串行同步队列,将读写操作全部放在序列化的队列里执行,所有指针对属性的操作即可同步。加锁和解锁的全部转移给 GCD 处理,而 GCD 在较深的底层实现,可以进行许多的优化。
然而设置方法不一定非得是同步的,设置实例变量的 block 没有返回值,所以可以将此方法改成异步:
- (void)setSomeString:(NSString *)someString { dispatch_async(_syncQueue, ^{ _someString = someString; }); }
这次只是把 dispatch_sync 改成 dispatch_async,从调用者来看提升了执行速度。但正是由于执行异步派发
dispatch_async 时会拷贝 block,当拷贝 block 的时间大于执行 block 的时间时,dispatch_async 的速度会比 dispatch_sync 速度更慢。所以实际情况应根据 block 所执行任务的繁重程度来决定使用 dispatch_async 还是 dispatch_sync。多个获取方法可以并发执行,获取方法与设置方法不能并发执行。据此可以使用并发队列和 GCD 的 barrier 来写出更快的代码。
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - (NSString *)someString { __weak NSString *localSomeString; dispatch_sync(_syncQueue, ^{ localSomeString = _someString; }); return localSomeString; } - (void)setSomeString:(NSString *)someString { // barrier dispatch_barrier_async(_syncQueue, ^{ _someString = someString; }); }
在使用上面的方式创建的同步锁之后,会发现执行速度和效率都更高。难道并发队列厉害吗?其实原因不只是并发队列,还有 barrier block 的功劳,那么什么是 barrier block 呢?
函数 dispatch_barrier_sync 和 dispatch_barrier_async 可以让队列中派发的 block 变成 barrier(栅栏) 使用,这种 block 称为 barrier block。队列中的 barrier block 必须等当前并发队列中的 block 都执行结束才开始执行,时序图如下:
其他同步机制
GCD 的同步方式还有组派发(dispatch group)和信号量(dispatch semaphore)
组派发(dispatch group)
/** * 阻塞当前线程,执行group内任务,阻塞时间为timeout * * @param group 等待的group * @param timeout 等待的时间,即函数在等待dispatch group内的任务执行完毕时,应阻塞多久 * * @return 如果执行dispatch group所需时间小于timeout,则返回0,否则返回非0值; timeout可以取常量DISPATCH_TIME_FOREVER,表示永远不会超时 */ long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
/** * 如果group内的任务全部执行完毕后,将block提交到queue上执行 * * @param group 等待的group * @param queue 即将提交的队列 * @param block 即将提交的任务 */ void dispatch_group_notify(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);
信号量(dispatch semaphore)
信号量在 linux/unix 开发中十分常见,其概念相当于经典的“生产者-消费者”模型。当信号个数为 0 时,则线程阻塞,等待发送新信号;一旦信号个数大于 0 时,就开始处理任务。
-
dispatch_semaphore_create:创建一个semaphore
-
dispatch_semaphore_signal:发送一个信号,信号个数加1
-
dispatch_semaphore_wait:等待信号
除了《Effective Objective-C 2.0》之外,本文还参考了:
[0]
[1] [2] [4] [5]