深刻理解 iOS 开发中的锁

摘要

本文的目的不是介绍 iOS 中各类锁如何使用,一方面笔者没有大量的实战经验,另外一方面这样的文章至关多,好比 iOS中保证线程安全的几种方式与性能对比iOS 常见知识点(三):Lock。本文也不会详细介绍锁的具体实现原理,这会涉及到太多相关知识,笔者不敢误人子弟。html

本文要作的就是简单的分析 iOS 开发中常见的几种锁如何实现,以及优缺点是什么,为何会有性能上的差距,最终会简单的介绍锁的底层实现原理。水平有限,若是不慎有误,欢迎交流指正。同时建议读者在阅读本文之前,对 OC 中各类锁的使用方法先有大概的认识。android

在 ibireme 的 再也不安全的 OSSpinLock 一文中,有一张图片简单的比较了各类锁的加解锁性能:ios

来源:ibireme

本文会按照从上至下(速度由快至慢)的顺序分析每一个锁的实现原理。须要说明的是,加解锁速度不表示锁的效率,只表示加解锁操做在执行时的复杂程度,下文会经过具体的例子来解释。git

OSSpinLock

上述文章中已经介绍了 OSSpinLock 再也不安全,主要缘由发生在低优先级线程拿到锁时,高优先级线程进入忙等(busy-wait)状态,消耗大量 CPU 时间,从而致使低优先级线程拿不到 CPU 时间,也就没法完成任务并释放锁。这种问题被称为优先级反转。github

为何忙等会致使低优先级线程拿不到时间片?这还得从操做系统的线程调度提及。算法

现代操做系统在管理普通线程时,一般采用时间片轮转算法(Round Robin,简称 RR)。每一个线程会被分配一段时间片(quantum),一般在 10-100 毫秒左右。当线程用完属于本身的时间片之后,就会被操做系统挂起,放入等待队列中,直到下一次被分配时间片。swift

自旋锁的实现原理

自旋锁的目的是为了确保临界区只有一个线程能够访问,它的使用能够用下面这段伪代码来描述:数组

do {
    Acquire Lock
        Critical section  // 临界区
    Release Lock
        Reminder section // 不须要锁保护的代码
}复制代码

在 Acquire Lock 这一步,咱们申请加锁,目的是为了保护临界区(Critical Section) 中的代码不会被多个线程执行。缓存

自旋锁的实现思路很简单,理论上来讲只要定义一个全局变量,用来表示锁的可用状况便可,伪代码以下:安全

bool lock = false; // 一开始没有锁上,任何线程均可以申请锁
do {
    while(lock); // 若是 lock 为 true 就一直死循环,至关于申请锁
    lock = true; // 挂上锁,这样别的线程就没法得到锁
        Critical section  // 临界区
    lock = false; // 至关于释放锁,这样别的线程能够进入临界区
        Reminder section // 不须要锁保护的代码 
}复制代码

注释写得很清楚,就再也不逐行分析了。惋惜这段代码存在一个问题: 若是一开始有多个线程同时执行 while 循环,他们都不会在这里卡住,而是继续执行,这样就没法保证锁的可靠性了。解决思路也很简单,只要确保申请锁的过程是原子操做便可。

原子操做

狭义上的原子操做表示一条不可打断的操做,也就是说线程在执行操做过程当中,不会被操做系统挂起,而是必定会执行完。在单处理器环境下,一条汇编指令显然是原子操做,由于中断也要经过指令来实现。

然而在多处理器的状况下,可以被多个处理器同时执行的操做任然算不上原子操做。所以,真正的原子操做必须由硬件提供支持,好比 x86 平台上若是在指令前面加上 “LOCK” 前缀,对应的机器码在执行时会把总线锁住,使得其余 CPU不能再执行相同操做,从而从硬件层面确保了操做的原子性。

这些很是底层的概念无需彻底掌握,咱们只要知道上述申请锁的过程,能够用一个原子性操做 test_and_set 来完成,它用伪代码能够这样表示:

