iOS 锁的原理分析(二)

972 阅读14分钟

锁的分类

自旋锁

线程反复检查锁变量是否可用。由于线程在这一过程中保持执行, 因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。 自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。

互斥锁

是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区,而达成。这里有两个要注意的点互斥跟同步,互斥就是当多个线程进行同一操作的时候,同一时间只有一个线程可以进行操作。同步是多个线程进行同一操作的时候,按照相应的顺序执行。互斥锁又分为两种情况,可递归和不可递归。

这里属于互斥锁的有:

  • NSLock
  • pthread_mutex
  • @synchronized

条件锁

就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就 是锁住了。当资源被分配到了,条件锁打开,进程继续运行。

  • NSCondition
  • NSConditionLock

递归锁

就是同一个线程可以加锁N次而不会引发死锁。

  • NSRecursiveLock
  • pthread_mutex(recursive)

信号量

信号量(semaphore):是一种更高级的同步机制,互斥锁可以说是 semaphore 在仅取值 0/1 时的特例。信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。

  • dispatch_semaphore

读写锁

读写锁实际是一种特殊的互斥锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源 进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU 数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。在读写锁保持期间也是抢占失效的。

如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁,否则它必须自旋在那里, 直到没有任何写者或读者。如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁。

一次只有一个线程可以占有写模式的读写锁, 但是可以有多个线程同时占有读模式的读写锁。 正是因为这个特性,当读写锁是写加锁状态时, 在这个锁被解锁之前, 所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时, 所有试图以读模式对它进行加锁的线程都可以得到访问权, 但是如果线程希望以写模式对此锁进行加锁,它必须直到所有的线程释放锁。通常,当读写锁处于读模式锁住状态时,如果有另外线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求,这样可以避免读模式锁⻓期占用,而等待的写模式锁请求⻓期阻塞。读写锁适合于对数据结构的读次数比写次数多得多的情况。因为,读模式锁定时可以共享,以写模式锁住时意味着独占,所以读写锁又叫共享-独占锁。

总结

其实基本的锁就包括了三类,自旋锁, 互斥锁 读写锁,其他的比如条件锁,递归锁,信号量都是上层的封装和实现!

pthread

Posix Thread 中定义有一套专⻔用于线程同步的函数 mutex,用于保证在任何时刻,都只能有一个线程访问该对象。 当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。

  1. 创建和销毁 A: POSIX 定义了一个宏 PTHREAD_MUTEX_INITIALIZER 来静态初始化互斥锁 B: int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr) C: pthread_mutex_destroy () 用于注销一个互斥锁

  2. 锁操作

  • int pthread_mutex_lock(pthread_mutex_t *mutex)
  • int pthread_mutex_unlock(pthread_mutex_t *mutex)
  • int pthread_mutex_trylock(pthread_mutex_t *mutex)
  • pthread_mutex_trylock() 语义与 pthread_mutex_lock() 类似,不同的是在锁已经被占据时返回 EBUSY 而不是挂起等待。

NSLock 和 NSReLock 的分析

这里我们通过几个使用案例来介绍一下 NSLockNSReLock 这两种锁。

  • 案例 1 image.png image.png

类似这样一段代码,当我们不加锁的情况下打印就会乱序,当我们在 testMethod(10) 执行前后分别加锁解锁就会循环按顺序打印。

  • 案例 2 image.png

类似这种,我们把 lockunlock 调整了下位置,就会出现类似死锁的现象,testMethod 递归执行。导致这个的原因是因为 NSLock 不具有可递归性。针对这种情况我们可以用 @synchronized 来解决,也可以用 NSRecursiveLock 来解决。因为在前面已经分析了 @synchronized,这里我们来试一下用 NSRecursiveLock 来解决,NSRecursiveLock 的使用频率也很高,我们在很多三方库里面在一些递归加锁的场景也可以看到 NSRecursiveLock 的应用。

  • 案例 3 image.png

当我们使用 NSRecursiveLock 的时候发现第一次可以打印,但是第二次就报错了,这是因为 NSRecursiveLock 具有可递归性,但是不支持多线程执行。

  • 案例 4 image.png

我们使用 @synchronized 既解决了递归调用,也解决了多线程的问题。

NSCondtion的分析

