OC底层知识点之-多线程(五)补充篇:锁

1,623 阅读20分钟

系列文章:OC底层原理系列OC基础知识系列

之前我们对多线程GCD有了比较深入的了解,在使用GCD的时候,我们为了保证线程安全会使用锁,这篇我们就来探究一下锁的使用原理

锁 总览

锁的性能

之前有张很有名的锁的性能图,在此借鉴一下 从上图我们可以知道锁的性能从低到高依次为:OSSpinLock(自旋锁) -> dispatch_semaphone(信号量) -> pthread_mutex(互斥锁) -> NSLock(互斥锁) -> NSCondition(条件锁) -> pthread_mutex(recursive 互斥递归锁) -> NSRecursiveLock(递归锁) -> NSConditionLock(条件锁) -> synchronized(互斥锁)。

锁的归类

OC基础知识点之-多线程(一)多线程基础我们对锁进行了简单介绍,将锁分为两大类:互斥锁和自旋锁。下面我们将上图的锁进行细分

互斥锁

定义:互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源(例如全局变量)进行读写的机制,该目的是通过将代码切成一个个临界区而达成,互斥锁属于sleep-waiting(睡眠等待)类型的锁。 互斥锁:

  • NSLock
  • pthread_mutex
  • @synchronized

自旋锁

定义:在自旋锁中,线程会反复检查变量是否可用。由于线程这个过程中一致保持执行,所以是一种忙等待。 一旦获取了自旋锁,线程就会一直保持该锁,直到显式释放自旋锁。自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。对于iOS属性的修饰符atomic,自带一把自旋锁。自旋锁的优点在于,因为自旋锁不会引起调用者睡眠,所以不会进行线程调度、cpu时间片轮转等耗时操作。所有如果能在很短的时间内获得锁自旋锁效率远高于互斥锁。如果不能在很短的时间内获得锁,这无疑会使CPU效率降低自旋锁不能实现递归调用 自旋锁:

  • OSSpinLock
  • atomic

条件锁

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

  • NSCondition
  • NSConditionLock

递归锁

定义:递归锁就是同一个线程可以加锁N次不会引发死锁。递归锁是特殊的互斥锁,即是带有递归性质的互斥锁

  • pthread_mutex(recursive)
  • NSRecursiveLock

信号量

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

  • dispatch_semaphore

读写锁

定义:读写锁实际是一种特殊的自旋锁。将对共享资源访问分成读者和写者读者只对共享资源进行读访问写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性

  • 1.一个读写锁同时只能一个写者或者多个读者与CPU数相关),但不能既有读者又有写者,在读写锁保持期间也是抢占失效的。
  • 2.如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁否则它必须自旋在那里, 直到没有任何写者或读者。如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁
  • 3.一次只有一个线程可以占有写模式读写锁, 但是可以有多个线程同时占有读模式的读写锁。正是因为这个特性,当读写锁是写加锁状态时, 在这个锁被解锁之前, 所有试图对这个锁加锁的线程都会被阻塞。当读写锁读加锁状态时, 所有试图以读模式对它进行加锁的线程都可以得到访问权, 但是如果线程希望以写模式对此锁进行加锁, 它必须直到所有的线程释放锁
  • 4.当读写锁处于读模式锁住状态时, 如果有另外线程试图以写模式加锁, 读写锁通常会阻塞,随后的读模式锁请求, 这样可以避免读模式锁⻓期占用, 而等待的写模式锁请求⻓期阻塞读写锁适合于对数据结构的读次数比写次数多得多的情况. 因为读模式锁定时可以共享, 以写模式锁住时意味着独占, 所以读写锁又叫共享-独占锁

条件锁

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

锁 总结

上面是对锁进行细分了,锁的大类就两种:互斥锁自旋锁

锁的探究

OSSpinLock

OSSpinLock是自旋锁,上面使用OSSpinLock编译会报警告,在iOS10已经废弃了OSSpinLock大家也就已经不再使用,因为它在一些场景下已经不安全了,这个可以参考YY大神的不再安全的 OSSpinLock。我们在官网搜索OSSpinLock,会搜索到os_unfair_lock_lock,点击进入,在最后有这么几句话:

os_unfair_lock_lock (互斥锁)

通过上面官网可以知道os_unfair_lock_lock是苹果官方推荐替换OSSpinLock的方案。但是它在iOS10.0以上的系统才可以调用os_unfair_lock是一种互斥锁,它不会向自旋锁那样忙等,而是等待线程会休眠