bool test_and_set (bool *target) {
    bool rv = *target; 
    *target = TRUE; 
    return rv;
}复制代码

这段代码的做用是把 target 的值设置为 1,并返回原来的值。固然,在具体实现时,它经过一个原子性的指令来完成。

自旋锁的总结

至此,自旋锁的实现原理就很清楚了:

bool lock = false; // 一开始没有锁上,任何线程均可以申请锁
do {
    while(test_and_set(&lock); // test_and_set 是一个原子操做
        Critical section  // 临界区
    lock = false; // 至关于释放锁,这样别的线程能够进入临界区
        Reminder section // 不须要锁保护的代码 
}复制代码

若是临界区的执行时间过长,使用自旋锁不是个好主意。以前咱们介绍过期间片轮转算法,线程在多种状况下会退出本身的时间片。其中一种是用完了时间片的时间,被操做系统强制抢占。除此之外,当线程进行 I/O 操做,或进入睡眠状态时,都会主动让出时间片。显然在 while 循环中,线程处于忙等状态,白白浪费 CPU 时间,最终由于超时被操做系统抢占时间片。若是临界区执行时间较长,好比是文件读写,这种忙等是毫无必要的。

信号量

以前我在 介绍 GCD 底层实现的文章 中简单描述了信号量 dispatch_semaphore_t 的实现原理,它最终会调用到 sem_wait 方法,这个方法在 glibc 中被实现以下:

int sem_wait (sem_t *sem) {
  int *futex = (int *) sem;
  if (atomic_decrement_if_positive (futex) > 0)
    return 0;
  int err = lll_futex_wait (futex, 0);
    return -1;
)复制代码

首先会把信号量的值减一,并判断是否大于零。若是大于零,说明不用等待,因此马上返回。具体的等待操做在 lll_futex_wait 函数中实现,lll 是 low level lock 的简称。这个函数经过汇编代码实现,调用到 SYS_futex 这个系统调用,使线程进入睡眠状态,主动让出时间片,这个函数在互斥锁的实现中,也有可能被用到。

主动让出时间片并不老是表明效率高。让出时间片会致使操做系统切换到另外一个线程,这种上下文切换一般须要 10 微秒左右,并且至少须要两次切换。若是等待时间很短,好比只有几个微秒,忙等就比线程睡眠更高效。

能够看到,自旋锁和信号量的实现都很是简单,这也是二者的加解锁耗时分别排在第一和第二的缘由。再次强调,加解锁耗时不能准确反应出锁的效率(好比时间片切换就没法发生),它只能从必定程度上衡量锁的实现复杂程度。

pthread_mutex

pthread 表示 POSIX thread,定义了一组跨平台的线程相关的 API,pthread_mutex 表示互斥锁。互斥锁的实现原理与信号量很是类似,不是使用忙等,而是阻塞线程并睡眠,须要进行上下文切换。

互斥锁的常见用法以下:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);  // 定义锁的属性

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &attr) // 建立锁

pthread_mutex_lock(&mutex); // 申请锁
    // 临界区
pthread_mutex_unlock(&mutex); // 释放锁复制代码

对于 pthread_mutex 来讲,它的用法和以前没有太大的改变,比较重要的是锁的类型,能够有 PTHREAD_MUTEX_NORMALPTHREAD_MUTEX_ERRORCHECKPTHREAD_MUTEX_RECURSIVE 等等,具体的特性就不作解释了,网上有不少相关资料。

通常状况下,一个线程只能申请一次锁,也只能在得到锁的状况下才能释放锁,屡次申请锁或释放未得到的锁都会致使崩溃。假设在已经得到锁的状况下再次申请锁,线程会由于等待锁的释放而进入睡眠状态,所以就不可能再释放锁,从而致使死锁。

然而这种状况常常会发生,好比某个函数申请了锁,在临界区内又递归调用了本身。辛运的是 pthread_mutex 支持递归锁,也就是容许一个线程递归的申请锁,只要把 attr 的类型改为 PTHREAD_MUTEX_RECURSIVE 便可。

