谈一谈 iOS 的锁

收录:原文地址html

翻看目前关于 iOS 开发锁的文章,大部分都起源于 ibireme 的 《再也不安全的 OSSpinLock》,我在看文章的时候有一些疑惑。此次主要想解决这些疑问:linux

    1. 锁是什么?
    1. 为何要有锁?
    1. 锁的分类问题
    1. 为何 OSSpinLock 不安全?
    1. 解决自旋锁不安全问题有几种方式
    1. 为何换用其它的锁,能够解决 OSSpinLock 的问题?
    1. 自旋锁和互斥锁的关系是平行对立的吗?
    1. 信号量和互斥量的关系
    1. 信号量和条件变量的区别

锁是什么

锁 -- 是保证线程安全常见的同步工具。锁是一种非强制的机制,每个线程在访问数据或者资源前,要先获取(Acquire) 锁,并在访问结束以后释放(Release)锁。若是锁已经被占用,其它试图获取锁的线程会等待,直到锁从新可用。ios

为何要有锁?

前面说到了,锁是用来保护线程安全的工具。git

能够试想一下,多线程编程时,没有锁的状况 -- 也就是线程不安全。github

当多个线程同时对一块内存发生读和写的操做,可能出现意料以外的结果:编程

程序执行的顺序会被打乱,可能形成提早释放一个变量,计算结果错误等状况。数组

因此咱们须要将线程不安全的代码 “锁” 起来。保证一段代码或者多段代码操做的原子性,保证多个线程对同一个数据的访问 同步 (Synchronization)安全

属性设置 atomic

上面提到了原子性,我立刻想到了属性关键字里, atomic 的做用。多线程

设置 atomic 以后,默认生成的 getter 和 setter 方法执行是原子的。并发

可是它只保证了自身的读/写操做,却不能说是线程安全。

以下状况:

//thread A
for (int i = 0; i < 100000; i ++) {
if (i % 2 == 0) {
    self.arr = @[@"1", @"2", @"3"];
}else {
    self.arr = @[@"1"];
}
NSLog(@"Thread A: %@\n", self.arr);
}

//thread B
if (self.arr.count >= 2) {
    NSString* str = [self.arr objectAtIndex:1];
}

就算在 thread B 中针对 arr 数组进行了大小判断,可是仍然可能在 objectAtIndex: 操做时被改变数组长度,致使出错。这种状况声明为 atomic 也没有用。

而解决方式,就是进行加锁。

须要注意的是,读/写的操做都须要加锁,不只仅是对一段代码加锁。

锁的分类

锁的分类方式,能够根据锁的状态,锁的特性等进行不一样的分类,不少锁之间其实并非并列的关系,而是一种锁下的不一样实现。关于锁的分类,能够参考 Java中的锁分类 看一下。

自旋锁和互斥锁的关系

不少谈论锁的文章,都会提到互斥锁,自旋锁。不多有提到它们的关系,其实自旋锁,也是互斥锁的一种实现,而 spin lock和 mutex 二者都是为了解决某项资源的互斥使用,在任什么时候刻只能有一个保持者。

区别在于 spin lock和 mutex 调度机制上有所不一样。

OSSpinLock

OSSpinLock 是一种自旋锁。它的特色是在线程等待时会一直轮询,处于忙等状态。自旋锁由此得名。

自旋锁看起来是比较耗费 cpu 的,然而在互斥临界区计算量较小的场景下,它的效率远高于其它的锁。

由于它是一直处于 running 状态,减小了线程切换上下文的消耗。

为何 OSSpinLock 再也不安全?

关于 OSSpinLock 再也不安全,缘由就在于优先级反转问题。

优先级反转(Priority Inversion)

什么状况叫作优先级反转?

wikipedia 上是这么定义的:

优先级倒置,又称优先级反转、优先级逆转、优先级翻转,是一种不但愿发生的任务调度状态。在该种状态下,一个高优先级任务间接被一个低优先级任务所抢先(preemtped),使得两个任务的相对优先级被倒置。 这每每出如今一个高优先级任务等待访问一个被低优先级任务正在使用的临界资源,从而阻塞了高优先级任务;同时,该低优先级任务被一个次高优先级的任务所抢先,从而没法及时地释放该临界资源。这种状况下,该次高优先级任务得到执行权。

再消化一下

