OC底层原理21-锁的原理

iOS--OC底层原理文章汇总

本文探索常用锁以及@synchronized底层的原理。

锁的分类

在开发中,使用最常见的恐怕就是@synchronized(互斥锁)、NSLock(互斥锁)、以及dispatch_semaphore(信号量)。其实还有许多种,总分类有:互斥锁、自旋锁,细分之下多出了: 读写锁、递归锁、条件锁、信号量,后三者是对基本锁的上层封装。先介绍几个概念。

自旋锁】是用于多线程同步的一种锁,线程反复检查锁变量是否可用(即可重入特性)。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。 自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。

【互斥锁】是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区而达成。

递归锁(recursive_mutext_t)是一种特殊的互斥锁。

【读写锁】是计算机程序的并发控制的一种同步机制(也称“共享-互斥锁”、多读-单写锁) 用于解决多线程对公共资源读写问题。读的操作可并发重入,写操作是互斥的。 读写锁通常用互斥锁、条件变量、信号量实现。

【信号量】是一种更高级的同步机制,互斥锁可以说是semaphore在仅取值0/1时的特例。信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。

条件锁】:条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,即锁住了,当资源被分配到了,条件锁打开了,进程继续运行。

对应有以下锁:
  1. OSSpinLock(自旋锁)
  2. dispatch_semaphone(信号量)
  3. pthread_mutex(互斥锁)
  4. NSLock(互斥锁)
  5. NSCondition(条件锁)
  6. os_unfair_lock (互斥锁)
  7. pthread_mutex(recursive 互斥递归锁)
  8. NSRecursiveLock(递归锁)
  9. NSConditionLock(条件锁)
  10. synchronized(互斥递归锁)

OSSpinLock(自旋锁)

  • 与互斥锁(阻塞-睡眠)不同,自旋锁加锁后是进入忙等状态。
  • 如果共享数据已经有其他线程加锁了,线程会以忙等的方式等待锁,一旦被访问的资源被解锁,则等待资源的线程会立即执行。

OSSpinLock效率很高,但是已不再安全。如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。

ibireme 大神--<不再安全的 OSSpinLock>

各类型锁的性能

所以苹果已经推荐使用os_unfair_lock

  • os_unfair_lock基本使用
    关于os_unfair_lock是苹果在iOS10之后推出,它属于互斥锁,os_unfair_lock加锁会让等待的线程进入休眠状态,而不是忙等。这样就提高了安全也降低了性能损耗。
#import <os/lock.h>
// 创建一个 os_unfair_lock_t 锁
os_unfair_lock_t unfairLock;
// 先分配此类型的变量并将其初始化为OS_UNFAIR_LOCK_INIT
unfairLock = &(OS_UNFAIR_LOCK_INIT);
// 尝试加锁,返回YES or NO
os_unfair_lock_trylock(unfairLock)
// 加锁
os_unfair_lock_lock(unfairLock);
// 解锁
os_unfair_lock_unlock(unfairLock);

dispatch_semaphone(信号量)

信号量适用于异步线程同步操作的场景。

    // 创建使用
    dispatch_semaphore_create(long value); // 创建信号量
    dispatch_semaphore_signal(dispatch_semaphore_t deem); // 发送信号量
    dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout); // 等待信号量
    //  👉注意: 发送信号量和信号等待是成对出现

    // 常见使用场景
    dispatch_semaphore_t sem = dispatch_semaphore_create(0);
    dispatch_async(dispatch_get_global_queue(0, 0), ^{ // ①
        
        NSLog(@"任务1:%@",[NSThread currentThread]);
        dispatch_semaphore_signal(sem); // ③
    });
    
    dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); // ②
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSLog(@"任务2:%@",[NSThread currentThread]);
        dispatch_semaphore_signal(sem); // ⑤
    });
    
    dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); // ④
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSLog(@"任务3:%@",[NSThread currentThread]); // ⑥
    });
    
    // 执行顺序:① - ② - ③ - ④ - ⑤ - ⑥

}

通过控制信号量通过数,就可实现锁的功能。

pthread_mutex(互斥锁)

  • 阻塞线程并sleep(加锁),加锁过程中切换上下(主动出让时间片,线程休眠,等待下一次唤醒)、cpu的抢占、信号的发送等开销。
  • 如果共享数据已经有其他线程加锁了,线程会进入休眠状态等待锁。一旦被访问的资源被解锁,则等待资源的线程会被唤醒。
  • 互斥锁范围,应该尽量小;锁定范围越大,效率越差。
  • 能够给任意NSObject对象加锁。

加解锁流程:sleep(加锁) -> 出让时间片 -> 线程休眠 -> 等待唤醒 -> running(解锁)

时间⽚(quantum):系统给每个正在运行的进程或线程微观上的一段CPU时间。

