Skip to content

Latest commit

 

History

History
265 lines (156 loc) · 13 KB

锁.md

File metadata and controls

265 lines (156 loc) · 13 KB

1 锁的简介

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

锁是用来保护线程安全的工具,在之前多线程的梳理中,多个线程同时对一块内存发生读和写的操作,可能会得到与预期不一致的结果。所以需要对线程不安全的代码加锁。保证一段代码或者多段代码操作的原子性,保证多个线程对同一个数据的访问同步。

1.1 属性修饰词 atomic

属性的修饰词可以定义为atomic,设置atomic后,默认生成的getter和setter方法执行是原子的。

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

如下情况:

@property (atomic, strong) NSArray *array;

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    for (int i = 0; i < 10000; i++) {
        if (i % 2 == 0) {
            self.array = @[@"1", @"2", @"3"];
        } else {
            self.array = @[@"1"];
        }
        NSLog(@"Thread A : %@ , i = %d, current Thread = %@", self.array, i, [NSThread currentThread]);
    }
});

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    for (int j = 0; j < 10000; j++) {
        if (self.array.count >= 2) {
            NSLog(@"value = %@。current Thread = %@", [self.array objectAtIndex:1], [NSThread currentThread]);
        }
        NSLog(@"=%d", j);
    }
});

我试了几次,碰到了在调用[self.array objectAtIndex:1]时,正好此时的self.array只有一个元素,即使你已经在上一句代码判断了数组大于2。这种情况下atomic也没有用,此时加锁可以解决问题。

同时,因为只保证了set和get,如果在另外的线程对该对象执行了release也会造成线程不安全的问题。

通过iOS源码看出,虽然使用atomic后,在底层是通过spinlock_t的类型的锁来加锁,但是因为优先级反转的bug,在iOS10之后使用的是互斥锁。

spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;        
slotlock.unlock();

实际上是互斥锁

using spinlock_t = mutex_tt<LOCKDEBUG>;
typedef mutex_t spinlock_t;
#define spinlock_lock(l) mutex_lock(l)
#define spinlock_unlock(l) mutex_unlock(l)
#define SPINLOCK_INITIALIZER MUTEX_INITIALIZER

class mutex_tt : nocopy_t {
    os_unfair_lock mLock;
 		......
};

可以看出mutex_tt内部是os_unfair_lock,执行加锁时,会让线程休眠,避免自旋锁导致的优先级反转问题。


2 锁的分类

锁的分类方式,可以根据锁的状态,锁的特性等进行不同的分类,很多锁之间其实并不是并列关系,而是一种锁下的不同实现。

2.1 OSSpinLock

OSSpinLock是一种自旋锁,它的特点是在线程等待时会一直轮询,处于忙等状态。自旋锁看起来是比较耗费cpu的,然后在互斥临界区计算量较小的场景下,它的效率远高于其他的锁。因为是一直running状态,减少了线程切换上下文的消耗。

但OSSpinLock不再安全,原因就在于优先级反转问题。

优先级倒置,是一种不希望发生的任务调度状态。在该种状态下,一个高优先级任务间接被一个低优先级任务所抢先,使得两个任务的相对优先级被倒置。这往往出现在一个高优先级任务等待访问一个被低优先级任务正在使用的临界资源,从而阻塞了高优先级任务;同时,该低优先级任务被一个次高优先级的任务所抢先,从而无法及时地释放该临界资源。这种情况下,该次高优先级任务获取执行权。

转换一下:

高优先级任务A / 次高优先级任务B / 低优先级任务C / 资源Z。A 等待 C 使用 Z,而B并不需要 Z,抢先获得时间片执行。 C 由于没有时间片,无法执行。这种情况造成A 在 B之后执行,使得优先级被倒置了。而如果A等待资源时不是阻塞等待,而是忙循环,可能永远无法获得资源,此时C无法与B争夺CPU时间,从而C无法执行,进而无法释放资源。造成的后果就是A无法获得Z而继续推进。

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