有:高优先级任务A / 次高优先级任务B / 低优先级任务C / 资源Z 。
A 等待 C 执行后的 Z
而 B 并不须要 Z,抢先得到时间片执行
C 因为没有时间片,没法执行(优先级相对没有B高)。
这种状况形成 A 在C 以后执行,C在B以后,间接的高优先级A在次高优先级任务B 以后执行, 使得优先级被倒置了。(假设: A 等待资源时不是阻塞等待,而是忙循环,则可能永远没法得到资源。此时 C 没法与 A 争夺 CPU 时间,从而 C 没法执行,进而没法释放资源。形成的后果,就是 A 没法得到 Z 而继续推动。)

而 OSSpinLock 忙等的机制,就可能形成高优先级一直 running ,占用 cpu 时间片。而低优先级任务没法抢占时间片,变成迟迟完不成,不释放锁的状况。

优先级反转的解决方案

关于优先级反转通常有如下三种解决方案

优先级继承

优先级继承,故名思义,是将占有锁的线程优先级,继承等待该锁的线程高优先级,若是存在多个线程等待,就取其中之一最高的优先级继承。

优先级天花板

优先级天花板,则是直接设置优先级上限,给临界区一个最高优先级,进入临界区的进程都将得到这个高优先级。

若是其余试图进入临界区的进程的优先级,都低于这个最高优先级,那么优先级反转就不会发生。

禁止中断

禁止中断的特色,在于任务只存在两种优先级:可被抢占的 / 禁止中断的 。

前者为通常任务运行时的优先级,后者为进入临界区的优先级。

经过禁止中断来保护临界区,没有其它第三种的优先级,也就不可能发生反转了。

为何使用其它的锁,能够解决优先级反转?

咱们看到不少原本使用 OSSpinLock 的知名项目,都改用了其它方式替代,好比 pthread_mutex 和 dispatch_semaphore 。

那为何其它的锁,就不会有优先级反转的问题呢?若是按照上面的想法,其它锁也可能出现优先级反转。

缘由在于,其它锁出现优先级反转后,高优先级的任务不会忙等。由于处于等待状态的高优先级任务,没有占用时间片,因此低优先级任务通常都能进行下去,从而释放掉锁。

线程调度

为了帮助理解,要提一下有关线程调度的概念。

不管多核心仍是单核,咱们的线程运行老是 "并发" 的。

当 cpu 数量大于等于线程数量,这个时候是真正并发,能够多个线程同时执行计算。

当 cpu 数量小于线程数量,总有一个 cpu 会运行多个线程,这时候"并发"就是一种模拟出来的状态。操做系统经过不断的切换线程,每一个线程执行一小段时间,让多个线程看起来就像在同时运行。这种行为就称为 "线程调度(Thread Schedule)"

线程状态

在线程调度中,线程至少拥有三种状态 : 运行(Running),就绪(Ready),等待(Waiting)

处于 Running的线程拥有的执行时间,称为 时间片(Time Slice),时间片 用完时,进入Ready状态。若是在Running状态,时间片没有用完,就开始等待某一个事件(一般是 IO 或 同步 ),则进入Waiting状态。

若是有线程从Running状态离开,调度系统就会选择一个Ready的线程进入 Running 状态。而Waiting的线程等待的事件完成后,就会进入Ready状态。

dispatch_semaphore

dispatch_semaphore 是 GCD 中同步的一种方式,与他相关的只有三个函数,一个是建立信号量,一个是等待信号,一个是发送信号。

信号量机制

信号量中,二元信号量,是一种最简单的锁。只有两种状态,占用和非占用。二元信号量适合惟一一个线程独占访问的资源。而多元信号量简称 信号量(Semaphore)。

信号量和互斥量的区别

信号量是容许并发访问的,也就是说,容许多个线程同时执行多个任务。信号量能够由一个线程获取,而后由不一样的线程释放。

互斥量只容许一个线程同时执行一个任务。也就是同一个程获取,同一个线程释放。

以前我对,互斥量只由一个线程获取和释放,理解的比较狭义,觉得这里的获取和释放,是系统强制要求的,用 NSLock 实验发现它能够在不一样线程获取和释放,感受很疑惑。

实际上,的确能在不一样线程获取/释放同一个互斥锁,但互斥锁原本就用于同一个线程中上锁和解锁。这里的意义更多在于代码使用的层面。