typedef struct os_unfair_lock_s {
	uint32_t _os_unfair_lock_opaque;
} os_unfair_lock, *os_unfair_lock_t;
void os_unfair_lock_lock(os_unfair_lock_t lock);
void os_unfair_lock_unlock(os_unfair_lock_t lock);

dispatch_semaphone 信号量

信号量简介

  • 信号量是基于计数器的一种多线程同步机制,用来管理对资源并发访问
  • 信号量就是一种可用来控制访问资源的数量的标识,设定了一个信号量,在线程访问之前加上信号量的处理,则可告知系统按照我们指定的信号量数量执行多个线程

dispatch_semaphone 相关函数

  • 1.dispatch_semaphore_t dispatch_semaphore_create(long value); 说明:创建信号量,参数:信号量的初值,如果小于0则会返回NULL
  • 2.long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout); 说明:1.等待降低信号量,接收一个信号和时间值(多为DISPATCH_TIME_FOREVER)。2.若信号的信号量为0,则会阻塞当前线程,直到信号量大于0或者经过输入的时间值。3.若信号量大于0,则会使信号量减1并返回,程序继续住下执行
  • 3.long dispatch_semaphore_signal(dispatch_semaphore_t dsema); 说明:提高信号量, 使信号量加1并返回 在dispatch_semaphore_waitdispatch_semaphore_signal这两个函数中间的执行代码,每次只会允许限定数量的线程进入,这样就有效的保证了在多线程环境下,只能有限定数量的线程进入

可用于处理在多个线程访问共有资源时候,会因为多线程的特性而引发数据出错的问题。 信号量底层实现在之前的文章中介绍过了,这里不再介绍。传送门:OC底层知识点之-多线程(四)GCD下篇

NSLock

NSLock:是Foundation框架中以对象形式暴露给开发者的一种锁,(Foundation框架同时提供了NSConditionLockNSRecursiveLockNSCondition)。NSLock定义如下: 说明:

  • 1.tryLock和lock方法都会请求加锁,唯一不同的是trylock在没有获得锁的情况下可以继续做一些任务和处理
  • 2.lockBeforeDate方法就是在limit时间点之前获得锁,没有拿到就返回NO,拿到返回YES。

底层实现

通过断点我们可以知道,NSLock底层实现在Foundation框架里,并未开源。但是可以通过Swift的开源框架Founfation来分析一下NSLock的底层实现,其原理和OC大致相同 通过上面的源码可以看出,底层是通过pthread_mutex互斥锁实现的,在init方法里对锁进行了初始化

使用扩展

看如下代码,运行会有什么问题

我们看到在未加锁之前,其中current value = 10、9有很多条,导致数据混乱,主要原因就是多线程导致的。

我们加锁,如下:

通过上图我们知道运行结果只有10,因为这个过程出现一直等待,这主要是因为嵌套使用递归锁,在block内部加锁,没有解锁,那么block内部会处于休眠状态,由于锁的释放是在block调用之后,而for循环则一直在进行,就会导致一直在加lock,之后减锁,但是加锁永远比减锁多,直到最后一次彻底解锁打印一次10

可以用下面方法解决上面的问题

  • 1.移动加锁位置
  • 2.使用@synchronized
  • 3.递归锁:NSRecursiveLock

应用

NSLock在AFNetworkingAFURLSessionManager中、AFHTTPResponseSrializer有所应用,应用如下:

pthread_mutex

pthread_mutex简介

pthread_mutex:是互斥锁,当锁被占用,其它线程申请锁时,不会一直忙等待,而是阻塞线程并睡眠

使用

// 导入头文件
#import <pthread.h>
// 全局声明互斥锁
pthread_mutex_t _lock;
// 初始化互斥锁
pthread_mutex_init(&_lock, NULL);
// 加锁
pthread_mutex_lock(&_lock);
// 这里做需要线程安全操作
// 解锁 
pthread_mutex_unlock(&_lock);
// 释放锁
pthread_mutex_destroy(&_lock);

上面NSLock我们看到其底层的锁用的就是pthread_mutex

底层实现目前没有找到,应该是没有开源。如果后面找到相关底层实现再做补充。

应用

pthread_mutex在YYCache中的YYMemoryCache中有所应用

NSRecursiveLock 递归锁

这部分实现在Foundation内,源码未公开,但是通过Swift的Foundation开源看下 我们看到NSRecursiveLock底层也是对pthread_mutex的封装

我们对比NSLock和NSRecursiveLock,其底层实现几乎一模一样区别在于init时NSRecursiveLock有一个标识PTHREAD_MUTEX_RECURSIVE,而NSLock是默认的

