iOS 锁的底层分析(1)--@synchronized

前言

上一篇文章研究完了GCD相关的底层原理,现在我们开始探索锁的底层原理。众所周知,锁分为两大类:自旋锁&互斥锁。那么他们的工作原理是怎么样子的呢?我们开发中怎么运用这些锁呢?拭目以待!

准备工作

  • Objc-818.2

1. 锁的归类

1.1 自旋锁

自旋锁是一种用于保护多线程共享资源的锁,与一般互斥锁mutex)不同之处在于当自旋锁尝试获取锁时以忙等待busy waiting)的形式不断地循环检查锁是否可用。当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会一直等待不会睡眠),当上一个线程的任务执行完毕,下一个线程会立即执行

注意:在多CPU的环境中,对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能

自旋锁:OSSpinLock(自旋锁)读写锁

1.2 互斥锁

当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会进入睡眠状态等待任务执行完毕,当上一个线程的任务执行完毕,下一个线程会自动唤醒然后执行任务,该任务也不会立刻执行,而是成为可执行状态就绪)。互斥锁mutex),⽤于保证在任何时刻,都只能有⼀个线程访问该对象

  • mutex函数
    Posix Thread中定义有⼀套专⻔⽤于线程同步的mutex函数mutex,⽤于保证在任何时刻,都只能有⼀个线程访问该对象。当获取锁操作失败时,线程会进⼊睡眠等待锁释放时被唤醒
    注意:NSLockNSConditionNSRecursiveLock底层都是对pthread的封装。

  • 互斥和同步的理解

    • 互斥两条线程处理,同一时间只有一个线程可以运行;
    • 同步:除了有互斥的意思外,同时还有一定的顺序要求,即按照一定的顺序执行
  • 递归锁
    就是同⼀个线程可以加锁N次不会引发死锁
    注意:NSRecursiveLock@synchronizedpthread_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函数对应的位置。见下图:
    man函数定位

    可以看到,调用了objc_sync_enter方法,并且使用了try-catch,在正常处理流程中,提供了_SYNC_EXIT结构体,最后也会调用对应的析构函数objc_sync_exit

  • 打开汇编断点,查看汇编流程

    汇编断点

    通过汇编我们可以发现底层调用了两个方法分别是objc_sync_enterobjc_sync_exit,根上面.cpp文件中的流程是一致的。

3.2 实现原理

libObjc.dylib源码中分析其实现原理。搜索objc_sync_enterobjc_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_enterobjc_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 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方法实现如下:

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 sDataLists;

StripedMap的结构分析
首先查看StripedMap的定义如下:

class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
    enum { StripeCount = 8 };    //表的size
#else
    enum { StripeCount = 64 };
#endif

给表为不同的架构环境提供了不同的容量,真机环境的容量为8,模拟环境的容量为64。而其元素为SyncListSyncList的数据结构为:

struct SyncList {
    SyncData *data;
    spinlock_t lock;
    constexpr SyncList() : data(nil), lock(fork_unsafe_lock) { }
};

SyncData是一个链表结构,至此形成了一个拉链结构。见下图:

拉链结构

注意:一个SyncData对应一个对象

@ synchronized的数据结构

@ synchronized数据结构图

3.3 案例跟踪分析

  • 单线程递归加锁object不变
    案例分析

    跟踪进入断点1104行),跟踪进入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,并且会绑定到当前线程中。

进入断点2objectperson2,此时线程已经绑定了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中获取对应的数据,在外层线程中,已经添加了person1person2对应的SyncData,所以这里是可以获取的。并且会针多线程操作,从而是threadCount1,此时对应的线程数会变成2,见下图:
    断点跟踪流程

    获取数据后,因为前面fastCacheOccupied=NO,则会将该SyncData绑定到当前这个线程,也就是每个线程都会默认绑定第一个object,见下图:
    断点跟踪流程

进入断点2,进行person2的加锁操作,此时首先会获取当前线程绑定的SyncData,因为此时已经绑定了person1tls对应的Object不相同。

然后会从线程对应的缓存列表中获取,因为当前线程没有添加过,所以这里查询不到,最终会在listp中获取对应的SyncData。与此同时会进行threadCount1操作。完成以上操作后,会将该SyncData添加到线程对应的缓存列表中。见下图:

断点跟踪流程

在新线程中的流程与外层线程的逻辑是一样的,只是线程绑定的数据和缓存列表数据不一样。

objc_sync_enter之后的流程图
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

      • enterlockCount++并存储到SYNC_COUNT_DIRECT_KEY
      • exitlockCount--并存储到SYNC_COUNT_DIRECT_KEYlockCount == 0清空。
    • TLS cache缓存获取,遍历cache找到对应的SyncData

      • enterlockCount++
      • exitlockCount--lockCount == 0替换cache->list对应的值为最后一个,used -1threadCount -1
    • sDataLists全局哈希表获取SyncData:找到的情况下threadCount + 1进入缓存逻辑,没有找到并且存在threadCount = 0则替换object相当于存储了新值。

    • SyncData创建:创建SyncData,赋值objectthreadCount初始化为1,创建mutex锁。并且采用头插法SyncData插入sDataLists对应的SynList头部

    • SyncData数据缓存:sDataLists添加了或者更新了数据会走到缓存逻辑,缓存逻辑是往TLS快速缓存以及TLS cache缓存添加数据。

      • enterTLS快速缓存不存在的情况下将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数据之间关系

  • @synchronized完整调用流程:

    @synchronized完整调用流程

你可能感兴趣的:(iOS 锁的底层分析(1)--@synchronized)