关键在于,理解信号量能够容许 N 个信号量容许 N 个线程并发地执行任务。

@synchonized

@synchonized 是一个递归锁。

递归锁

递归锁也称为可重入锁。互斥锁能够分为非递归锁/递归锁两种,主要区别在于:同一个线程能够重复获取递归锁,不会死锁; 同一个线程重复获取非递归锁,则会产生死锁。

由于是递归锁,咱们能够写相似这样的代码:

- (void)testLock{
   if(_count>0){ 
      @synchronized (obj) {
         _count = _count - 1;
         [self testLock];
      }
    }
 }

而若是换成NSLock,它就会由于递归发生死锁了。

实际使用问题

若是obj 为 nil,或者 obj地址不一样,锁会失效。

因此咱们要防止以下的状况:

@synchronized (obj) {
  obj = newObj;
}

这里的 obj 被更改后,等到其它线程访问时,就和没加锁同样直接进去了。

另一种状况,就是 @synchonized(self). 很多代码都是直接将self传入@synchronized当中,而 self 很容易做为一个外部对象,被调用和修改。因此它和上面是同样的状况,须要避免使用。

正确的作法是什么?obj 应当传入一个类内部维护的NSObject对象,并且这个对象是对外不可见的,不被随便修改的。

pthread_mutex

pthread定义了一组跨平台的线程相关的 API,其中可使用 pthread_mutex做为互斥锁。

pthread_mutex 不是使用忙等,而是同信号量同样,会阻塞线程并进行等待,调用时进行线程上下文切换。