NSCondition 的对象实际上作为一个锁和一个线程检查器,锁主要为了当检测条件时保护数据源,执行条件引发的任务;线程检查器主要是根据条件决定是否继续运行线程,即线程是否被阻塞。

NSConditionapi介绍:

  • [condition lock]:一般用于多线程同时访问、修改同一个数据源,保证在同一 时间内数据源只被访问、修改一次,其他线程的命令需要在 lock 外等待,只到 unlock,才可访问。
  • [condition unlock]:与lock 同时使用。
  • [condition wait]:让当前线程处于等待状态。
  • [condition signal]:CPU发信号告诉线程不用在等待,可以继续执行。

案例

- (void)cx_testConditon{
    _testCondition = [[NSCondition alloc] init];
    //创建生产-消费者
    for (int i = 0; i < 50; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self cx_producer];
        });
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self cx_producer];
        });
        
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self cx_consumer];
        });
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self cx_consumer];
        });
    }
}

- (void)cx_producer {
    [_testCondition lock]; // 操作的多线程影响
    self.ticketCount = self.ticketCount + 1;
    NSLog(@"生产一个 现有 count %zd",self.ticketCount);
    if (self.ticketCount > 0) {
        [_testCondition signal]; // 信号
    }
    [_testCondition unlock];
}

- (void)cx_consumer {
     [_testCondition lock];  // 操作的多线程影响
    if (self.ticketCount == 0) {
        NSLog(@"等待 count %zd",self.ticketCount);
        [_testCondition wait];
    }
    //注意消费行为,要在等待条件判断之后
    self.ticketCount -= 1;
    NSLog(@"消费一个 还剩 count %zd ",self.ticketCount);
     [_testCondition unlock];
}

image.png

类似这样一段代码,我们定义了生产方法 cx_producer 跟消费方法 cx_consumer,在 ticketCount 值修改的时候都会加锁,但是在消费方法里面会判断 ticketCount 小于零的时候就会进入等待,禁止消费,在生产方法 cx_producer 中判断 ticketCount 大于零的时候就会发送信号,继续执行。保证了事务的安全。

foundation 源码关于锁的封装

我们前面也讲了,例如 NSLock, NSRecursiveLock, NSCondition 等这些锁的底层都是基于 pthread 的封装,但是这些锁的底层都是在 NSFoundation 框架下实现的,但是 NSFoundation 框架并不开源,我们怎么来探究它们的底层实现呢?这里我们取了个巧,用 swiftfoundation 框架源码来进行探究。源码已上传到 github,感兴趣的小伙伴可以下载。

NSLock

image.png

在我们的代码下我们我们按住 control + command 键点击进入 NSLock 的头文件实现可以看到 NSLock 是继承于 NSObject 的一个类,只是遵循了 NSLocking 协议。因为这里只能看到协议的声明,具体实现我们打开源码来看一下。

image.png image.png

NSRecursiveLock

image.png

上面案例分析的时候我们知道 NSRecursiveLock 相对于 NSLock 具有可递归性,对比他们的源码我们可以看到,只是因为 NSRecursiveLock 的底层 pthread_mutex_init 的时候多了一个 attrs 参数。它们的 lockunlock 方法的底层实现都是一样。

NSCondition

image.png

查看 NSCondition 的源码实现,我们发现 NSCondition 只是在初始化的时候多了一句 pthread_cond_init(cond, nil),它的 wait 方法底层调用的是 pthread_cond_wait(cond, mutex)。通过对这几种锁的分析我们可以看到它们的底层都是基于 pthread 的封装,当我们不知道使用哪种锁的时候,用 pthread 来实现是最完美的。

NSConditionLock

