iOS 底层探索篇 —— 锁
1. 锁的简介
我们在使用多线程的时候多个线程可能会访问同一块资源
,这样就很容易引发数据错乱和数据安全
等问题。而合理的运用锁可以保证每次只有一个线程访问这一块资源
,这样就可以解决多线程引发的数据问题。
2. ios 8大锁
将各种锁循环加锁开锁100000次,计算出耗费的时间来看一下各种锁的性能
。从输出可以看到各种锁的性能差别。可以看出,锁的性能从高到底依次是:OSSpinLock(自旋锁) -> dispatch_semaphone(信号量) -> pthread_mutex(互斥锁) -> NSLock(互斥锁) -> NSCondition(条件锁) -> pthread_mutex(recursive 互斥递归锁) -> NSRecursiveLock(递归锁) -> synchronized(互斥锁) -> NSConditionLock(条件锁)
自旋锁
:线程反复检查锁变量是否可用。由于线程在这一过程中保持执行, 因此是一种忙等待
。一旦获取了自旋锁,线程会一直保持该锁
,直至显式释放自旋锁。 自旋锁避免了进程上下文的调度开销
,因此对于线程只会阻塞很短时间
的场合是有效的。
互斥锁
:是一种用于多线程编程中,防止两条线程同时对同一公共资源(比 如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区
而达成。这里属于互斥锁的有:
- NSLock
- pthread_mutex
- @synchronized
条件锁
:就是条件变量,当进程的某些资源要求不满足
时就进入休眠
,也就是锁住了。当资源被分配到
了,条件锁打开
,进程继续运行
- NSCondition
- NSConditionLock
递归锁
:就是同一个线程可以加锁N次
而不会引发死锁
- NSRecursiveLock
- pthread_mutex(recursive)
信号量(semaphore):
是一种更高级的同步机制,互斥锁可以说是 semaphore 在仅取值0/1时的特例
。信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。
- dispatch_semaphore
其实基本的锁就包括了三类 自旋锁
互斥锁
读写锁
,
其他的比如条件锁,递归锁,信号量都是上层的封装和实现
!
3. @synchronized
3.1 总体流程
在实际开发中,@synchronized用的最经常,范围也最广,所以先从@synchronized开始探索。
当开始多线程售票的时候,那么余票的数量就会因为多线程而发生数据错乱的问题。这个时候,就需要为其加上一把锁,这样就能保证数据的安全。
这时候就有了一些疑问。
@synchronized
里面的参数
应该传什么呢?这里为什么传self呢?是否可以传nil呢?@synchronized代码块
到底是什么呢?@synchronized
如何做到加锁以及递归可重入
的呢?@synchronized
是什么结构
呢?
先探索这些问题,那么就在main.m中添加@synchronized,然后用xcrun
生成main.cpp文件。
生成main.cpp文件后打开,看到@synchronized在c中的结构。
这里如果加锁成功的话,就不需要看catch里面的代码。结构体_SYNC_EXIT的代码可以放在外面。id _sync_obj = (id)appDelegateClassName;
就是传的参数。那么需要看的就只剩下objc_sync_enter
和_sync_exit
。_sync_exit是_SYNC_EXIT的析构函数,所以_sync_exit相当于objc_sync_exit。那么最终
@synchronized 相当于 objc_sync_enter 和 objc_sync_exit
。
在工程中下 objc_sync_enter 和 objc_sync_exit 断点,发现都在libobjc.A.dylib
里面。
接下来就去libobjc.A.dylib里面探索。
这里看到objc_sync_enter
,如果objc不存在会报出异常,也就是当如果传nil的话,就会报出异常。这里调用的objc_sync_nil也是没有做任何事情。BREAKPOINT_FUNCTION是一个封装,BREAKPOINT_FUNCTION( void objc_sync_nil(void))
相当于 void objc_sync_nil(void){};
也就是什么都没有实现。
看到objc_sync_exit
,这里如果obj不存在的话,那么也是不做处理。这里id2data传的第二个参数和objc_sync_enter不一样为RELEASE
,objc_sync_enter为ACQUIRE
。并且这里调用data->mutex.tryUnlock()
,而objc_sync_enter
则是data->mutex.lock()
。
接下来看SyncData的结构。看到里面有nextData
,大致可以猜出这里是单向链表结构
。这里的DisguisedPtr
在关联对象有看到过,是把对象封装成统一的数据结构。threadCount则是代表着线程数量,说明可以多线程访问。recursive_mutex_t
则是递归锁
,但是recursive_mutex_t不能多线程递归,否则就会有bug。
然后看到id2data
。这里做了一些变量的准备,然后判断data
是否存在。这里的SUPPORT_DIRECT_THREAD_KEYS
是是否支持tls(线程局部存储(Thread Local Storage,TLS) pthread_key_create(),是操作系统为线程单独提供的私有空间,通常只有有限的容量)
。然后再判断cache是否存在。这里说明了有两个地方存储这个data。
往下这里则是开辟了一个新的SyncData
,进行了内存对齐的操作,并对SyncData的值进行了初始化赋值
。
往下到了done
,就进行了解锁
,这里的锁是id2data这个方法的锁
,然后返回result
。
看完总体流程,然后在从头看。开头变量的赋值是从sDataLists里面获取的。sDataLists
是全局静态变量,具有全局性,是一个全局哈希表
。SyncList里面则有data和lock
。
可以在代码里面打下断点后输出来查看数据结构。
这里最外面是array包了一层,因为是哈希结构所以第一次插入需要计算下标,这里是在[43]。
所以sDataLists的数据结构为下图所示。外面是全局的表,里面则有着SyncList,SyncList里面有着data 指向SyncData。这里syncList的下标是通过id2data方法的参数object得到的,如果哈希冲突的话,那么又会创建一个SyncData,通过链表的形式解决哈希冲突的问题。
往下看发现data和cache的逻辑是相似的,只是存储的数据结构不一样。如果支持tls
,则走data,否则就走cache。所以这里只需要看一个地方就够了。
当第一次
进入这个方法时,data是为空的,那么就会走到下面创建SyncData
的地方,这里会创建SyncData并且将其加入到list
里面。 这里的两步操作 result->nextData = *listp; 和 *listp = result; 说明用的是头插法。
接下来到done里面。这里只有新的ACQUIRE
才可以进来,RELEASE
和 recursive ACQUIRE
应该在前面就被处理
了。
当第二次进来这个方法时,SyncData就不为空
了,就会走到if (data)里面。 这里的data时通过tls_get_direct
获取的,而SYNC_DATA_DIRECT_KEY
是一个宏定义是个固定的值,那么说明每次进来取得SyncData都是上一个存储的SyncData的data
。这里如果object是相同的的,则会拿到这个对象已经锁了多少次也就是 lockCount
,而lockCount
说明了可递归
。然后判断why
,如果是ACQUIRE
,则进行lockCount++
操作。如果是RELEASE
则进行lockCount--
操作,并且如果lockCount等于0
也就是解锁完成了,那么就会进行线程数的--操作
,这里也说明了可以在多线程使用
。如果是CHECK就什么都不做。
3.2 同一个对象不同线程
接下来实际操作一下同一个对象不同线程里面加锁的情况。
这里第一次进来创建syncData
,并且进行初始化赋值
,然后将result也就是刚创建的syncData存到tls
,并将lockCount设为1存入到tls
。
输出result看到threadCount
是为1
的。
第二次进来则进入到这里,进行threadCount的++
操作.然后将result也就是处理过的syncData
存到tls
,并将lockCount
设为1
存入到tls
。
输出result看到这里threadCount
变成了2
。
3.3 同一个对象同一线程
接下来实际操作一下同一个对象同一线程里面加锁的情况。
当第一次进来的时候,和之前的情况一下。需要看的是第二次进来的时候的情况。
这里就会进来判断data是否存在,然后根据传进来的why
进行相对应的操作,这里是进行++。
3.4 不同对象同一线程
接下来实际操作一下不同对象同一线程里面加锁的情况。
当第一次进来的时候,和之前的情况一下。需要看的是第二次进来的时候的情况。
发现第二次进来,会重新创建syncData并进行初始化赋值。
但是在done里面,会把创建的syncData存入到cache里面而不是tls。
3.5 不同对象不同线程
接下来实际操作一下不同对象不同线程里面加锁的情况。
当第一次进来的时候,和之前的情况一下。需要看的是第二次进来的时候的情况。
发现第二次进来,会重新创建syncData并进行初始化赋值。
输出sDataLists发现两个存在了不同的地方,说明没有哈希冲突。
这里修改一下StripedMap的值来模拟一下哈希冲突的情况。
这里输出后,发现第二次创建的syncData的nextData有值了,并且是第一个syncData,说明了这里使用的的拉链法解决的哈希冲突。
流程总结
- 首先在tls即线程缓存中查找。通过tls_get_direct去获取SyncData。
- 判断获取的data是否存在,并且判断data里面的object是否等于传进来的object
- 如果相等则获取lockCount,就是对象被锁的次数(即锁的嵌套次数)
- 如果data中的threadCount 小于等于0,或者 lockCount 小于等于0时,则直接崩溃
- 通过传入的why,判断是什么操作类型
- 如果是ACQUIRE,表示加锁,则进行lockCount++,并保存到tls缓存
- 如果是RELEASE,表示释放,则进行lockCount–,并保存到tls缓存。如果lockCount 等于 0,从tls中移除线程data,并且对threadCount进行–操作
- 如果是CHECK,则什么也不做
- 如果tls中没有,则在cache缓存中查找
- 通过fetch_cache方法查找cache缓存中是否有线程
- 如果有,则遍历cache总表,读取出线程对应的SyncCacheItem
- 从SyncCacheItem中取出data,然后后续步骤与tls里面的操作是一致的
- 如果listp存在但是tls中以及cache中没有,说明是在另外一个线程,就会判断listp里面的object和传进来的object是否一样,一样的话就进行threadCount ++ 的操作然后去done里面把syncData存在tls,lockCount设为1存在tls。
- 如果以上情况都不是,也就是第一次进来。则创建SyncData,然后进行相对应的初始化赋值,然后到done里面里面把syncData存在tls,lockCount设为1存在tls。
所以在id2data方法中,主要分为5种情况
-
第一次进来,没有锁
创建syncData,进行初始化赋值,然后将syncData存储到tls, lockCount 设为1 存储到tls。 -
不是第一次进来,且是同一对象,同一个线程
tls中有数据,则判断是否为同一对象,同一对象则根据传进来的why进行相对应的操作后存储到tls。 -
不是第一次进来,且是同一对象,不同线程
是同一对象,进行threadCount++操作,然后将syncData存入到tls, lockCount 设为1 存储到tls。 -
不是第一次进来,且是不同对象,不同线程
创建syncData,进行初始化赋值,然后将syncData存储到tls, lockCount 设为1 存储到tls,如果哈希冲突就用拉链法,否则就是根据哈希算出来的下标存入到sDataLists。 -
不是第一次进来,且是不同对象,同一线程
创建syncData,进行初始化赋值,然后将syncData存储到cache, lockCount 设为1 存储到cache。
4. @synchronized总结
- sync是全局的哈希表,采用的是拉链法
- sDataList array 存的是SyncList,SyncList版订的是object,也就是要加锁的对象。
- 底层是 objc_sync_enter / exit 对称 递归互斥锁
- 两种存储,tls以及cache存储
- 第一次来的时候,就会创建一个syncData,用头插法创建一个链表结构,标记threadCount为1
- 第二次来的时候判断是否为同一个对象,如果是的话就会在TLS里面对lockCount进行++操作。
- TLS 找不到,重新创建syncData,然后对 threadCount进行 ++操作
-如果exit的话,就会对lockCount进行-- ,当lockCount为0时,则会对threadCount进行–操作- Synchronized : 可重入 递归 多线程是因为有threadCount记录多少条线程对这个锁对象加锁,lockCount 记录在这个线程里面锁了多少次
- 注意事项,@synchronized传的对象不能为nil,否则加锁失败。@synchronized传的对象也不要为临时变量,因为临时变量生命周期只会在当前作用域。
- 传self 的原因:
- 生命周期
- 只会形成一条拉链,方便存储和释放。