- 「时光不负,创作不停,本文正在参加2021年终总结征文大赛」
一、iOS中锁的性能对比
多线程编程都会涉及到线程安全的问题,实现线程安全的一个很重要的方式就是加锁。在iOS中有常见的锁有如下几种:
- OSSpinLock
- dispatch_semaphore_t 信号量
- os_unfair_lock
- pthread_mutex_t
- NSLock
- NSCondition
- pthreadMutexRecurive
- NSRecursiveLock
- NSConditionLock
- @synchronized
针对这几种锁,设计一个Demo来测试其性能,使用for循环100000次,在循环体中只做加锁和解锁操作,在循环体开始前记录开始时间,结束后记录结束时间,逻辑以dispatch_semaphore_t的demo为例,其余锁的测试逻辑也与之相同,写法不同而已,代码如下:
分别在
iPhone 12 Pro的真机和模拟器上运行,运行结果如下所示:
将上述结果做成图表如下:
通过上述的数据可以发现,同一种锁在真机和模拟器上的性能表现差距还是比较大的,不过大致的趋势是一致的(不同测试设备得到的结果可能会有差异)。
二、@synchronized使用及源码定位
上一节探索了iOS中各种锁的性能,本节回归文章的主题,开始探索@synchronized这种加锁方式。对比各种锁的性能可以发现,@synchronized的性能并不出众,但是这种锁并没有被废弃,而且使用频率还颇高,说明其自有优势。下面一起来探索吧,关注点有如下几点,在之后的内容中都会一一回答:
- 问题1: 如果传入nil,是否安全
- 问题2: 是否递归可重入
- 问题3: 底层的数据结构如何,怎样进行加解锁
首先看下@synchronized如何使用,先看下不加@synchronized的情况,代码如下:
如果是上述代码其执行结果很大可能是乱序的,如下图所示:
很显然这样的写法存在线程安全的问题,17都跑到19前面了,此时可以通过@synchronized加锁,代码如下:
此时的执行结果如下:
可见加了锁后,虽然会开启线程,但是结果是正确的,不会出现乱序的现象。这里可以把@synchronized包裹的代码块看作一个临界区,对这个区域上锁,就可以保证不会出现线程安全问题。
知道了@synchronized的用法,那么@synchronized的原理是怎样的,还需要继续探索。首先,我们可以探索下@synchronized的本质,新建一个空的Mac工程,写下一个空的@synchronized代码块,然后通过clang看下底层是怎样的表现,结果如下:
经过整理和简化,可以发现关于@synchronized的两个关键代码:
objc_sync_enter()
objc_sync_exit()
给这两个函数添加符号断点,进入如下代码:
由图可以定位到@synchronized的源码是在Objc源码中,下面我们一起看下@synchronized源码中是如何实现加锁和解锁的。
三、@synchronized源码分析
上一小节中定位了@synchronized是在Objc源码中,本小节就来探索下其源码实现。打开objc4-818.2源码,全局搜索objc_sync_enter和objc_sync_exit,得到如下源码:
两个函数分别是加锁和解锁的操作,内部实现流程类似,这里可以放在一起分析。根据源码显示,当obj传入为nil时,objc_sync_exit什么都不会做,objc_sync_enter会调用objc_sync_nil();函数,进入该函数可以发现其实现如下:
BREAKPOINT_FUNCTION(
void objc_sync_nil(void)
);
#define BREAKPOINT_FUNCTION(prototype) \
OBJC_EXTERN __attribute__((noinline, used, visibility("hidden"))) \
prototype { asm(""); }
最终调用BREAKPOINT_FUNCTION的宏定义,相当于如下的调用方式,也就是说不会做任何事。
void objc_sync_nil(void) { asm(""); }
当obj值不为nil时,两个函数都调用id2data函数,并返回一个SyncData指针,下面根据这两个线索看下@synchronized底层的数据结构。
3.1 @synchronized底层数据结构分析
上一小节有两个线索,SyncData类型和id2data函数。我们先看下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;
其中recursive_mutex_t mutex表明了@synchronized是一个递归锁,至此解决了问题1和问题2。其他的成员表示什么意思呢?先不妨猜想,object表示上锁的对象,nextData指向下一个结点,猜想这是一个链表的结点。先继续往下分析。
进入id2data函数,可以看到如下代码:
由于代码过多,所以折起来一部分。看代码前两句,根据object取出两个数据,进入宏定义查看,代码如下:
可以发现,数据是从一个全局静态变量sDataLists中取出的,而sDataLists是一个StripedMap<SyncList>类型,很显然SyncList是一个泛型,源码中其定义如下:
再查看StripedMap,其定义如下:
原来StripedMap是一个模板类,其内部创建了一个PaddedT结构体,并创建了一个PaddedT类型的数组,其实就是传入泛型的数组,在这里就是一个SyncList类型数组,而SyncList内部包含一个SyncData类型成员。
注意观察可以发现,StripedMap取下标的方式并不是直接使用数字,而是根据indexForPointer函数计算得到,很显然这是一个哈希函数,即StripedMap实际是一个哈希表,这一点在下一节中的源码流程中也可以验证。由此,可以发现@synchronized的底层结构如下图所示:
sDataLists是一个哈希表,采用拉链法解决哈希冲突- 每个item以上锁的对象为key,存储一个
SyncList,这是一个链表结构 - 每一个结点存储一个
SyncData,这个结点表示对该对象上的一个锁
3.2 id2data函数流程分析
根据上小节中id2data函数的定义,可以发现该函数折叠的代码从上到下可以分为四个部分
- 从TLS中取出数据及操作
- 从Cache中取出数据及操作
- 遍历链表,看上锁对象
object是否已经存入sDataLists,并操作lockCount object没有上过锁,因此进行第一次上锁
3.2.1 第一次加锁
给一个没上过锁的对象加锁,然后调试源码发现,第一次走的代码如下:
这里会创建一个新的SyncData,并赋值给result,然后将result->object指向当前上锁的对象,同时threadCount默认置为1,因为虽然是第一次加锁,但是肯定会在某一个线程中的。
最后两句代码result->nextData = *listp;和*listp = result;,这是单链表的头插法,也从侧面说明了sDataLists中每个item是单链表的形式存储。
至此,第一次加锁完成,下面会将result存入 TLS 或者 Cache。
3.2.2 从TLS和Cache中获取SyncData
TLS全称Thread Local Storage,中文线程局部存储,是某些操作系统为线程单独提供的私有空间,不过空间有限。也就是说,如果操作系统支持TLS,线程会有一个私有空间,该空间中可以存储加锁的信息,即SyncData。该部分的代码如下:
可以看见如果是同一个加锁对象,则将data赋值给result,同时根据外部传入的why判断执行何种操作,ACQUIRE执行加锁,lockCount加1,并更改TLS;如果是RELEASE,则执行解锁操作,lockCount减1,当lockCount减到0时,移除缓存,并调用原子函数OSAtomicDecrement32Barrier处理线程数量。
Cache了获取代码如下,其逻辑与TLS类似:
3.2.3 对于加锁对象是否使用过的判断和处理
如果一个对象没有在Cache和TLS中,走到这一步时,会遍历根据object取出的链表,如果链表为空则进行第一次加锁的操作,否则result赋值为p,并增加引用的线程数。
总结
本篇探索了@synchronized的底层数据结构和原理,总结如下:
- @synchronized传入nil不会导致崩溃,事实上不会做什么事情,不过这样做没有必要
- @synchronized底层数据结构是个哈希表,使用拉链法解决冲突,每个key是一个加锁对象,存储一个链表
以上为对于
@synchronized的探索,不足之处欢迎大家指正。