互斥锁的实现

互斥锁在申请锁时,调用了 pthread_mutex_lock 方法,它在不一样的系统上实现各有不一样,有时候它的内部是使用信号量来实现,即便不用信号量,也会调用到 lll_futex_wait 函数,从而致使线程休眠。

上文说到若是临界区很短,忙等的效率也许更高,因此在有些版本的实现中,会首先尝试必定次数(好比 1000 次)的 test_and_test,这样能够在错误使用互斥锁时提升性能。

另外,因为 pthread_mutex 有多种类型,能够支持递归锁等,所以在申请加锁时,须要对锁的类型加以判断,这也就是为何它和信号量的实现相似,但效率略低的缘由。

NSLock

NSLock 是 Objective-C 以对象的形式暴露给开发者的一种锁,它的实现很是简单,经过宏,定义了 lock 方法:

#define MLOCK \
- (void) lock\
{\
  int err = pthread_mutex_lock(&_mutex);\
  // 错误处理 ……
}复制代码

NSLock 只是在内部封装了一个 pthread_mutex,属性为 PTHREAD_MUTEX_ERRORCHECK,它会损失必定性能换来错误提示。

这里使用宏定义的缘由是,OC 内部还有其余几种锁,他们的 lock 方法都是如出一辙,仅仅是内部 pthread_mutex 互斥锁的类型不一样。经过宏定义,能够简化方法的定义。

NSLockpthread_mutex 略慢的缘由在于它须要通过方法调用,同时因为缓存的存在,屡次方法调用不会对性能产生太大的影响。

NSCondition

NSCondition 的底层是经过条件变量(condition variable) pthread_cond_t 来实现的。条件变量有点像信号量,提供了线程阻塞与信号机制,所以能够用来阻塞某个线程,并等待某个数据就绪,随后唤醒线程,好比常见的生产者-消费者模式。

如何使用条件变量

不少介绍 pthread_cond_t 的文章都会提到,它须要与互斥锁配合使用:

void consumer () { // 消费者
    pthread_mutex_lock(&mutex);
    while (data == NULL) {
        pthread_cond_wait(&condition_variable_signal, &mutex); // 等待数据
    }
    // --- 有新的数据,如下代码负责处理 ↓↓↓↓↓↓
    // temp = data;
    // --- 有新的数据,以上代码负责处理 ↑↑↑↑↑↑
    pthread_mutex_unlock(&mutex);
}

void producer () {
    pthread_mutex_lock(&mutex);
    // 生产数据
    pthread_cond_signal(&condition_variable_signal); // 发出信号给消费者,告诉他们有了新的数据
    pthread_mutex_unlock(&mutex);
}复制代码

天然咱们会有疑问:“若是不用互斥锁,只用条件变量会有什么问题呢?”。问题在于,temp = data; 这段代码不是线程安全的,也许在你把 data 读出来之前,已经有别的线程修改了数据。所以咱们须要保证消费者拿到的数据是线程安全的。

wait 方法除了会被 signal 方法唤醒,有时还会被虚假唤醒,因此须要这里 while 循环中的判断来作二次确认。

为何要使用条件变量

介绍条件变量的文章很是多,但大多都对一个一个基本问题避而不谈:“为何要用条件变量?它仅仅是控制了线程的执行顺序,用信号量或者互斥锁能不能模拟出相似效果?”

网上的相关资料比较少,我简单说一下我的见解。信号量能够必定程度上替代 condition,可是互斥锁不行。在以上给出的生产者-消费者模式的代码中, pthread_cond_wait 方法的本质是锁的转移,消费者放弃锁,而后生产者得到锁,同理,pthread_cond_signal 则是一个锁从生产者到消费者转移的过程。

若是使用互斥锁,咱们须要把代码改为这样:

void consumer () { // 消费者
    pthread_mutex_lock(&mutex);
    while (data == NULL) {
        pthread_mutex_unlock(&mutex);
        pthread_mutex_lock(&another_lock)  // 至关于 wait 另外一个互斥锁
        pthread_mutex_lock(&mutex);
    }
    pthread_mutex_unlock(&mutex);
}复制代码