NSConditionLock 介绍

  • 1.1 NSConditionLock 是一种锁,一旦一个线程获得锁,其他线程一定等待。
  • 1.2 [conditionLock lock] 表示 conditionLock 期待获得锁,如果没有其他线程获得锁(不需要判断内部的 condition) 那它能执行此行以下代码,如果已经有其他线程获得锁(可能是条件锁,或者无条件锁),则等待,直至其他线程解锁。
  • 1.3 [conditionLock lockWhenCondition:A条件] 表示如果没有其他线程获得该锁,但是该锁内部的 condition 不等于 A条件,它依然不能获得锁,仍然等待。如果内部的 condition 等于 A条件,并且 没有其他线程获得该锁,则进入代码区,同时设置它获得该锁,其他任何线程都将等待它代码的 完成,直至它解锁。
  • 1.4 [conditionLock unlockWithCondition:A条件] 表示释放锁,同时把内部的 condition 设置为 A条件
  • 1.5 return = [conditionLock lockWhenCondition:A条件 beforeDate:A时间] 表示如果被锁定(没获得锁),并超过该时间则不再阻塞线程。但是注意:返回的值是 NO,它没有改变锁的状态,这个函数的目的在于可以实现两种状态下的处理。
  • 1.6 所谓的 condition 就是整数,内部通过整数比较条件。

案例

- (void)cx_testConditonLock{
    NSConditionLock *conditionLock = [[NSConditionLock alloc] initWithCondition:2];
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
        [conditionLock lockWhenCondition:1];
        NSLog(@"线程 1");
        [conditionLock unlockWithCondition:0];
    });
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        [conditionLock lockWhenCondition:2];
        sleep(0.1);
        NSLog(@"线程 2");
        [conditionLock unlockWithCondition:1];
    });
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
       [conditionLock lock];
       NSLog(@"线程 3");
       [conditionLock unlock];
    });
}
  • 线程 1 调用 [NSConditionLock lockWhenCondition:],此时此刻因为不满足当前条件,所以会进入 waiting 状态,当前进入到 waiting 时,会释放当前的互斥锁。

  • 此时当前的线程 3 调用 [NSConditionLock lock:],本质上是调用 [NSConditionLock lockBeforeDate:],这里不需要比对条件值,所以线程 3 会打印

  • 接下来线程 2 执行 [NSConditionLock lockWhenCondition:],因为满足条件值,所以线程 2 会打印,打印完成后会调用[NSConditionLock unlockWithCondition:],这个时候将 value 设置为 1,并发送 boradcast, 此时线程 1 接收到当前的信号,唤醒执行并打印。

  • 自此当前打印为 线程 3->线程 2 -> 线程 1。

  • [NSConditionLock lockWhenCondition:]:这里会根据传入的 condition 值和 Value 值进行对比,如果不相等,这里就会阻塞,进入线程池,否则的话就继续代码执行。

  • [NSConditionLock unlockWithCondition:]:这里会先更改当前的 value 值,然后进行广播,唤醒当前的线程。

NSConditionLock 执行流程分析

通过上面的案例我们会有几个疑问:

  • NSConditionLockNSCondition 有什么区别
  • 初始化的时候 [[NSConditionLock alloc] initWithCondition:2] 会传入一个参数 2,这个值的作用是干什么的
  • lockWhenCondition 是如何控制流程的
  • unlockWithCondition 又做了什么

前面几种锁我们都是通过源码看到了底层的实现,但是当我们没有源码的时候我们又应该用哪种思路去分析呢?这里我们尝试一下通过反汇编跟来探索一下。这里环境用的是真机。

  • initWithCondition 流程追踪

image.png

首先对 initWithCondition 方法下符号断点 -[NSConditionLock initWithCondition:],这里需要注意因为是对象方法,所以符号断点有点特殊。

image.png

断点之后我们可以看到汇编代码,这里 x0, x1, x2 分别代表方法的调用者, 调用方法, 参数。这里我们输出之后跟我们 OC 代码的调用都能一一对应上。这里我们重点追踪 bl 的执行,因为 bl 代表跳转。下面我们就一步一步的断点 bl 的执行。

image.png

这里 x0 输出暂时看不到,但是可以看到调用了 init 方法,并且参数是 2。

image.png

这里追踪到 NSConditionLock 调用了 init 方法,并且参数是 2。

image.png

这里 NSConditionLock 调用了 zone 方法,也就是内存开辟。

image.png

这里 NSCondition 调用了 allocWithZone 方法。

image.png

这里 NSCondition 调用了 init 方法。

image.png

这里就是 returnx0 就是返回对象,打印 x0 的内存结构,可以看到它有 NSCondition 跟 2 两个成员变量。

  • lockWhenCondition 流程追踪

image.png image.png

这里 NSDate 调用了 distantFuture 方法且参数为 1。

image.png

这里执行了 waitUntilDate 方法,进行了等待。

image.png