//  导入互斥锁头文件--C语言
#import <pthread.h>
// 可添加成员变量
 pthread_mutex_t mutex;

- (void)myfun
{
    pthread_mutex_init(&mutex, NULL);
    
}
- (void)MyLockingFunction
{
    pthread_mutex_lock(&mutex);
    // Do something.
    pthread_mutex_unlock(&mutex);
}
- (void)dealloc
{  
     // 不用要释放掉
    pthread_mutex_destroy(&mutex);
}
// 这只是简单使用,具体还需针对进行错误代码处理
互斥锁 vs 自旋锁

相同:都能保证同一时间只有一个线程访问共享资源。都能保证线程安全。
不同:

  • 互斥锁:如果共享数据已经有其他线程加锁了,线程会进入休眠状态等待锁。一旦被访问的资源被解锁,则等待资源的线程会被唤醒。
  • 自旋锁:如果共享数据已经有其他线程加锁了,线程会以忙等的方式等待锁,一旦被访问的资源被解锁,则等待资源的线程会立即执行。

NSLock(互斥锁)

NSLock是对底层pthread_mutex的封装。一般使用有:

self.lock = [[NSLock alloc] init];
[self.lock tryLock]; // 尝试加锁;返回YES or NO
[self.lock lock]; // 加锁
[self.lock unlock]; // 解锁

底层原理

NSLock - Swift Foundation

NSLockFoundation下的,闭源则源码不可见。借助Swift的Foundation可以看看同集成NSLockingNSLock在底层做了什么操作。

    1. 调用必须初始化;而底层则直接调用了互斥锁pthread_mutex_init.(可以知道性能相近的原因了)
    1. 底层实现也是调用了pthread_mutexlockunlock.即就是对pthread_mutex的封装。

在Apple官方文档中指出

Warning
The NSLock class uses POSIX threads to implement its locking behavior. When sending an
unlock message to an NSLock object, you must be sure that message is sent from the
same thread that sent the initial lock message. Unlocking a lock from a different thread can result in undefined behavior.
Tra:本NSLock类使用POSIX线程执行其锁定行为。向NSLock对象发送解锁消息时,必须确保该消
息是从发送初始锁定消息的同一线程发送的。从其他线程解锁锁可能导致未定义的行为。

所有它仅限用于同一线程中,且也不应使用此类来实现递归锁。lock在同一线程上两次调用该方法将永久锁定您的线程。原因是加锁还未解锁又再一次加锁,一直在加锁就会陷入死锁状态。如下:

NSLock *lock = [[NSLock alloc] init];
for (int i= 0; i<50; i++) {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        static void (^testMethod)(int);
        testMethod = ^(int value){
            [lock lock];
            if (value > 0) {
              NSLog(@"current value = %d",value);
              testMethod(value - 1);
            }
        };
        testMethod(10);
        [lock unlock];
    });
}  

可以使用NSRecursiveLock来实现递归锁,也可使用@synchronized替代处理。

@synchronized

@synchronized是开发中用的非常广泛的一种锁,目的就是防止不同的线程同时执行同一段代码。但是就其性能而言,可谓惨不忍睹。常言存在即合理,广泛使用,面试中常常被提及,就需要探索一下其底层原理。

首先确定下研究方法:1. 汇编;2. Clang。

有这样一个例子,「onePx」奶茶店生意很好,奶茶就剩20杯的量,三个窗口卖(类似三条条线程),这还有越卖越多情况,就不符合逾期了。这就多线程对同一资源访问,发生了数据错乱。

多个窗口卖奶茶

当然了,主题是锁,就通过加锁即可解决。譬如加一个@synchronized,就完美控制。
image.png

汇编

@synchronized打下一个端点,打开汇编,Xcode菜单栏,Debug -> Debug Workflow -> Always Show Disassembly

汇编断点-无锁

汇编断点-加锁

通过汇编可以窥见一二,在加锁和无锁的情况下有很大区别,执行流程变得更加复杂,且多出两个关键方法:objc_sync_enter、objc_sync_exit,这就是@synchronized的进出口方法。

Clang

在main.m中编写一个@synchronized方法

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
        @synchronized (appDelegateClassName) {
        }
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

然后对其Clang指令xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp编译出已C++文件,查看底层

main.cpp的main部分底层

由图中可验证汇编形式下,@synchronized的底层会调用objc_sync_enter、objc_sync_exit。如果捕获到异常,就把异常抛出。

objc_sync_enter

在之前的工程中下一个方法为objc_sync_enter的符号断点,我们可以知道底层源码是在libobjc.A.dylib中。也可以在@synchronized处下断点,进入汇编然后调试到objc_sync_enter,点击底部调试菜单栏step into进入objc_sync_enter的深一层汇编,也可以知道其归属于libobjc.A.dylib范畴。