递归锁主要是用于解决一种嵌套形式,其中循环嵌套居多

应用

NSRecursiveLock在YYKit中YYWebImageOperation.m中的dealloc方法有用到:(删除了无关代码报错)

@synchronized(互斥递归锁)

开启汇编调试,运行代码 看下@synchronized在执行过程

我们看到@synchronized在底层会走objc_sync_enterobjc_sync_exit方法。

我们通过clang,查看C++底层实现 通过对objc_sync_enter方法符号断点,查看底层所在的源码库,通过断点发现在objc源码

objc_sync_enter 分析

我们打开objc源码,搜索objc_sync_enter

  • 1.如果obj存在,则通过id2data方法获取相应的syncData,对threadCountlockCount进行递增操作。
  • 2.如果obj不存在,则调用objc_sync_nil,通过符号断点得知,这个方法里面是什么都没有做,直接return

objc_sync_exit 分析

在objc源码里搜索objc_sync_exit

  • 1.如果obj存在,则调用id2data方法获取对应的SyncData,对threadCountlockCount进行递减操作。
  • 2.如果obj为nil,什么也不做,直接return

通过上面两个实现逻辑的对比,发现它们有一个共同点,在obj存在时,都会通过id2data方法,获取SyncData,下面我们看下SyncData

SyncData

进入SyncData的定义,是一个结构体,主要用来表示一个线程data类似于链表结构有next指向,且封装recursive_mutex_t属性,可以确认@synchronized确实是一个递归互斥锁

id2data 分析

上面我们知道SyncData是通过id2data获得,而且加锁和解锁都是复用该方法。我们看下id2data源码:

  • 1.首先在tls线程缓存中查找
    • tls_get_direct方法中以线程为key,通过KVC的方式获取与之绑定的SyncData,即线程data。其中的tls(),表示本地局部的线程缓存
    • 判断获取的data是否存在,以及判断data中是否能找到对应的object
    • 如果都找到了,在tls_get_direct方法中以KVC的方式获取lockCount,用来记录对象被锁了几次(即锁的嵌套次数)。
    • 如果data中的threadCount 小于等于0,或者 lockCount 小于等于0时,则直接崩溃
    • 通过传入的why,判断是操作类型
      • 如果是ACQUIRE,表示加锁,则进行lockCount++,并保存到tls缓存。
      • 如果是RELEASE,表示释放,则进行lockCount--,并保存到tls缓存。如果lockCount 等于 0,从tls中移除线程data
      • 如果是CHECK,则什么也不做。
  • 2.如果tls中没有,则在cache缓存中查找
    • 通过fetch_cache方法查找cache缓存中是否有线程
    • 如果有,则遍历cache总表读取出线程对应的SyncCacheItem
    • SyncCacheItem中取出data,然后后续步骤与tls的匹配是一致
  • 3.如果cache中也没有,即第一次进来,则创建SyncData,并存储到相应缓存中
    • 如果在cache中找到线程,且与object相等,则进行赋值、以及threadCount++
    • 如果在cache中没有找到,则threadCount等于1 所以在id2data方法中,主要分为三种情况:
  • 1.第一次进来,没有锁
    • threadCount = 1
    • lockCount = 1
    • 存储到tls
  • 2.不是第一次进来,且是同一个线程
    • tls中有数据,则lockCount++
    • 存储到tls
  • 3.不是第一次进来,且是不同线程
    • 全局线程空间进行查找线程
    • threadCount++
    • lockCount++
    • 存储到cache

tls和cache 表结构分析

针对tls和cache缓存,底层的表结构如下: 我们对上图进行解释:

  • 1.哈希表结构中通过SyncList结构来组装多线程的情况
  • 2.SyncData通过链表的形式组装,记录当前可重入的情况
  • 3.下层通过tls线程缓存cache缓存来进行处理
  • 3.底层主要有两个东西:lockCountthreadCount,解决递归互斥锁,解决嵌套可重入

使用 @synchronized 注意问题

  • 1.看如下代码 运行我们发现,崩溃了

崩溃的主要原因是mArray在某一瞬间变成了nil,从@synchronized底层流程知道,如果加锁的对象成了nil,是锁不住的,相当于下面这种情况,block内部不停的retain、release,会在某一瞬间上一个还未release下一个已经准备release,这样会导致野指针的产生

下面我们验证下,上面代码,做如下图操作,来查看是否存在僵尸对象 再运行代码,结果如下 我们发现确实出现过度释放,出现野指针。所以我们一般使用@synchronized (self),主要是因为mArray的持有者是self

  • 过度释放:对象已经不存在了,还进行release,即多次release
  • 野指针:指针指向的对象已经被回收掉了,这个指针就叫做野指针.