这里 NSConditionLock 调用了 lockWhenCondition:beforeDate:,第一个参数为 1,第二个参数为 [NSDate distantFuture] 的返回值。并且在这里新增符号断点 -[NSConditionLock lockWhenCondition:beforeDate:]

image.png

这里会断到 lockWhenCondition:beforeDate: 方法。

image.png image.png

lockWhenCondition:beforeDate: 之后会再次来到 lockWhenCondition 方法,只是到了线程 4,参数变为了 2。

image.png

线程 4 中 lockWhenCondition 之后还会来到 lockWhenCondition:beforeDate: 方法。在 bl 这里 NSCondition 调用了 lock 方法。

  • unlockWithCondition 流程追踪

image.png image.png

这里会来到 unlockWithCondition 方法,并且也进行了加锁操作。

image.png

这里 NSCondition 调用了 broadcast 方法。

image.png

方法结束后 NSCondition 调用了 unlock 方法。

image.png

紧接着这里会来到我们 OC 代码中的线程一中的 lockWhenCondition:beforeDate: 方法,在这里又进行了一次解锁操作,跟上面我们两次加锁一一对应上了。

image.png

执行结束也返回了 0x0000000000000001,也就是 1。

image.png

最后执行 OC 代码中的线程一中的 unlockWithCondition 方法。然后又会执行上面 unlockWithCondition 方法的汇编流程。这里 1 代表不在等待。

反汇编分析与源码对比

image.png image.png

通过对比我们可以看到我们反汇编分析的执行流程与源码逻辑一致。

GCD 实现多读单写

比如在内存中维护一份数据,有多处地方可能同时操作这块数据,怎么能保证数据库的安全呢?这里需要满足以下三点:

  • 1.读写互斥
  • 2.写写互斥
  • 3.读读并发
- (instancetype)init {
    self = [super init];
    if (self) {
        _currentQueue = dispatch_queue_create("chenxi", DISPATCH_QUEUE_CONCURRENT);
        _dic = [NSMutableDictionary dictionary];
    }
    return self;
}

- (void)cx_setSafeObject:(id)object forKey:(NSString *)key {
    key = [key copy];
    __weak __typeof(self)weakSelf = self;
    dispatch_barrier_async(_currentQueue, ^{
        [weakSelf.dic setObject:object forKey:key];
    });
}

- (id)cx_safeObjectForKey:(NSString *)key {
    __block id temp;
    __weak __typeof(self)weakSelf = self;
    dispatch_sync(_currentQueue, ^{
        temp = [weakSelf.dic objectForKey:key];
    });
    return temp;
}
  • 首先我们要维系一个GCD队列,最好不用全局队列,毕竟大家都知道全局队列遇到栅栏函数是有坑点的,这里就不分析了!

  • 因为考虑性能, 死锁, 堵塞的因素不考虑串行队列,用的是自定义的并发队列!

  • 首先我们来看看读操作: cx_safe0bjectForKey 我们考虑到多线程影响是不能用异步函数的!说明:

    • 线程 2 获取: name 线程 3 获取 age
    • 如果因为异步并发,导致混乱本来读的是 name 结果读到了 age
    • 我们允许多个任务同时进去! 但是读操作需要同步返回,所以我们选择:同步函数(读读并发)
  • 我们再来看看写操作,在写操作的时候对 key 进行了 copy,关于此处的解释,插入一段来自参考文献的引用:

函数调用者可以自由传递一个 NSMutableStringkey,并且能够在函数返回后修改它。因此我们必须对传入的字符串使用 copy 操作以确保函数能够正确地工作。如果传入的字符串不是可变的(也就是正常的 NSString 类型),调用 copy 基本上是个空操作。

  • 这里我们选择 dispatch_barrierasync,为什么是栅栏函数而不是异步函数或者同步函数,下面分析:

    • 栅栏函数任务:之前所有的任务执行完毕,并且在它后面的任务开始之前,期间不会有其他的任务执行,这样比较好的促使写操作一个接一个写(写写互斥),不会乱!
    • 为什么不是异步函数? 应该很容易分析,毕竟会产生混乱!
    • 为什么不用同步函数?如果读写都操作了,那么用同步函数,就有可能存在:我写需要等待读操作回来才能执行,显然这里是不合理!