iOS进阶 -- @synchornized原理分析

579 阅读7分钟

一、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为例,其余锁的测试逻辑也与之相同,写法不同而已,代码如下: Xnip2021-12-03_12-58-09.png 分别在iPhone 12 Pro的真机和模拟器上运行,运行结果如下所示:

Xnip2021-12-02_23-01-09.png

Xnip2021-12-02_23-01-49.png

将上述结果做成图表如下:

Xnip2021-12-02_23-06-42.png

通过上述的数据可以发现,同一种锁在真机和模拟器上的性能表现差距还是比较大的,不过大致的趋势是一致的(不同测试设备得到的结果可能会有差异)。

二、@synchronized使用及源码定位

上一节探索了iOS中各种锁的性能,本节回归文章的主题,开始探索@synchronized这种加锁方式。对比各种锁的性能可以发现,@synchronized的性能并不出众,但是这种锁并没有被废弃,而且使用频率还颇高,说明其自有优势。下面一起来探索吧,关注点有如下几点,在之后的内容中都会一一回答:

  • 问题1: 如果传入nil,是否安全
  • 问题2: 是否递归可重入
  • 问题3: 底层的数据结构如何,怎样进行加解锁

首先看下@synchronized如何使用,先看下不加@synchronized的情况,代码如下:

Xnip2021-12-03_13-51-50.png

如果是上述代码其执行结果很大可能是乱序的,如下图所示:

Xnip2021-12-03_13-51-12.png

很显然这样的写法存在线程安全的问题,17都跑到19前面了,此时可以通过@synchronized加锁,代码如下: Xnip2021-12-03_13-52-10.png 此时的执行结果如下:

Xnip2021-12-03_13-54-49.png

可见加了锁后,虽然会开启线程,但是结果是正确的,不会出现乱序的现象。这里可以把@synchronized包裹的代码块看作一个临界区,对这个区域上锁,就可以保证不会出现线程安全问题。

知道了@synchronized的用法,那么@synchronized的原理是怎样的,还需要继续探索。首先,我们可以探索下@synchronized的本质,新建一个空的Mac工程,写下一个空的@synchronized代码块,然后通过clang看下底层是怎样的表现,结果如下:

Xnip2021-12-03_14-08-36.png

经过整理和简化,可以发现关于@synchronized的两个关键代码:

objc_sync_enter()
objc_sync_exit()

给这两个函数添加符号断点,进入如下代码:

Xnip2021-12-03_14-16-26.png

由图可以定位到@synchronized的源码是在Objc源码中,下面我们一起看下@synchronized源码中是如何实现加锁和解锁的。

三、@synchronized源码分析

上一小节中定位了@synchronized是在Objc源码中,本小节就来探索下其源码实现。打开objc4-818.2源码,全局搜索objc_sync_enterobjc_sync_exit,得到如下源码:

Xnip2021-12-03_14-32-42.png

Xnip2021-12-03_14-32-56.png

两个函数分别是加锁和解锁的操作,内部实现流程类似,这里可以放在一起分析。根据源码显示,当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函数,可以看到如下代码:

Xnip2021-12-03_16-40-06.png

由于代码过多,所以折起来一部分。看代码前两句,根据object取出两个数据,进入宏定义查看,代码如下:

Xnip2021-12-03_16-46-08.png

可以发现,数据是从一个全局静态变量sDataLists中取出的,而sDataLists是一个StripedMap<SyncList>类型,很显然SyncList是一个泛型,源码中其定义如下:

Xnip2021-12-03_16-49-14.png

再查看StripedMap,其定义如下:

Xnip2021-12-03_16-52-49.png

原来StripedMap是一个模板类,其内部创建了一个PaddedT结构体,并创建了一个PaddedT类型的数组,其实就是传入泛型的数组,在这里就是一个SyncList类型数组,而SyncList内部包含一个SyncData类型成员。

注意观察可以发现,StripedMap取下标的方式并不是直接使用数字,而是根据indexForPointer函数计算得到,很显然这是一个哈希函数,即StripedMap实际是一个哈希表,这一点在下一节中的源码流程中也可以验证。由此,可以发现@synchronized的底层结构如下图所示:

Xnip2021-12-03_17-18-07.png

  • sDataLists是一个哈希表,采用拉链法解决哈希冲突
  • 每个item以上锁的对象为key,存储一个SyncList,这是一个链表结构
  • 每一个结点存储一个SyncData,这个结点表示对该对象上的一个锁

3.2 id2data函数流程分析

根据上小节中id2data函数的定义,可以发现该函数折叠的代码从上到下可以分为四个部分

  • 从TLS中取出数据及操作
  • 从Cache中取出数据及操作
  • 遍历链表,看上锁对象object是否已经存入sDataLists,并操作lockCount
  • object没有上过锁,因此进行第一次上锁

3.2.1 第一次加锁

给一个没上过锁的对象加锁,然后调试源码发现,第一次走的代码如下:

Xnip2021-12-03_17-47-56.png

这里会创建一个新的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。该部分的代码如下:

Xnip2021-12-03_18-24-20.png

可以看见如果是同一个加锁对象,则将data赋值给result,同时根据外部传入的why判断执行何种操作,ACQUIRE执行加锁,lockCount加1,并更改TLS;如果是RELEASE,则执行解锁操作,lockCount减1,当lockCount减到0时,移除缓存,并调用原子函数OSAtomicDecrement32Barrier处理线程数量。

Cache了获取代码如下,其逻辑与TLS类似:

Xnip2021-12-03_19-02-04.png

3.2.3 对于加锁对象是否使用过的判断和处理

如果一个对象没有在Cache和TLS中,走到这一步时,会遍历根据object取出的链表,如果链表为空则进行第一次加锁的操作,否则result赋值为p,并增加引用的线程数。 Xnip2021-12-03_19-04-45.png

总结

本篇探索了@synchronized的底层数据结构和原理,总结如下:

  • @synchronized传入nil不会导致崩溃,事实上不会做什么事情,不过这样做没有必要
  • @synchronized底层数据结构是个哈希表,使用拉链法解决冲突,每个key是一个加锁对象,存储一个链表 以上为对于@synchronized的探索,不足之处欢迎大家指正。