解决方案:

  • 优先级继承,将占有锁的线程优先级,继承等待该锁的线程高优先级,如果存在多个线程等待,就取其中之一最高的优先级继承。
  • 优先级天花板,直接设置优先级上限,给临界区一个最高优先级,进入临界区的进程都将获得这个高优先级。如果其他试图进入临界区的进程的优先级,都低于这个最高优先级,那么优先级反转就不会发生。
  • 禁止中断,通过禁止中断来保护临界区,没有其他第三种的优先级,也就不会发生反转。只有两种优先级:可被抢占的/禁止中断的,将进入临界区的优先级设置为禁止中断。

2.2 线程调度

无论多核心还是单核,线程的运行总是“并发”的,当CPU数量大于等于线程数量,这个时候是真正并发,可以多个线程同时执行计算。当CPU数量小于线程数量,总有一个CPU会运行多个线程,这个时候“并发”就是一种模拟出来的状态,操作系统通过不断的切换线程,每个线程执行一小段时间,让多个线程看起来就像在同时运行。这种行为称为“线程调度”。

2.2.1 线程状态

在线程调度中,线程至少拥有三种状态:运行(runnning)、就绪(ready)、等待(waiting)。

处于running的线程拥有的执行时间,称为 时间片,时间片用完时,进入ready状态。如果在running状态,时间片没有用完,就开始等待某一个事件(通常是IO或同步),则进入waiting状态。

如果有线程从running状态离开,调度系统就会选择一个ready的线程进入running状态,而waiting的线程等待的时间完成后,就会进入ready状态。


3 dispatch_semaphore

信号量的使用已经在多线程中进行梳理。

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

信号量是允许并发访问的,也就是说,允许多个线程同时执行多个任务,信号量可以由一个线程获取,然后由不同线程释放。

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


4 @synchonized

@synchronized是一个递归锁。

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

因为是递归锁,我们可以对锁进行递归调用,比如:

- (void)testSynchronized {
    if (count > 0) {
        @synchronized (self) {
            count--;
            [self testSynchronized];
        }
    }
}

如果使用NSLock就会变成死锁发生崩溃。

需要注意的是@synchronized (self),中的self如果是nil或者地址变了,锁就会失效。因为self可能作为一个外部对象,被调用和修改,所以尽量使用一个内部维护的不能被外部随便修改的对象。


5 pthread_mutex

pthread定义了一组跨平台的线程相关API,其中可以使用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初始化的互斥锁时,此协议值会影响其他线程的优先级和调度。其他线程以其较高的优先级或者以他拥有的所有互斥锁的最高优先级上限运行。基于被他拥有的任一互斥锁阻塞的较高优先级线程对于他的调度没有任何影响。

使用PTHREAD_PRIO_INHERIT,运用优先级继承的方式,可以解决优先级反转的问题。

在iOS中,NSLock、NSRecursiveLock都是基于pthread_mutex来实现的。


6 NSLock

NSLock属于pthread_mutex的一层封装,设置了属性为PTHREAD_MUTEX_ERRORCHECK,会损失一定性能换来错误提示。并简化直接使用pthread_mutex的定义。


7 NSCondition

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

条件变量

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

对于上述情况,可以使用条件变量来操作。

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

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

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

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

使用NSCondition解决生产者-消费者问题

在使用条件wait之前,需要保证消费者操作的正确,使用while循环中进行判断,进行二次确认:

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

条件变量和信号量的区别

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

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


8 NSConditionLock

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

这里分清两个概念:

  • unlockWithCondition:先解锁,再修改condition参数的值,并不是当condition符合某个值去解锁。
  • lockWhencondition:与unlockWithCondition不一样,不会修改condition参数的值,而是符合condition的值再上锁。

在这里可以利用NSConditionLock实现任务之间的依赖。


9 NSRecursiveLock

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

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


10 各种锁的效率对比图

11 其他保证线程安全的方式

除了加锁,还有一些其他方式保证线程安全

11.1 使用单线程访问

避免多线程的设计,即可保证资源的顺序执行,达到预期效果

11.2 不对资源做修改

避免对资源修改,如果都是访问共享资源,而不去修改共享资源,也可以保证线程安全。

比如NSArray作为不可变类型是安全的。然后他们的可变版本,比如NSMutableArray是线程不安全的,事实上,如果是一个队列中串行地进行访问的话,在不同线程中使用也是没有问题的。

11.3 使用串行队列处理

可以使用GCD创建串行队列,再配合同步或者异步的任务,将需要保持线程安全的代码放到串行队列中执行,从而保证任务都在同一个线程中执行。