前言
上一篇文章研究完了GCD
相关的底层原理,现在我们开始探索锁的底层原理。众所周知,锁分为两大类:自旋锁
&互斥锁
。那么他们的工作原理是怎么样子的呢?我们开发中怎么运用这些锁呢?拭目以待!
准备工作
1. 锁的归类
1.1 自旋锁
自旋锁是一种用于保护多线程共享资源
的锁,与一般互斥锁
(mutex
)不同之处在于当自旋锁尝试获取锁时以忙等待
(busy waiting
)的形式不断地循环检查锁是否可用
。当上一个线程的任务没有执行完毕的时候(被锁住
),那么下一个线程会一直等待
(不会睡眠
),当上一个线程的任务执行完毕,下一个线程会立即执行
。
注意:在多CPU
的环境中,对持有锁较短的程序
来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能
。
自旋锁:OSSpinLock(自旋锁)
、读写锁
1.2 互斥锁
当上一个线程的任务没有执行完毕
的时候(被锁住
),那么下一个线程会进入睡眠状态等待任务执行完毕
,当上一个线程的任务执行完毕,下一个线程会自动唤醒然后执行任务
,该任务也不会立刻执行
,而是成为可执行状态
(就绪
)。互斥锁
(mutex
),⽤于保证在任何时刻,都只能有⼀个线程访问该对象
。
mutex
函数
在Posix Thread
中定义有⼀套专⻔⽤于线程同步的mutex函数
。mutex
,⽤于保证在任何时刻,都只能有⼀个线程访问该对象
。当获取锁操作失败时,线程会进⼊睡眠
,等待锁释放时被唤醒
。
注意:NSLock
、NSCondition
、NSRecursiveLock
底层都是对pthread
的封装。-
互斥和同步的理解
-
互斥
:两条线程处理
,同一时间只有一个线程
可以运行; -
同步
:除了有互斥
的意思外,同时还有一定的顺序
要求,即按照一定的顺序执行
。
-
递归锁
就是同⼀个线程可以加锁N次
⽽不会引发死锁
。
注意:NSRecursiveLock
、@synchronized
、pthread_mutex(recursive)
是递归锁
互斥锁:pthread_mutex(互斥锁)
、@synchronized(互斥锁)
、NSLock(互斥锁)
、NSConditionLock(条件锁)
、NSCondition(条件锁)
、NSRecursiveLock(递归锁)
、dispatch_semaphore_t(信号量)
1.3 自旋锁和互斥锁的特点
-
自旋锁会忙等
,所谓忙等,即在访问被锁资源时,调用者线程不会休眠
,而是不停循环在那里
,直到被锁资源释放锁
。 -
互斥锁会休眠
,所谓休眠,即在访问被锁资源时,调用者线程会休眠
,此时cpu可以调度其他线程工作
,直到被锁资源释放锁。此时会唤醒休眠线程
。
1.3.1 自旋锁优缺点
-
优点
:自旋锁不会引起调用者睡眠
,所以不会进行线程调度
,CPU
时间片轮转等耗时操作。所有如果能在很短的时间内获得锁,自旋锁的效率远高于互斥锁
。 -
缺点
:自旋锁一直占用CPU
,他在未获得锁的情况下,一直运行自旋
,所以占用着CPU
,如果不能在很短的时间内获得锁,这无疑会使CPU
效率降低。自旋锁不能实现递归调用
。
1.4 锁的性能
以下是锁的性能图,同意条件下各种锁的耗时,如下:
大部分锁在
真机上
性能表现更好,@synchronized
在真机与模拟器中表现差异巨大
。也就是说苹果在真机模式下优化了@synchronized
的性能。与之前相比目前@synchronized
的性能基本能满足要求。
注意:判断一把锁的性能好坏
,一般情况下是与pthread_mutex_t
做对比(因为底层都是对它的封装)。
2. 锁的作用
通过一个案例进行分析。模拟一个售票流程,总票数为20
张,有4
个窗口在同时进行售票,实时跟踪剩余票数
。见下面代码:
@interface ViewController ()
@property (nonatomic, assign) NSUInteger ticketCount;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.ticketCount = 20;
[self testSaleTicket];
}
- (void)testSaleTicket{
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 3; i++) {
[self saleTicket];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 10; i++) {
[self saleTicket];
}
});
}
- (void)saleTicket{
if (self.ticketCount > 0) {
self.ticketCount--;
sleep(0.1);
NSLog(@"当前余票还剩:%lu张",(unsigned long)self.ticketCount);
} else {
NSLog(@"当前车票已售罄");
}
}
@end
运行结果如下:
通过上面的运行结果,发现因为
异步操作
的原因,出现了数据不安全问题
,数据出现了混乱
。通常我们会通过加锁
的方式来保证数据的安全
,用来保证在任一时刻,只能有一个线程访问该对象
。
对上面的案例进行修改如下:
添加一个
@synchronized
互斥锁,重新运行程序,发现其能够正常运行,并能够保证数据的安全性
。@synchronized用着更方便,可读性更高,也是我们最常用的
。当然一些小伙伴说也可以用信号量
来控制啊,别忘了信号量也是互斥锁
。
3. @synchronized实现原理
通过上面的案例我们了解到了锁的作用,那么@synchronized
到底做了什么工作呢?这是我们所需要研究分析的。
3.1 底层探索
-
通过使用
xcrun
生成.cpp
文件查看底层原理
提供以下的代码,如下:
xcrun
之后生成.cpp
文件,打开.cpp
文件,定位到main
函数对应的位置。见下图:
可以看到,调用了objc_sync_enter
方法,并且使用了try-catch
,在正常处理流程中,提供了_SYNC_EXIT
结构体,最后也会调用对应的析构函数objc_sync_exit
。 -
打开汇编断点,查看汇编流程
通过汇编我们可以发现底层调用了两个方法分别是objc_sync_enter
和objc_sync_exit
,根上面.cpp
文件中的流程是一致的。
3.2 实现原理
在libObjc.dylib
源码中分析其实现原理。搜索objc_sync_enter
和objc_sync_exit
两个方法的源码实现:
int objc_sync_enter(id obj)
{
int result = OBJC_SYNC_SUCCESS;
if (obj) {
SyncData* data = id2data(obj, ACQUIRE);
ASSERT(data);
data->mutex.lock();
} else {
// @synchronized(nil) does nothing
if (DebugNilSync) {
_objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
}
objc_sync_nil();
}
return result;
}
int objc_sync_exit(id obj)
{
int result = OBJC_SYNC_SUCCESS;
if (obj) {
SyncData* data = id2data(obj, RELEASE);
if (!data) {
result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
} else {
bool okay = data->mutex.tryUnlock();
if (!okay) {
result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
}
}
} else {
// @synchronized(nil) does nothing
}
return result;
}
由源码可以看出,objc_sync_enter
与objc_sync_exit
的方法流程都是一一对应的。
流程分析:
- 首先加锁和解锁都会对
obj
进行判断,如果obj
为空,什么也没有做,在libObjc.dylib
源码中,没有查到objc_sync_nil()
的相关实现。 - 如果
obj
不为空,在enter
方法中,会封装一个SyncData
对象,并对调用mutex
属性进行上锁lock()
;在exit
方法时,同样获取对应的SyncData
对象,然后调用data->mutex.tryUnlock();
进行解锁。
SyncData结构分析
查看源码,发现SyncData
的定义如下:
typedef struct alignas(CacheLineSize) SyncData {
struct SyncData* nextData;
DisguisedPtr<objc_object> object;
int32_t threadCount; // number of THREADS using this block
recursive_mutex_t mutex;
} SyncData;
-
struct SyncData* nextData;
包含了一个相同的数据结构,说明它是一个单向链表结构
-
object
使用DisguisedPtr
进行了包装 -
threadCount
线程的数量,有多少个线程对该对象进行加锁
recursive_mutex_t mutex;递归锁
初步判断:@synchronized
支持递归锁
,并且支持多线程访问
。
id2data
方法
id2data
方法实现如下:
分析:
包含
3
个大步骤,首先通过tls
,从线程缓存
中获取当前线程的SyncData
进行相关处理;如果缓存中存在对应的SyncData
则从缓存中获取并处理;最后包括一些内部的初始化插入缓存等操作。(详细的步骤在后面案例通过lldb
进行分析)
LOCK_FOR_OBJ&LIST_FOR_OBJ
查看两者的宏定义如下:
#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
#define LIST_FOR_OBJ(obj) sDataLists[obj].data
static StripedMap<SyncList> sDataLists;
StripedMap
的结构分析
首先查看StripedMap
的定义如下:
class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
enum { StripeCount = 8 }; //表的size
#else
enum { StripeCount = 64 };
#endif
给表为不同的架构环境提供了不同的容量,真机环境的容量为8
,模拟环境的容量为64
。而其元素为SyncList
,SyncList
的数据结构为:
struct SyncList {
SyncData *data;
spinlock_t lock;
constexpr SyncList() : data(nil), lock(fork_unsafe_lock) { }
};
而SyncData
是一个链表结构
,至此形成了一个拉链结构
。见下图:
注意:一个SyncData对应一个对象
@ synchronized
的数据结构
3.3 案例跟踪分析
-
单线程递归加锁
object
不变
跟踪进入断点1
(104
行),跟踪进入id2data
方法。此时StripedMap
表中64
个数据全是空。见下图:
继续跟踪调试,会调用tls_get_direct
方法,获取当前线程绑定的SyscData
,因为是第一次进行加锁
,所以这里的data
是空。见下图:
紧接着会从当前线程的缓存列表
中获取对应的SyncData
,很显然此时缓存中也没有存储该对象
,所以此时也是空
。见下图:
当前线程绑定的SyncData
和线程对应的缓存列表
中的SyncData
都为空
,则会从哈希表
中获取,当前的表中也没有对应的数据,见下图:
上面三个地方都没有找到对应的SyncData
,最终会创建一个SyncData
,并采用头插法
将数据插入到对应listp头部
。见下图:
完成SyncData
创建后,会绑定到当前线程上
(一个线程只会绑定一个,并且绑定后不再改变
),注意此时并没有保存到线程对应的缓存列表中
。见下图:
最后返回result
,完成加锁功能
。
然后进入断点2
,查看第二次加锁
的流程,进入id2data
方法,此时哈希表中已经有一个数据
,也就是此时对象对应的listp
此时也不再为空(同一个对象),如下图:
继续往下走,再次获取当前线程绑定的
SyncData
,此时不再为空
,并且object
相同。见下图:线程绑定的
SyncData
对应的object
,与此时的object
相同,再次创建锁,并且锁次数++
,见下图:然后继续进入断点3
,进行第三次加锁
时,因为此时object
没有发生改变,线程也没有改变,此时哈希表依然是一个元素
,同时对应的listp
也只有一个元素
,此时上锁此时会变为3
。见下图:
-
单线程递归加锁
object
变化
引入下面这个案例,我们直接从第二个断点
开始分析,见下图:
第一个断点的流程分析已经在上面的案例分析了,这里就不再做分析。此时会创建一个新的SyncData
,并且会绑定到当前线程
中。
进入断点2
,object
为person2
,此时线程已经绑定了person1
对应的SyncData
,所以线程绑定关系已经被占用
,但是object
不相同。见下图:
因为
person2
对象是第一次加锁
,所以线程对应缓存列表
和listp
中都没有对应的SyncData
。见下图:person2
初次进入,会进行对象的创建,并将SyncData
放入缓存列表
中。见下图:如果下次
person2
再次加锁时,会从缓存列表中获取
。而如果person1
再次加锁,会从当前线程
中获取,因为当前线程已经绑定了person1对应的SyncData
。
-
多线程递归加锁
object
变化
引入下面的案例,见下图:
上面案例中,前两个加锁过程这里不再分析,和上面单线程是一样的,我们从多线程
时开始分析,也就是第113
行开始。
从断点1
处进行跟踪,进入id2data
方法,此时哈希表
中的数据个数为2
,也就是外层线程
添加的两个SyncData
。见下图:
继续跟踪代码,从线程中获取其绑定的SyncData
,此时为NULL
,因为是新的线程,还没有加过锁,所以绑定数据为空
,fastCacheOccupied=NO
。见下图:
从缓存列表中获取对应的SyncData
,也是NULL
,所以这里的缓存列表也是和线程一一对应
的。见下图:
最后会从listp
中获取对应的数据,在外层线程中,已经添加了person1
和person2
对应的SyncData
,所以这里是可以获取的。并且会针多线程操作,从而是threadCount
加1
,此时对应的线程数会变成2
,见下图:
获取数据后,因为前面fastCacheOccupied=NO
,则会将该SyncData绑定到当前这个线程
,也就是每个线程都会默认绑定第一个object
,见下图:
进入断点2,进行person2
的加锁操作,此时首先会获取当前线程绑定的SyncData
,因为此时已经绑定了person1
,tls
对应的Object
不相同。
然后会从线程对应的缓存列表
中获取,因为当前线程没有添加过
,所以这里查询不到,最终会在listp
中获取对应的SyncData
。与此同时会进行threadCount
加1
操作。完成以上操作后,会将该SyncData
添加到线程对应的缓存列表
中。见下图:
在新线程中的流程与外层线程的逻辑是一样的,只是线程绑定的数据和缓存列表数据不一样。
objc_sync_enter之后的流程图
3.4 @synchronized原理总结
参数传
nil
没有做任何事情。传self
在使用过程中不会被释放
,并且同一个类
中如果都用self
底层只会存在一个SynData
。@synchronized
底层是封装的os_unfair_lock
。objc_sync_enter
中加锁,objc_sync_exit
中解锁。@synchronized
加锁的数据信息都存储在sDataLists全局哈希表
中。同时还有TLS
快速缓(一个SynData数据,通常是第一个,释放后会存放新的
)以及线程缓存(缓存跟线程一一对应的,缓存之间是互斥关系
)-
id2data
获取SynData
流程:-
TLS
快速缓存获取(SYNC_COUNT_DIRECT_KEY
),obj
对应的SyncData
存在的情况下获取SYNC_COUNT_DIRECT_KEY
对应的lockCount
。-
enter
:lockCount++
并存储到SYNC_COUNT_DIRECT_KEY
。 -
exit
:lockCount--
并存储到SYNC_COUNT_DIRECT_KEY
,lockCount == 0
清空。
-
-
TLS cache
缓存获取,遍历cache
找到对应的SyncData
。-
enter
:lockCount++
。 -
exit
:lockCount--
。lockCount == 0
替换cache->list
对应的值为最后一个,used -1
,threadCount -1
。
-
sDataLists
全局哈希表获取SyncData
:找到的情况下threadCount + 1
进入缓存
逻辑,没有找到并且存在threadCount = 0
则替换object
相当于存储了新值。SyncData
创建:创建SyncData
,赋值object
,threadCount
初始化为1
,创建mutex锁
。并且采用头插法
将SyncData插入sDataLists对应的SynList头部
。-
SyncData
数据缓存:sDataLists
添加了或者更新了数据会走到缓存逻辑,缓存逻辑是往TLS快速缓存
以及TLS cache缓存
添加数据。-
enter
:TLS快速缓
存不存在的情况下将SyncData
存储快速缓存
,否则存入cache
缓存的尾部
。 -
exit
:直接return
。
-
-
-
lockCount
是针对单个线程
而言的,当lockCount = 0
的时候对数据进行释放
。-
TLS
快速缓存是直接设置为NULL
(只有一个SyncData
)。 -
TLS cache
缓存是直接用最后一个
数据进行替换(一组SyncData
),然后used -1
进行释放
。 - 同时
threadCount - 1
相当于当前线程被释放
。
-
threadCount
是针对跨线程
的,在threadCount = 0
的时候并不立即释放
,而是在下次插入数据的时候进行替换
。sDataLists
保存所有的数据
。lockCount
是@synchronized
可重入可递归的原因
,threadCount
是@synchronized
可跨线程的原因
。-
@synchronized
数据之间关系:
-
@synchronized
完整调用流程: