dispatch_semaphore

除了dispatch_sync、dispatch_queue系列接口,日常开发中,GCD中另一被使用得最多的系列接口恐怕是dispatch_semaphore了。

信号量(semaphore)是PV操作的载体,基本操作有四种:初始化、等信号、给信号、清理。对应的dispatch_semaphore系列接口也就寥寥3个:

dispatch_semaphore_t dispatch_semaphore_create(long value);
long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
long dispatch_semaphore_signal(dispatch_semaphore_t dsema);

本文旨在对这几个接口进行简单介绍,描述我所遇到的坑,最后,罗列常见应用。

P.S: ARC下,semaphore的清理工作(释放)由GCD在底层自动完成。

dispatch_semaphore_create

dispatch_semaphore_create(value)返回一个semaphore(dispatch_semaphore_t类型),参数value用于初始化semaphore.count;需要注意的是,value的有效值不可小于0(否则返回NULL),count>=0是信号量的基本设定;另外,从接口上看,它是long类型,可以定义一个足够大的值。

dispatch_semaphore_wait

dispatch_semaphore_wait()对应信号量的P操作。众所周知,P操作尝试将semaphore.count减1,如果semaphore.count大于0,则意味着P操作执行成功,可顺利执行线程之后的逻辑;否则,P操作会阻塞当前线程,直到semaphore.count大于0。

站在semaphore.count的角度来看,dispatch_semaphore_wait()结果有两种:

  • P操作成功,即对semaphore.count成功减1,返回值为0
  • P操作失败,即对semaphore.count没有任何影响,返回值为非0

什么情况会导致P操作失败呢?GCD提供的dispatch_semaphore_wait()接口在P操作基础上新增了timeout,即便当前线程因为P操作而阻塞,timeout后也会被唤醒,这种情况下dispatch_semaphore_wait()返回值为非0值,表示P操作失败。

P.S: P对应荷兰语的passeren(通过)。

dispatch_semaphore_signal

dispatch_semaphore_signal()对应信号量的V操作。V操作会将semaphore.count加1,如果存在某个线程因为P操作而阻塞,则该操作会释放其中一个线程。

dispatch_semaphore_wait()不同,dispatch_semaphore_signal()不存在失败的情况,但从semaphore.count的角度来看,也有两种情况:

  • 不存在因为P操作而阻塞的线程,直接返回0,semaphore.count增加了1
  • 存在因为P操作而阻塞的线程,唤醒该线程,然后返回非0值

如何理解第二种情况?为啥返回非0值呢?可以这么理解:该情况同样对semaphore.count加1,但是唤醒了因为P操作阻塞的线程,对于后者而言,是谓「P操作成功,将semaphore.count减1」,一增一减,从semaphore.count结果来看,并没有发生变化,所以用非0返回值表述更合适(在UNIX世界里,0一般表示可喜的结果)。

P.S: V对应荷兰语的vrijgeven(发布)。

dispatch_semaphore遇坑记

曾经使用dispatch_semaphore遇到一个crash,为了叙述方便,将逻辑简化如下:

@implementation ViewController {
dispatch_semaphore_t _semaphore;
}
- (void)viewDidLoad {
[super viewDidLoad];
_semaphore = dispatch_semaphore_create(3);
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
}
@end

我的操作逻辑是:创建一个ViewController实例,push到navigationController,然后pop,接着便触发了crash,crash调用栈的top操作是_dispatch_semaphore_dispose,看到如下有价值的提示信息:

BUG IN CLIENT OF LIBDISPATCH: Semaphore object deallocated while in use

上述代码在逻辑非常简单,viewDidLoad里构建了一个初始值为3的semaphore,之后的P操作使得semaphore.count减1,似乎没啥毛病。

如何理解这个crash呢?一段懵逼时间之后,查处各种资料,最终在搞清楚了原因。

简单来说,当semaphore在被释放时,其值小于原来的初始值,则系统认为该资源仍然处于「in use」状态,对其进行dispose时就会报错…

如何解决这个问题呢?我能想到的有两种解决方案。

方案一

使用dispatch_semaphore_create()创建semaphore时,传入0参数,然后使用dispatch_semaphore_signal将semaphore.count加到想要的值,即:

_semaphore = dispatch_semaphore_create(0);
for (int i = 0; i < 3; ++i) {
dispatch_semaphore_signal(_semaphore);
}

方案二

- (void)dealloc {
while (dispatch_semaphore_signal(_semaphore) != 0) {
// do nothing
}
for (int i = 1; i < 3; ++i) {
dispatch_semaphore_signal(_semaphore);
}
}

如何理解方案二呢?while语句使得所有因为_semaphore操作而阻塞的线程都被释放掉了;到了for语句这里,semaphore.count为1,接着for语句将semaphore.count增加到3。ok,此后底层dispose semaphore时就不会有问题了。

针对这个case,哪种方案更好呢?我认为是第一种,更优雅,且没啥坑。事实上,第二种方案存在问题的,倘若在执行for语句时,其他线程对_semaphore上执行了P操作,那么最终仍然会导致crash;当然,通过完善dealloc的逻辑可以规避该问题,但太丑陋了,远不如第一种方案痛快。

关于这一点,Apple有一段important说明

Calls to dispatch_semaphore_signal must be balanced with calls to dispatch_semaphore_wait. Attempting to dispose of a semaphore with a count lower than value causes an EXC_BAD_INSTRUCTION exception.

如何理解这段话呢?我的理解,问题的症结不在于dispatch_semaphore_create()的初始值,而在于PV操作的平衡;用人话说:针对某个semaphore,用户得确保dispatch_semaphore_signal()次数大于等于dispatch_semaphore_wait();为啥要求大于等于?dispatch_semaphore_wait()因为timeout参数的存在,并不一定会成功执行P操作。另外,可以得出一个结论:dispatch_semaphore_signal()是无害的!

使用dispatch_semaphore实现互斥锁

如下简短代码基于dispatch_semaphore实现了互斥锁:

@interface Lock : NSObject
- (void)lock;
- (void)unlock;
@end
@implementation Lock {
dispatch_semaphore_t _semaphore;
}
- (instancetype)init {
if (self = [super init]) {
_semaphore = dispatch_semaphore_create(1);
}
return self;
}
- (void)lock {
dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
}
- (void)unlock {
dispatch_semaphore_signal(_semaphore);
}
@end

P.S: 有空使用这里的方法比较一下上述Lock和NSLock的性能。

P.P.S: 这种基于dispatch_semaphore的自定义Lock相较于NSLock有一个优点,即lock和unlock操作可以分别在不同的线程进行,而后者要求对NSLock的所有行为都得在同一个线程进行。

使用dispatch_semaphore实现读写锁

所谓读写锁,简单来说,对于同一敏感资源,写操作必须互斥,读操作支持并发;更具体的概念这里就不再赘述了,详见维基百科。就功能而言,使用dispatch_barrier可以轻易实现。如果使用dispatch_semaphore,又该如何实现呢?

使用信号量实现读写锁的典型资源配比是:两个信号量+一个状态变量。

为了简化代码,在上述基于信号量实现的Lock基础上实现读写锁,如下:

@interface ReadWriteLock : NSObject
- (void)lockRead;
- (void)unlockRead;
- (void)lockWrite;
- (void)unlockWrite;
@end
@implementation ReadWriteLock {
Lock *_readLock;
Lock *_writeLock;
NSInteger _readCount;
}
- (instancetype)init {
if (self = [super init]) {
_readLock = [Lock new];
_writeLock = [Lock new];
_readCount = 0;
}
return self;
}
- (void)lockRead {
[_readLock lock];
_readCount++;
if (_readCount == 1) {
[_writeLock lock];
}
[_readLock unlock];
}
- (void)unlockRead {
[_readLock lock];
_readCount--;
if (_readCount == 0) {
[_writeLock unlock];
}
[_readLock unlock];
}
- (void)lockWrite {
[_writeLock lock];
}
- (void)unlockWrite {
[_writeLock unlock];
}
@end

就功能而言,上述实现逻辑能满足基本需求,但这段代码是有缺陷的。在大多数需要读写锁的需求中,读操作的频次要远高于写操作,这种场景下,可能会造成的问题是:ReadWriteLock#lockWrite长期处于block状态,俗称「写饥饿」。

问题的关键在于ReadWriteLock#lockRead无法感知writeLock的状态,除了在if (_readCount == 1)语句中锁住writeLock,其他情况下读操作都无视writeLock的存在,因此,解决问题的关键在于提升写操作的地位,这可以通过添加一个优先级锁来解决,如下:

@implementation ReadWriteLock {
Lock *_readLock;
Lock *_writeLock;
Lock *_priorityLock;
NSInteger _readCount;
}
- (instancetype)init {
if (self = [super init]) {
_readLock = [Lock new];
_writeLock = [Lock new];
_priorityLock= [Lock new];
_readCount = 0;
}
return self;
}
- (void)lockRead {
[_priorityLock lock];
[_readLock lock];
_readCount++;
if (_readCount == 1) {
[_writeLock lock];
}
[_readLock unlock];
[_priorityLock unlock];
}
- (void)unlockRead {
[_readLock lock];
_readCount--;
if (_readCount == 0) {
[_writeLock unlock];
}
[_readLock unlock];
}
- (void)lockWrite {
[_priorityLock lock];
[_writeLock lock];
}
- (void)unlockWrite {
[_writeLock unlock];
[_priorityLock unlock];
}
@end

新增的priorityLock起到什么作用呢?它用于记录writeLock的状态,即只要有写操作存在时,就会锁住priorityLock;此时,ReadWriteLock#lockRead逻辑的开始就会被block住。从效果来看,writeLock的地位高于readLock,即写操作优先。