libobjc.A.dylib -> objc_sync_enter

其实从这里就可以知道它在底层大致做了什么,做一个等值判断,根据判断结果,它会调用id2Data的一个方法,然后再会调用一个os_unfair_recursive_lock_lock_with_options;否则就跳转调试超父类,调用一个方法objc_sync_nil。严谨一点还是要走底层代码摸索一波。

打开一份前面文章分析用过的objc源码,我们可以查找到对应的源码

// Begin synchronizing on 'obj'.  开始同步
// Allocates recursive mutex associated with 'obj' if needed.
// 如果需要,分配与“ obj”关联的递归互斥体。 这里可以知道,它是一把递归互斥锁,具体看底层。
// Returns OBJC_SYNC_SUCCESS once lock is acquired. 
// 成功时返回一个 OBJC_SYNC_SUCCESS
int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;
    // 先判空
    if (obj) { 
        // id2data -> 关键方法。obj 不为空,从id2Data 获取一个SyncData类型数据,然后加锁。
        SyncData* data = id2data(obj, ACQUIRE); 
        ASSERT(data);
        // 加锁
        /**
         mutex 类型为 recursive_mutex_t;
        */
        data->mutex.lock();
    } else {
        // @synchronized(nil) does nothing 如果加锁传入的obj为空,什么也不做
        // 如果obj 为空,报以奔溃  objc_sync_nil()
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        } 
        objc_sync_nil();
    }
    return result;
}
  • SyncData结构 + SyncList结构
typedef struct alignas(CacheLineSize) SyncData {
    struct SyncData* nextData; // SyncData -> SyncData ,不断的链接。这证明是一个链表结构
    DisguisedPtr<objc_object> object; //通过运算使指针隐藏于系统工具,同时保持指针的能力,其作用是通过计算把保存的 T 的指针隐藏起来,实现指针到整数的映射。
    int32_t threadCount;  // number of THREADS using this block。该代码块的线程数
    recursive_mutex_t mutex; // 证明其底部就是递归互斥
} SyncData;

struct SyncList {
    SyncData *data;
    spinlock_t lock;

    constexpr SyncList() : data(nil), lock(fork_unsafe_lock) { }
};
// Use multiple parallel lists to decrease contention among unrelated objects.使用多个并行列表以减少不相关对象之间的争用。
// 哈希出对象的数组下标,然后取出数组对应元素的 lock 或 data
#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
#define LIST_FOR_OBJ(obj) sDataLists[obj].data
static StripedMap<SyncList> sDataLists;

SyncData做为一个一个的节点,依次存储,每个 SyncList 结构体都有个指向 SyncData 节点链表头部的指针,也有一个用于防止多个线程对此列表做并发修改的锁。类似SyncData -> SyncData -> SyncData ...,它是链表形式存储,不同的并行列表间互相不对数据进行访问。
链表的底层是通过哈希算法来存储的,即StripedMap底层就是是将对象指针在内存的地址转化为无符号整型,通过算法((addr >> 4) ^ (addr >> 9)) % StripeCount, 来获取下标indexForPointer

StripedMap

SyncList示意图

SyncData单向存储链表
  • obj == nil时啥也不做


    obj = nil --> do nothing

无论是objc_sync_enter还是objc_sync_exit 都调用了一个关键方法id2Data(),则我们的侧重点就是在里面的实现。

id2Data

宏观查看id2Data
  • 第一个情况 : SUPPORT_DIRECT_THREAD_KEYS = 1;即为快速缓存查找
    线程缓存池中快速查找
  1. 在线程缓存池中进行快速查找对象,获取锁的数量,再做一些异常判断;
  2. 判断why,如果类型为ACQUIRE,则使得lockCount 加1,再存储下来;
    如果类型为RELEASE,则使得lockCount 减1,再存储下来;
    如果类型为CHECK,啥也不做。
  • 第二个情况:SyncCache缓存查找
    SyncCache缓存查找

SyncCache结构,与tls线程缓存相似,它也是链式存储。

typedef struct {
    SyncData *data;
    unsigned int lockCount;  // number of times THIS THREAD locked this block 线程加锁次数
} SyncCacheItem;

typedef struct SyncCache {
    unsigned int allocated;
    unsigned int used;
    SyncCacheItem list[0]; 
} SyncCache;

有所不同的是,它如果有多条线程时,就会有多个这样的list。


SyncCacheItem存储SyncData

这个情况下就是在SyncCache的缓存列表里,不同线程间,查找是否有匹配对象,如果找到匹配对应的类型,进行lockCount加和减操作。

  • 第一次进来
    首次进入锁模块

    先在线程池中遍历获取p->nextData,通过 p =*listp取出第一个SyncData数据, 判断p->object 是否为传入object, 如果相等,则存储下object的持有者SyncDatap;跳转到done,通过判断fastCacheOccupied, YES时存储到快速线程缓存中;否则存储到线程缓存(SyncCache)中。