总结

  • 1.@synchronized在底层封装的是一把递归锁,所以这个锁是递归互斥锁
  • 2.@synchronized可重入,即可嵌套,主要是由于lockCountthreadCount的搭配
  • 3.@synchronized使用链表的原因是链表方便下一个data的插入
  • 4.但是由于底层中链表查询缓存的查找以及递归,是非常耗内存以及性能的,导致性能低,所以在前文中,该锁的排名在最后
  • 5.但是目前该锁的使用频率仍然很,主要是因为方便简单,且不用解锁
  • 6.不能使用非OC对象作为加锁对象,因为其object的参数为id
  • 7.@synchronized (self)这种适用于嵌套次数较少的场景。这里锁住的对象也并不永远是self,这里需要注意
  • 8.如果锁嵌套次数较多,即锁self过多,会导致底层的查找非常耗时,因为其底层是链表进行查找,所以性能较差,此时可以使用NSLock、信号量

应用

目前@synchronized在实际项目中应用分别是SDWebImageUIView+WebCacheOperationdownload方法AFNetworkingisNetworkActivityOccurring属性的getter方法(这是一部分)

NSCondition

NSCondition定义:是一个条件锁,在日常开发中使用较少,与信号量有点相似线程1需要满足条件才会往下走,否则会堵塞等待直到条件满足。经典模型是生产消费者模型

NSCondition的对象实际上作为一个锁和一个线程检查器

  • 1.主要为了当检测条件保护数据源执行条件引发的任务
  • 2.线程检查器主要是根据条件决定是否继续运行线程,即线程是否被阻塞

用法

//初始化
NSCondition *condition = [[NSCondition alloc] init]

//一般用于多线程同时访问、修改同一个数据源,保证在同一 时间内数据源只被访问、修改一次,其他线程的命令需要在lock 外等待,只到 unlock ,才可访问
[condition lock];

//与lock 同时使用
[condition unlock];

//让当前线程处于等待状态
[condition wait];

//CPU发信号告诉线程不用在等待,可以继续执行
[condition signal];

底层分析

通过Swift的Foundation源码查看NSCondition的底层实现

