🔐顾名思义。。锁上了表示就进不来,解锁后你才可以进来。。在开发中也是如此,在多线程开发中,就经常会使用到锁机制。锁是一种同步机制,用于在多线程的环境中对资源的访问限制,防止多个线程在同一时间操作资源.
不同的锁的性能与实现也是不一样的,我们就来研究下在iOS中的几种不同的锁~~
我们先来看下这个图
这个是Y神在对锁的性能测试后得到的一个结论, 暂且不讨论性能,我们按从上到下来看下这些锁都是怎么样实现的。
OSSpinLock(自旋锁)
SpinLock又称自旋锁,线程通过busy-wait-loop
的方式来获取锁,任时刻只有一个线程能够获得锁,其他线程忙等直到获得锁。其实现是通过标志位、 test_and_set
指令执行原子性和while循坏对资源进行锁操作
缺点:
对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环(也就是忙等)在那里看是否该自旋锁的保持者已经释放了锁,这也就是自旋锁比互斥锁性能好的原因. 但是假设是时间较长的I/O操作,就会占用大量的CPU时间,从而使锁的效率降低,所以自旋锁比较适用锁使用者保持锁时间比较短的情况.
自旋锁不支持递归,如果递归调用的话会造成死锁. 所以递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁
自旋锁使用起来比较简单:
1 | OSSpinLock oslock = OS_SPINLOCK_INIT; |
但是自旋锁是有一个比较严重的bug,假设一个低优先级的线程获得了锁并访问了资源,这时一个高优先级的线程也尝试获得这个锁,此时它会处于busy-wait
忙等状态从而占用大量 CPU 时间,此时低优先级线程无法与高优先级线程争夺 CPU
时间,从而导致任务不能完成无法释放 lock
, 直到超时被操作系统抢占. 这就造成了优先级的反转.
Dispatch_semaphore (信号量)
在libdispatch源码中,我们可以在semaphore.h
文件发现其提供的API也是十分的简单,只有三个函数
1 | dispatch_semaphore_t dispatch_semaphore_create(long value); // 创建一个信号 |
dispatch_semaphore_signal
发送一个信号,会让信号总量加1,dispatch_semaphore_wait
用于等待信号,让信号总量减1,当信号总量少于0的时候就会一直等待,否则就可以正常的执行,根据这样的原理,我们便可以快速的创建一个并发控制来同步任务和有限资源访问控制
1 | long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout) { |
去掉了中间的在不同架构下的优化部分,基本上就是这样
#define dispatch_atomic_dec(p) __sync_sub_and_fetch((p), 1)
这个宏调用的是__sync_sub_and_fetch
, 这是个GCC的内置函数,它实现了减法的原子性操作, 将dsema_value
的值减一,并把新的值赋给dsema_value
,如果信号量的值减1之后大于等于0,表示有资源可用,那么直接返回0, 否则将会都到_dispatch_semaphore_wait_slow
函数
1 | static long _dispatch_semaphore_wait_slow(dispatch_semaphore_t dsema, dispatch_time_t timeout) { |
进入这个方法,timeout
有三种case:
DISPATCH _TIME _NOW: 不等待, 如果
dsema_value
小于0,那么将其+1, 然后返回KERN_OPERATION_TIMED_OUT
; 如果大于等于0,表示有资源可用,那么将会进入到again;DISPATCH _TIME _FOREVER: 无线等待,
semaphore_wait
将无限等待到信号值等于于KERN_ABORTED
;
等信号来了,跳转到again;Default: 计时等待,
semaphore_timedwait(dsema->dsema_port, _timeout)
直到timeout
或者KERN_ABORTED
为止
1 | long dispatch_semaphore_signal(dispatch_semaphore_t dsema) { |
发送信号与等待信号的函数差不多, dispatch_atomic_inc
, #define dispatch_atomic_inc(p) __sync_add_and_fetch((p), 1)
这也是一个GCC内置函数, 若信号值加1后大于零,表示有资源可用,那么直接返回0, 否则将会走到_dispatch_semaphore_signal_slow
中
1 | static long _dispatch_semaphore_signal_slow(dispatch_semaphore_t dsema) { |
如果信号量之前的值小于0,那么信号量加1;其核心是:semaphore_signal(dsema->dsema_port)
利用系统的信号量库实现发送信号量的功能,最后返回1, 表示其当前有(一个或多个)线程等待其处理的信号量,并且该函数唤醒了一个等待的线程(当线程有优先级时,唤醒优先级最高的线程;否则随机唤醒).
信号量使用起来也是比较简单的:
1 | dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); |
pthread_mutex
pthread
表示 POSIX
标准的线程库,该标准定义了创建和操纵线程的一整套API, pthread_mutex
表示的是互斥锁
POSIX下抽象了一个锁类型的结构:ptread _mutex _t
我们可以看下在iOS中,是怎么定义的。
1 | typedef __darwin_pthread_mutex_t pthread_mutex_t; |
ptread _mutex _t
其实就是一个_opaque_pthread_mutex_t
结构体, 其原理与信号量类似,不使用忙等,而是阻塞线程并睡眠,需要进行上下文的切换. PS:一个线程只能申请一次锁,只有在获得锁的情况下才能释放锁,多次申请锁或释放未获得的锁都会导致崩溃.
因为ptread _mutex _t
支持多种类型, 在申请加锁时,需要对锁的类型进行判断, 虽然实现跟信号量差不多,但是性能却略低一些.
我们来看下其提供的API
1 | // 创建锁 可以传一个属性为 `pthread_mutexattr_t` 类型的参数 |
来看下使用方法:
1 | pthread_mutexattr_t attr; // 定义一个锁属性对象 |
NSLock
由于GNU的源码是开源的,也跟Cocoa的实现差不多,所以我们打算使用 GNU源码 来分析NSLock
我们先来看看NSLock
的方法:
1 | + (void) initialize { |
在NSLock
类初始化的时候,初始化了三种属性,attr_normal
、attr_reporting
、attr_recursive
,对应的类型分别是PTHREAD_MUTEX_NORMAL
、PTHREAD_MUTEX_ERRORCHECK
、PTHREAD_MUTEX_RECURSIVE
,
当执行NSLock
的init
方法时:
1 | /* Use an error-checking lock. This is marginally slower, but lets us throw |
初始化的是属性为attr_reporting
的锁,也就是error-checking
锁, 这样性能相比于attr_normal
的锁,会稍稍降低,因为它会在发生提示时牺牲一定的性能来换取提示错误信息
它提供了两个方法:
1 |
|
sched_yield
函数可以使用另一个级别等于或高于当前线程的线程先运行.如果没有符合条件的线程,那么这个函数将会立刻返回然后继续执行当前线程的程序.
NSLcok
使用起来也是比较简单的,因为遵循了<NSLocking>
协议,协议中仅有两个方法,- (void)lock;``- (void)unlock;
所以NSLock
对象可以直接调用这俩个方法
1 | NSLock *lock = [[NSLock alloc] init]; |
NSCondition(条件变量)
使用NSCondition
(条件变量)对象来控制进程的同步,即可实现生产者消费者问题。其内部是通过pthread_cond_t
来实现. NSCondition
是利用线程间共享的全局变量进行同步的一种机制,NSCondition
也实现了NSLocking
协议,因此也可以调用lock
、 unlock
来实现线程的同步, 为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起.
生产者-消费者模式
首先要创建公用的NSCondition实例。然后:
- 消费者取得锁,取产品,如果没有,则wait,这时会释放锁,直到有线程唤醒它去消费产品;
- 生产者制造产品,首先也是要取得锁,然后生产,再发signal,这样可唤醒wait的消费者。
NSCondition
提供了这么些API:
1 | - (void)wait; // 让当前线程处于等待状态 |
1 | // 唤醒在此NSCondition对象上等待的所有线程 |
下面来看下使用方法:
1 | // 创建消费者 |
当condition
进入到判断条件中,products.count == 0
时,condition
调用wait
使当前线程处于等待状态;其他线程开始访问products
,当NSObject
创建完成并加入到products
时,cpu发出single
的信号时,处于等待的线程被唤醒,开始执行[products removeObjectAtIndex:0]
pthread_mutex (recursive)
这个就是上文说的pthread_mutex
, 但它的属性类型是PTHREAD_MUTEX_RECURSIVE
, 也就是支持递归,允许一个线程递归的申请锁,而不会造成死锁. 这与等会说到的NSRecursiveLock
实现类似
NSRecursiveLock(递归锁)
我们来看下它的实现,
1 | + (void)initialize { |
它在类初始化的时候,调用的是NSLock
类的初始化方法,也就是初始化了三种attributes
, 然后在init
的时候,初始化了属性为attr_recursive
的锁,也就是递归锁,也就是说它支持递归和循环, NSRecursiveLock
会记录上锁和解锁的次数,当二者平衡的时候,才会释放锁,其它线程才可以上锁成功, 所以不会造成死锁.
因为内部判断了锁的类型,所以性能会比pthread_mutex(recursive)
要低一些
NSConditionLock (条件锁)
其实条件锁也是一个生产者-消费者模式,内部通过NSCondition
来实现. NSConditionLock
会持有一个 NSCondition
对象和 _condition_value
属性,在初始化时就会对这个属性进行赋值 ,默认的init
, value
则为0
, 我们来看下它的方法实现:
1 |
|
其中很重要的一个属性便是condition
,外部传入的condition
与内部相同才会获取到lock
对象,反之阻塞当前线程,直到condition
相同, 这其实是一个消费者方法
1 | // 消费者方法 |
但是解锁的时候并不是condition
相同才能解锁, 而是给_condition_value
赋值并通过broadcast
方法唤醒在此NSCondition
对象上等待的所有线程, 最后解锁
我们来看下使用方法:
1 |
|
输出如下:
1 | 2017-09-26 00:01:03.235 LuaTest[72076:8074748] 线程2 |
上面代码先输出了 线程 2
,因为线程 1
的加锁条件不满足,初始化时候的 condition
参数为 0,而加锁条件是 condition
为 1,所以加锁失败。locakWhenCondition
与 lock 方法类似,加锁失败会阻塞线程,所以线程 1
会被阻塞着,而 tryLockWhenCondition
方法就算条件不满足,也会返回 NO,不会阻塞当前线程。回到上面的代码,线程 2
执行了 [lock unlockWithCondition:2]
所以 Condition
被修改成了 2
。而线程 3
的加锁条件是 Condition
为 2
, 所以线程 3
才能加锁成功,线程 3
执行了 [lock unlock]
; 解锁成功且不改变 Condition
值。线程 4
的条件也是 2
,所以也加锁成功,解锁时将 Condition
改成 1
。这个时候线程 1
终于可以加锁成功,解除了阻塞。 假如线程4``unlockWithCondition
解锁条件为非1
, 内部conditon
值不一致, 那么线程1
将会永久阻塞.
从上面可以得出,NSConditionLock 还可以实现任务之间的依赖。
@Synchronized
先来看下用法,
1 | @synchronized (obj) { |
也就是只需要传入一个对象,就可以使代码块实现同步
我们在Xcode
中选择Product -> Perform Action -> Assemble 'main.m'
, 我们便能看到汇编代码…
我们会发现两个比较陌生的方法调用 _objc_sync_enter
和 _objc_sync_exit
emmmmmm ?
… 我们只调用了synchronized
… 莫非就是这个函数的底层实现? 我们到objc源码找找, 在objc-sync.mm
中找到了这两个函数的实现
1 | // Begin synchronizing on 'obj'. |
方法的注释已经很清楚了,这个方法的实现用的是递归锁,首先判断obj
是否存在,如果存在则通过obj
创建一个SyncData
对象,然后调用lock
,否则则直接调用objc_sync_nil ()
方法,这是一个空方法,啥都没干,也就是说如果直接调用@synchronized(nil)
的话,其实什么都没有做,也不会创建锁.
1 | typedef struct SyncData { |
SyncData
包括nextData
、object
、threadCount (线程数)
、mutex (递归锁)
我们来看下方法是如何通过obj
获取到data
的,
1 | /* |
发现定义了sDataLists
,一个SyncList
结构体数组,
1 | static unsigned int indexForPointer(const void *p) { |
通过哈希算法将传入obj
映射到数组上的一个下标, 是将对象指针在内存的地址,映射到另一个内存空间来存放SyncList
,这样能够保证不会发生数组越界,当调用objc_sync_enter
时,通过obj
内存地址的哈希值找到 SyncData
,并调用lock
1 | // End synchronizing on 'obj'. |
objc_sync_exit
方法也差不多,通过obj
内存地址的哈希值找到 SyncData
,并调用tryUnlock
,如果返回true,则表示解锁成功,否则是失败
NSDistributedLock (分布式锁)
NSDistributedLock
与其他锁不太一样, 它没有实现NSLocking
协议, 它底层是基于文件系统实现的互斥锁,会自动创建用于标识的临时文件或文件夹,执行完后自动清除临时文件或文件夹;可以在多个进程或多个程序之间需要构建互斥的场景中使用
1 | + (void)initialize { |
我们从它的初始化方法便能够看出内部是基于NSFileManager
文件系统实现的,由于其没有实现NSLocking
协议,所以没有会阻塞线程的lock
方法,取而代之的是非阻塞的tryLock
方法, NSDistributedLock
只有在锁持有者显式地释放后才会被释放. 如果你的程序在一个NSDistributedLock
的时候崩溃了,其他客户端无法访问该受保护的资源。在这种情况下,你可以使用breadLock
方法来打破现存的锁以便你可以获取它, 但是通常应该避免打破锁,除非你确定拥有进程已经死亡并不可能再释放该锁. 和其他类型的锁一样,当你使用NSDistributedLock
对象时,你也可以通过调用unlock
方法来释放它
PS:花了快一星期零零碎碎的时间,研究了一下这些🔐,发现收货良多,从🔐的使用到实现都有了更进一步的认识.以及对锁的性能也有了进一步的理解.
最后更新: 2023年03月25日 22:39:55
本文链接: http://aeronxie.github.io/post/ad4ae88b.html
版权声明: 本作品采用 CC BY-NC-SA 4.0 许可协议进行许可,转载请注明出处!