pthread_mutex` 自己拥有设置协议的功能,经过设置它的协议,来解决优先级反转:

pthread_mutexattr_setprotocol(pthread_mutexattr_t *attr, int protocol)

其中协议类型包括如下几种:

  • PTHREAD_PRIO_NONE:线程的优先级和调度不会受到互斥锁拥有权的影响。
  • PTHREAD_PRIO_INHERIT:当高优先级的等待低优先级的线程锁定互斥量时,低优先级的线程以高优先级线程的优先级运行。这种方式将以继承的形式传递。当线程解锁互斥量时,线程的优先级自动被降到它原来的优先级。该协议就是支持优先级继承类型的互斥锁,它不是默认选项,须要在程序中进行设置。
  • PTHREAD_PRIO_PROTECT:当线程拥有一个或多个使用 PTHREAD_PRIO_PROTECT初始化的互斥锁时,此协议值会影响其余线程(如 thrd2)的优先级和调度。thrd2 以其较高的优先级或者以thrd2拥有的全部互斥锁的最高优先级上限运行。基于被thrd2拥有的任一互斥锁阻塞的较高优先级线程对于 thrd2的调度没有任何影响。

设置协议类型为 PTHREAD_PRIO_INHERIT ,运用优先级继承的方式,能够解决优先级反转的问题。

而咱们在 iOS 中使用的 NSLock,NSRecursiveLock等都是基于pthread_mutex 作实现的。

NSLock

NSLock属于 pthread_mutex的一层封装, 设置了属性为 PTHREAD_MUTEX_ERRORCHECK 。

它会损失必定性能换来错误提示。并简化直接使用 pthread_mutex 的定义。

NSCondition

NSCondition是经过pthread中的条件变量(condition variable) pthread_cond_t来实现的。

条件变量

在线程间的同步中,有这样一种状况: 线程 A 须要等条件 C 成立,才能继续往下执行.如今这个条件不成立,线程 A 就阻塞等待. 而线程 B 在执行过程当中,使条件 C 成立了,就唤醒线程 A 继续执行。

对于上述状况,可使用条件变量来操做。

条件变量,相似信号量,提供线程阻塞与信号机制,能够用来阻塞某个线程,等待某个数据就绪后,随后唤醒线程。

一个条件变量老是和一个互斥量搭配使用。

NSCondition其实就是封装了一个互斥锁和条件变量,互斥锁的lock/unlock方法和后者的wait/signal统一封装在 NSCondition对象中,暴露给使用者。

用条件变量控制线程同步,最为经典的例子就是 生产者-消费者问题。

生产者-消费者问题

生产者消费者问题,是一个著名的线程同步问题,该问题描述以下:

有一个生产者在生产产品,这些产品将提供给若干个消费者去消费。要求让生产者和消费者能并发执行,在二者之间设置一个具备多个缓冲区的缓冲池,生产者将它生产的产品放入一个缓冲区中,消费者能够从缓冲区中取走产品进行消费,显然生产者和消费者之间必须保持同步,即不容许消费者到一个空的缓冲区中取产品,也不容许生产者向一个已经放入产品的缓冲区中再次投放产品。

咱们能够恰好可使用 NSCondition解决生产者-消费者问题。具体的代码放置在文末的 Demo 里了。

这里须要注意,实际操做NSCondition作 wait操做时,若是用if判断:

if(count==0){
    [condition wait];
}

上面这样是不能保证消费者是线程安全的。

由于NSCondition能够给每一个线程分别加锁,但加锁后不影响其余线程进入临界区。因此 NSCondition使用 wait并加锁后,并不能真正保证线程的安全。

当一个signal操做发出时,若是有两个线程都在作 消费者 操做,那同时都会消耗掉资源,因而绕过了检查。

例如咱们的条件是,count == 0 执行等待。

假设当前 count = 0,线程A 要判断到 count == 0,执行等待;

线程B 执行了count = 1,并唤醒线程A 执行 count - 1,同时线程C 也判断到 count > 0 。由于处在不一样的线程锁,一样判断执行了 count - 1。2 个线程都会执行count - 1,可是 count = 1,实际就出现count = -1的状况。

因此为了保证消费者操做的正确,使用 while 循环中的判断,进行二次确认:

while (count == 0) {
   [condition wait];
}

条件变量和信号量的区别

每一个信号量有一个与之关联的值,发出时+1,等待时-1,任何线程均可以发出一个信号,即便没有线程在等待该信号量的值。

但是对于条件变量,例如 pthread_cond_signal发出信号后,没有任何线程阻塞在 pthread_cond_wait上,那这个条件变量上的信号会直接丢失掉。

NSConditionLock

NSConditionLock称为条件锁,只有 condition 参数与初始化时候的 condition相等,lock才能正确进行加锁操做。

这里分清两个概念:

  • unlockWithCondition:,它是先解锁,再修改 condition 参数的值。 并非当 condition 符合某个件值去解锁。
  • lockWhenCondition:,它与 unlockWithCondition: 不同,不会修改 condition 参数的值,而是符合 condition 的值再上锁。

在这里能够利用 NSConditionLock实现任务之间的依赖.

NSRecursiveLock

NSRecursiveLock 和前面提到的 @synchonized同样,是一个递归锁。

NSRecursiveLock 与 NSLock 的区别在于内部封装的pthread_mutex_t 对象的类型不一样,NSRecursiveLock 的类型被设置为 PTHREAD_MUTEX_RECURSIVE

NSDistributedLock

这里顺带提一下 NSDistributedLock, 是 macOS 下的一种锁.

苹果文档 对于NSDistributedLock 的描述是:

A lock that multiple applications on multiple hosts can use to restrict access to some shared resource, such as a file

意思是说,它是一个用在多个主机间的多应用的锁,能够限制访问一些共享资源,例如文件。

按字面意思翻译,NSDistributedLock 应该就叫作 分布式锁。可是看概念和资料,在 解决NSDistributedLock进程互斥锁的死锁问题(一) 里面看到,NSDistributedLock 更相似于文件锁的概念。 有兴趣的能够看一看 Linux 2.6 中的文件锁

其它保证线程安全的方式

除了用锁以外,有其它方法保证线程安全吗?

使用单线程访问

首先,尽可能避免多线程的设计。由于多线程访问会出现不少不可控制的状况。有些状况即便上锁,也没法保证百分之百的安全,例如自旋锁的问题。

不对资源作修改

而若是仍是得用多线程,那么避免对资源作修改。

若是都是访问共享资源,而不去修改共享资源,也能够保证线程安全。

好比NSArry做为不可变类是线程安全的。然而它们的可变版本,好比 NSMutableArray 是线程不安全的。事实上,若是是在一个队列中串行地进行访问的话,在不一样线程中使用它们也是没有问题的。

总结

若是实在要使用多线程,也没有必要过度追求效率,而更多的考虑线程安全问题,使用对应的锁。

对于平时编写应用里的多线程代码,仍是建议用 @synchronized,NSLock 等,可读性和安全性都好,多线程安全比多线程性能更重要。

这里提供了学习锁用的代码,感兴趣的能够看一看 实验 Demo


相关文章
相关标签/搜索