这样作存在的问题在于,在等待 another_lock 以前, 生产者有可能先执行代码, 从而释放了 another_lock。也就是说,咱们没法保证释放锁和等待另外一个锁这两个操做是原子性的,也就没法保证“先等待、后释放 another_lock” 这个顺序。

用信号量则不存在这个问题,由于信号量的等待和唤醒并不须要知足前后顺序,信号量只表示有多少个资源可用,所以不存在上述问题。然而与 pthread_cond_wait 保证的原子性锁转移相比,使用信号量彷佛存在必定风险(暂时没有查到非原子性操做有何不妥)。

不过,使用 condition 有一个好处,咱们能够调用 pthread_cond_broadcast 方法通知全部等待中的消费者,这是使用信号量没法实现的。

NSCondition 的作法

NSCondition 实际上是封装了一个互斥锁和条件变量, 它把前者的 lock 方法和后者的 wait/signal 统一在 NSCondition 对象中,暴露给使用者:

- (void) signal {
  pthread_cond_signal(&_condition);
}

// 其实这个函数是经过宏来定义的,展开后就是这样
- (void) lock {
  int err = pthread_mutex_lock(&_mutex);
}复制代码

它的加解锁过程与 NSLock 几乎一致,理论上来讲耗时也应该同样(实际测试也是如此)。在图中显示它耗时略长,我猜想有多是测试者在每次加解锁的先后还附带了变量的初始化和销毁操做。

NSRecursiveLock

上文已经说过,递归锁也是经过 pthread_mutex_lock 函数来实现,在函数内部会判断锁的类型,若是显示是递归锁,就容许递归调用,仅仅将一个计数器加一,锁的释放过程也是同理。

NSRecursiveLockNSLock 的区别在于内部封装的 pthread_mutex_t 对象的类型不一样,前者的类型为 PTHREAD_MUTEX_RECURSIVE

NSConditionLock

NSConditionLock 借助 NSCondition 来实现,它的本质就是一个生产者-消费者模型。“条件被知足”能够理解为生产者提供了新的内容。NSConditionLock 的内部持有一个 NSCondition 对象,以及 _condition_value 属性,在初始化时就会对这个属性进行赋值:

// 简化版代码
- (id) initWithCondition: (NSInteger)value {
    if (nil != (self = [super init])) {
        _condition = [NSCondition new]
        _condition_value = value;
    }
    return self;
}复制代码

它的 lockWhenCondition 方法其实就是消费者方法:

- (void) lockWhenCondition: (NSInteger)value {
    [_condition lock];
    while (value != _condition_value) {
        [_condition wait];
    }
}复制代码

对应的 unlockWhenCondition 方法则是生产者,使用了 broadcast 方法通知了全部的消费者:

- (void) unlockWithCondition: (NSInteger)value {
    _condition_value = value;
    [_condition broadcast];
    [_condition unlock];
}复制代码

@synchronized

这实际上是一个 OC 层面的锁, 主要是经过牺牲性能换来语法上的简洁与可读。

咱们知道 @synchronized 后面须要紧跟一个 OC 对象,它其实是把这个对象当作锁来使用。这是经过一个哈希表来实现的,OC 在底层使用了一个互斥锁的数组(你能够理解为锁池),经过对对象去哈希值来获得对应的互斥锁。

具体的实现原理能够参考这篇文章: 关于 @synchronized,这儿比你想知道的还要多

参考资料

  1. pthread_mutex_lock
  2. ThreadSafety
  3. Difference between binary semaphore and mutex
  4. 关于 @synchronized,这儿比你想知道的还要多
  5. pthread_mutex_lock.c 源码
  6. [Pthread] Linux中的线程同步机制(二)--In Glibc
  7. pthread的各类同步机制
  8. pthread_cond_wait
  9. Conditional Variable vs Semaphore
相关文章
相关标签/搜索