【总结】

  • 首次进来: 没有锁,threadCount = 1,lockCount = 1, 存到tls_set_direct中;
  • 不是第一次进来,是在tls链表进行快速缓存查找的,它们是在同一个线程进行lockCount加,并且将result存到tls_set_direct;
  • 不是第一次但是在SyncCache中查找的,则可能在一个线程或多个线程中遍历SyncCacheItem类型的list单向链表,查找SyncData,查找到也会对lockCount加操作。

objc_sync_exit

// End synchronizing on 'obj'. 
// Returns OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
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;
}

这解锁就是反向操作了,这可不是像某些人反向吸Y那样子。🤣
它要判断obj,然后在id2Data里面做release操作,使得lockCount减少;
如果拿到的非空data闲尝试解锁,解锁成功的即返回result;解锁失败的,及时报错。

【@synchnized 坑点】
坑点1:经过以上的分析,@synchnized慢的原因就非常清楚了,它的加锁解锁都经过了一些列的增删改查再加缓存,链表接口的存取都会影响速度。
坑点2:也是一个面试题

- (void)testSynchronized {
    for (int i = 0; i < 10000; i++) {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
                self.dataSources = [NSMutableArray array];
        });
    }
}

执行之后会卡死。可以通过开启

打开Zombie Objects(僵尸对象)

再次运行就可以看到奔溃。原因是在反复初始化,调用setter retain 新值,释放旧值。线程不断的release旧值,导致了野指针。
尝试锁一下

- (void)testSynchronized {
    for (int i = 0; i < 10000; i++) {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
             @synchronized (self.dataSources) { 
                self.dataSources = [NSMutableArray array];
            }
        });
    }
}

但是会出现同样的奔溃,原因是:self.dataSources的生命周期,它是会被释放的,释放后为nil,在前面分析中就可以知道,如果加锁对象为nil,则在「锁」内就do nothing

所以@synchnized一般锁self,保证锁住的objc的生命周期未结束。但亦不能一直锁self,在底层objc_sync_enter的时候,self的链表会很多,就会导致链表查询很繁琐,性能降低更加明显。

补充:atomic & nonatomic

atomic

  • atomic 原⼦属性(线程安全),针对多线程设计的,需要消耗⼤量的资源
  • atomic 本身就有⼀把锁(⾃旋锁)
  • 保证同⼀时间只有⼀个线程能够写⼊,但是同⼀个时间多个线程都可以取值。(单写多读:单个线程写⼊,多个线程可以读取)

nonatomic

  • nonatomic ⾮原⼦属性
  • nonatomic:⾮线程安全,适合内存⼩的移动设备。

属性应都声明为 nonatomic
尽量避免多线程抢夺同⼀块资源;
尽量将加锁、资源抢夺的业务逻辑交给服务器端处理,减⼩移动客户端的压⼒。

推荐阅读更多精彩内容

  • iOS 底层原理 文章汇总[https://www.jianshu.com/p/412b20d9a0f6] 本文主...
    Style_月月阅读 3,454评论 9 14
  • 转链接:https://juejin.im/post/5d395318f265da1b8608ca98 自旋锁 O...
    DL是谁阅读 339评论 0 0
  • 探索底层原理,积累从点滴做起。大家好,我是Mars。 往期回顾 iOS底层原理探索 — OC对象的本质iOS底层原...
    劳模007_Mars阅读 980评论 1 9
  • 目录:1.为什么要线程安全2.多线程安全隐患分析3.多线程安全隐患的解决方案4.锁的分类-13种锁4.1.1OSS...
    二斤寂寞阅读 1,011评论 0 3
  • 久违的晴天,家长会。 家长大会开好到教室时,离放学已经没多少时间了。班主任说已经安排了三个家长分享经验。 放学铃声...
    飘雪儿5阅读 6,959评论 16 21
  • 今天感恩节哎,感谢一直在我身边的亲朋好友。感恩相遇!感恩不离不弃。 中午开了第一次的党会,身份的转变要...
    迷月闪星情阅读 10,006评论 0 10
  • 可爱进取,孤独成精。努力飞翔,天堂翱翔。战争美好,孤独进取。胆大飞翔,成就辉煌。努力进取,遥望,和谐家园。可爱游走...
    赵原野阅读 2,452评论 1 1
  • 在妖界我有个名头叫胡百晓,无论是何事,只要找到胡百晓即可有解决的办法。因为是只狐狸大家以讹传讹叫我“倾城百晓”,...
    猫九0110阅读 2,811评论 7 3