底层也是下层对pthread_mutex的封装

  • 1.NSCondition是对mutexcond的一种封装(cond就是用于访问和操作特定类型数据的指针
  • 2.wait操作会阻塞线程,使其进入休眠状态直至超时
  • 3.signal操作是唤醒一个正在休眠等待的线程
  • 4.broadcast唤醒所有正在等待的线程

NSConditionLock

NSConditionLock定义:条件锁,一旦一个线程获得锁,其他线程一定等待。其本质就是NSCondition + Lock

相比NSConditionLock而言,NSCondition使用比较繁琐,所以推荐使用NSConditionLock

使用方法

其使用如下

//初始化
NSConditionLock *conditionLock = [[NSConditionLock alloc] initWithCondition:2];

//表示conditionLock期待获得锁,如果没有其他线程获得锁(不需要判断内部的condition) 那它能执行此行以下代码,如果已经有其他线程获得锁(可能是条件锁,或者无条件锁),则等待,直至其他线程解锁
[conditionLock lock]; 

//表示如果没有其他线程获得该锁,但是该锁内部的condition不等于A条件,它依然不能获得锁,仍然等待。如果内部的condition等于A条件,并且 没有其他线程获得该锁,则进入代码区,同时设置它获得该锁,其他任何线程都将等待它代码的完成,直至它解锁。
[conditionLock lockWhenCondition:A条件]; 

//表示释放锁,同时把内部的condition设置为A条件
[conditionLock unlockWithCondition:A条件]; 

// 表示如果被锁定(没获得 锁),并超过该时间则不再阻塞线程。但是注意:返回的值是NO,它没有改变锁的状态,这个函数的目的在于可以实现两种状态下的处理
return = [conditionLock lockWhenCondition:A条件 beforeDate:A时间];

底层实现

通过底层源码可以知道

  • 1.NSConditionLockNSCondition的封装
  • 2.NSConditionLock可以设置锁条件,即condition值,而NSCondition只是信号的通知

代码验证

验证NSConditionLock

如下代码:其中self.myLock是NSCondition,我们在conditionLock部分打上断点,运行(需要在真机上运行,模拟器上运行的是Intel指令,而真机上运行的是arm指令)运行到断点,我们开启汇编模式,如下图所示:

其中x0是接收者self,x1是cmd

下面我们通过register read读取寄存器内容 我们在objc_msgSend处加断点,让代码运行到断点处,在此再次读寄存器x0(register read x0)

我们看到此时执行到了[conditionLock lockWhenCondition:2]; 读x1(register read x1),然后发现无法读取,这是因为x1存储的是sel,并非对象类型,可以通过强转读取SEL

下面加符号断点-[NSConditionLock lockWhenCondition:]-[NSConditionLock lockWhenCondition:beforeDate:],然后查看bl、b等跳转

  • 读取寄存器 x0、x2是当前的lockWhenCondition:beforeDate:的参数,实际走的是[conditionLock lockWhenCondition:1]; 通过汇编可以知道,x2移动到了x21 到这里后,我们调试的目的主要有两个:NSCondition + lock以及condition与value的值匹配

NSCondition + lock验证

继续执行,在bl处断住

读取寄存器x0 ,此时是跳转至NSCondition 读取x1,即po (SEL)0x00000001c746e484

所以可以验证NSConditionLock在底层调用的是NSCondition的lock方法

condition与value的值匹配

继续执行,跳到ldr 通过上图可以知道编译器通过一个方法,拿到了condition2的属性值,存储到x8

  • register read x19
  • po (SEL)0x0000000282ca5790 -- x19的地址+0x10

register read x8,此时的x8中存储的是2 cmp x8, x21,意思是将x8和x21匹配,即2和1匹配,并不匹配 第二次来到cmp x8, x21,此时的x8、x21 是匹配的 ,即执行[conditionLock lockWhenCondition:2];

总结

通过上面的图和前面的解释,我们来总结NSConditionLock

  • 1.在初始化是,我们对NSConditionLock设置了满足条件condition等于2
  • 2.线程1(打印任务)[conditionLock lockWhenCondition:1]字面意思:conditionLock加锁条件是当condition等于1,我们上面初始化时设置的条件为2,所以不满足,此时会进入waiting状态进入waiting状态前释放该锁
  • 3.线程2(打印任务)[conditionLock lockWhenCondition:2]此时的条件是满足的,所以对conditionLock进行加锁,然后执行打印线程2任务,执行完成进行解锁,将满足条件置为condition等于1,接着发送boradcast信号
  • 4.线程1会收到boradcast信号,再次查看是否满足条件,此时满足加锁执行打印任务,打印任务完成解锁
  • 5.线程3没有条件限制,所以最先执行 上面解释了,打印结果就是3->2->1 符合分析结果

其他锁

pthread_rwlock 读写锁

读写锁在上面进行比较详细的介绍了,这里只补充下用法

用法

//加读锁
pthread_rwlock_rdlock(&rwlock);
//解锁
pthread_rwlock_unlock(&rwlock);
//加写锁
pthread_rwlock_wrlock(&rwlock);
//解锁
pthread_rwlock_unlock(&rwlock);

pthread_mutex(recursive)

pthread_mutex锁也支持递归,只需要设置PTHREAD_MUTEX_RECURSIVE即可

用法

pthread_mutex_t lock;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&lock, &attr);
pthread_mutexattr_destroy(&attr);
pthread_mutex_lock(&lock);
pthread_mutex_unlock(&lock);

atomic(原子锁)

atomic适用于OC中属性修饰符,其自带一把自旋锁,但是这个一般基本不使用,都是使用的nonatomic

底层简单探究

我们知道setter方法会根据修饰符调用不同方法,其中最后统一调用reallySetProperty方法,其中就有atomic和非atomic的操作

从源码中可以看出,对于atomic修饰的属性,进行了spinlock_t加锁处理,spinlock_t在底层是通过os_unfair_lock实现的加锁 getter方法中对atomic的处理,同setter是大致相同

总结

上面对各种锁做了分析,下面我们对上面的内容做个总结:

  • 1.OSSpinLock被废弃,苹果用os_unfair_lock进行代替
  • 2.NSLockNSRecursiveLock底层都是对pthread_mutex的封装
  • 3.NSConditionNSConditionLock条件锁,底层都是对pthread_mutex的封装,当满足某一个条件时才能进行操作信号量dispatch_semaphore类似
  • 4.@synchronized嵌套次数多时,性能低,主要是因为嵌套导致其底层在链表查询缓存查找递归消耗内存浪费大量时间导致的,但由于简单好用,在少嵌套场景使用频率很高

写到最后

写的内容比较多,由于本人能力有限,有些地方可能解释的有问题,请各位能够指出,同时对锁有关的疑问,欢迎大家留言。希望大家能够相互交流、探索,一起进步!