【iOS】锁

文章目录

前言

iOS里有很多锁的类型,在前面的【iOS】多线程基础中我们反复提到了锁,这里我们就来着重分析一下各类锁,先来看一下各类锁的性能数据对比图

由上图知道,图中锁的性能从高到底依次是:OSSpinLock(自旋锁,已废弃) -> dispatch_semaphore(信号量) -> pthread_mutex(互斥锁) -> NSLock(互斥锁) -> NSCondition(条件锁) -> pthread_mutex(recursive 互斥递归锁) -> NSRecursiveLock(递归锁) -> NSConditionLock(条件锁) -> @synchronized(互斥锁)

OSSpinLock 自旋锁已废弃,iOS 10 之后使用 os_unfair_lock 替代

上面的排序也只是特定情况下的排序,不同情况下的结果也有可能不一样

线程安全

首先在学习锁之前我们也得看一下为什么要用锁

在多线程访问同一块资源的时候,会很容易引起数据的混乱,比如下面这段代码

objc 复制代码
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
__block int num = 0;
for (int i = 0; i < 10000; i++) {
    dispatch_async(queue, ^{
        num++;
    });
}
NSLog(@"num = %d", num);

我们的预期应该是显示输出10000,但是我们运行之后却显示一些不同的结果

这里就是一个典型的数据竞争的问题,原因是对于num的操作不是原子性的操作(num++在底层分三步:读取,对num+1,返回。两个线程若是同时执行某一步读取的是相同的旧值,就会导致只加一次返回)

要解决的话可以进行加锁或者使用串行队列的方法

这里就引入了互斥访问的概念,任意时刻只允许至多一个线程访问,从而保证一个线程可以对共享资源进行安全的操作,来解决这个临界区问题,如下图所示

  • 临界区:访问共享资源的代码片段,这段代码在同一时刻只能被一个线程执行

简介

锁是一种用于保证线程安全的同步机制

在多个线程同时访问同一数据或者资源时候,如果不进行保护,很容易发生数据混乱,资源竞争等的问题,为了解决这些问题,可以用锁来控制对共享资源的访问。

线程在访问共享资源之前,需要先获取锁;访问完成后,再释放锁。如果锁已经被其他线程占用,那么当前线程需要等待,直到锁被释放后才能继续执行。

锁本身就会带来一定的性能开销,因此我们应该尽量缩小加锁的范围,不把过多无关的代码放在锁的内部,否则这个线程持有锁执行的时候,其他线程只能等待,从而降低了并发的性能,无法充分发挥多线程的优势

分类

iOS中的锁基本种类只有三种:互斥锁(Mutex)、自旋锁(Spin Lock)、读写锁(Read-Write Lock,可以视为自旋锁的一种特殊形式,允许多个线程同时进行读操作,但写操作具有独占性)

我们其他时候常见的:条件锁(NSCondition/NSConditionLock)、递归锁(NSRecursiveLock)、信号量(dispatch_semaphore)、一次性执行(dispatch_once),这些实际上都是基于底层同步机制进行封装和扩展实现的线程安全工具

互斥锁

互斥锁(Mutual Exclusion)是一种用于保证线程安全的同步机制,它能够防止多个线程同时访问同一份共享资源

当一个线程获取到互斥锁之后,其他线程如果再尝试获取该锁,就会被阻塞线程进入睡眠,直到持有锁的线程释放锁为止。这样就保证了同一时刻只有一个线程能访问临界区代码,从而避免数据竞争或者数据不一致的问题

根据他的特性我们得知

互斥锁(Mutex)是一种阻塞式锁------当线程获取锁失败时,不会持续占用 CPU 进行循环等待,而是被操作系统挂起并进入睡眠状态,直到持有锁的线程释放锁后再被唤醒继续竞争锁。

因此互斥锁能够有效避免 CPU 空转,但线程的睡眠与唤醒会产生上下文切换开销。当锁竞争较激烈或预计等待时间较长时,互斥锁通常是一种更合适的选择。

  • 递归锁(可重入锁):同一个线程在未释放锁的情况下,可以多次获取同一把锁。系统会记录加锁次数,调用加锁,计数器加一;解锁,计数器减一,只有计数器减到0时,锁才被真正释放
  • 非递归锁(不可重入锁):同一个线程已经持有锁的时候,再次尝试获取同一把锁,会导致线程自己阻塞自己,从而导致死锁,必须等锁释放后才可以再次获取锁

互斥锁解决的是多个线程同时访问共享资源的问题;递归锁和非递归锁的区别在于,同一个线程能否重复获取同一把锁

非递归互斥锁

pthread_mutex(默认 NORMAL)

pthread_mutex就是互斥锁本身,默认初始化得到的就是非递归锁,性能在所有互斥锁里面最高,在同一时间只有一个线程进入临界区,锁被占用的时候,其他线程申请锁,会阻塞线程并且睡眠

objc 复制代码
#import <pthread.h>// 导入头文件

// 全局声明
pthread_mutex_t _lock;

// 初始化
pthread_mutex_init(&_lock, NULL);

// 加锁
pthread_mutex_lock(&_lock);
// 做需要线程安全操作
// 解锁
pthread_mutex_unlock(&_lock);

// 释放锁
pthread_mutex_destroy(&_lock);

这里的pthread有四种类型:

  • PTHREAD_MUTEX_NORMAL:非递归,同一线程二次加锁直接死锁
  • PTHREAD_MUTEX_RECURSIVE:递归,同一线程可以多次加锁,内部计数
  • PTHREAD_MUTEX_ERRORCHECK:非递归,但是二次加锁会返回错误码而不是死锁
  • PTHREAD_MUTEX_DEFAULT:由系统决定,在macOS和iOS里都等同于NORMAL

NSLock

NSLock是对pthread_mutex默认模式的OC封装,是"非递归互斥锁",基本用法如下:

objc 复制代码
NSLock *lock = [[NSLock alloc] init];//创建锁
[lock lock];//加锁
//临界区代码
[lock unlock];//解锁

下面这段代码会出现一个递归阻塞的问题,我们来分析一下

objc 复制代码
NSLock *lock = [[NSLock alloc] init];
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);
        }
        [lock unlock];
    };
    testMethod(10);
});

这里我们看到,程序只执行了一次就一直在等待,主要是因为嵌套使用了递归,导致了同一个线程重复加锁,从而发生了死锁

第一次调用(lock 成功) → 第二次调用(lock 等待) → 锁永远不会释放 → 死锁

问题的一部分原因在于:

  • NSLock 不是递归锁
  • 它不知道当前持有锁的是不是同一个线程
  • 只要锁已经被占用,就会等待

所以控制台那里只有current value = 10

递归互斥锁

pthread_mutex(RECURSIVE 模式)

在之前的pthread_mutex基础上,通过pthread_mutexattr_t把其行为类型改成PTHREAD_MUTEX_RECURSIVE,让其支持同一线程的重入:

objc 复制代码
#import <pthread.h>

pthread_mutex_t _recursiveLock;

// 初始化递归锁
- (void)setupRecursiveMutex {
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&_recursiveLock, &attr);
    pthread_mutexattr_destroy(&attr);
}

// 递归调用测试
- (void)recursiveTest:(int)value {
    pthread_mutex_lock(&_recursiveLock);   // lockCount +1
    if (value > 0) {
        NSLog(@"value = %d", value);
        [self recursiveTest:value - 1];    // 递归加锁,lockCount 再 +1
    }
    pthread_mutex_unlock(&_recursiveLock); // lockCount -1,减到 0 才真正释放
}

// 用完记得销毁
// pthread_mutex_destroy(&_recursiveLock);

加锁和解锁次数必须严格匹配。 加了几次就必须解几次,少一次锁就永远不会真正释放,后续其他线程永远拿不到这把锁。

和之前 NSLock 的递归死锁不同,这里每次pthread_mutex_lock都会让内部计数器 +1,同一线程不会被阻塞,最终从 10 打印到 1,全部 unlock 后锁才真正释放。

NSRecursiveLock

OC风格的递归锁,本质上就是前面pthread_mutex(RECURSIVE模式)的OC封装,其底层和NSLock一样都是pthread_mutex,差别只在初始化的时候设置PTHREAD_MUTEX_RECURSIVE类型。NSRecursiveLock 等于把 pthread_mutexattr 那一套配置代码都封装好了,用法和 NSLock 一样。

跟前面相同的递归调用我们来测试一下:

objc 复制代码
NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
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);
        }
        [lock unlock];
    };
    testMethod(10);
});

运行结果:

多线程下的测试

NSRecursiveLock 只解决了"同一个线程重复获取同一把锁"的问题,并没有解决"多把锁交叉依赖"的问题。当多个线程以不同顺序获取多把 NSRecursiveLock 时,仍然可能形成循环等待而死锁。

下面是一个典型的交叉死锁场景:

objc 复制代码
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSRecursiveLock *lockA = [[NSRecursiveLock alloc] init];
        NSRecursiveLock *lockB = [[NSRecursiveLock alloc] init];

        dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

        // 线程1:先拿 lockA,再拿 lockB
        dispatch_async(queue, ^{
            [lockA lock];
            NSLog(@"线程1 拿到 lockA");
            sleep(1);                     // 确保线程2 先拿到 lockB
            NSLog(@"线程1 尝试拿 lockB...");
            [lockB lock];                 // 等 lockB,但 lockB 被线程2 持有
            NSLog(@"线程1 拿到 lockB");   // 永远走不到这里
            [lockB unlock];
            [lockA unlock];
        });

        // 线程2:先拿 lockB,再拿 lockA
        dispatch_async(queue, ^{
            [lockB lock];
            NSLog(@"线程2 拿到 lockB");
            sleep(1);                     // 确保线程1 先拿到 lockA
            NSLog(@"线程2 尝试拿 lockA...");
            [lockA lock];                 // 等 lockA,但 lockA 被线程1 持有
            NSLog(@"线程2 拿到 lockA");   // 永远走不到这里
            [lockA unlock];
            [lockB unlock];
        });

        [[NSRunLoop currentRunLoop] run];
    }
    return 0;
}

运行结果:

可以看到两个线程各自拿到一把锁后互相等待对方释放,形成经典的循环等待死锁

小结:

  • NSRecursiveLock 解决的是同一个线程重复加同一把锁的问题
  • 不能解决多锁交叉依赖导致的死锁
  • 真正的死锁条件是"互斥 + 持有并等待 + 不可剥夺 + 循环等待",NSRecursiveLock 只消除了"同一线程重复加锁"这一个死锁场景,不改变其他三个条件

@synchronized

@synchronized是OC里写法最简洁且使用最频繁的递归互斥锁,写法非常简单

objc 复制代码
@synchronized (obj) {
    // 临界区
}

同一时刻,只允许一个线程执行这段代码,@synchronized(obj)本质上干了两件事:

  • 根据obj找到对应的锁
  • 对这把锁执行lock/unlock

所以,@synchronized(obj)我们容易以为是把obj这个对象锁住了,但是其实不是------obj相当于key,真正的锁在Runtime里面。每次调用相当于把这个对象作为key创建一把对应的recursive_mutex_t锁然后保存起来,再配合lockCount计数实现重入。

他的特点是可以"自动管理"------对锁的查找、复用和加锁、配对全都在运行时完成。

@synchronized底层

objc 复制代码
@synchronized (obj) {
    // 临界区
}

这一部分编译后大概会变成

objc 复制代码
objc_sync_enter(obj);

@try {
    //临界区
}
@finally {
    objc_sync_exit(obj);
}

这部分对应步骤是进入作用域,加锁,执行代码,再退出作用域,解锁。

这里的@finally保证了无论临界区怎么退出,objc_sync_exit一定会执行,所以他比NSLock更安全------避免了因为忘记unlock或异常退出导致的死锁。

我们的锁究竟被存在哪里?前面只说了一个对象对应一个对应的锁,这里就要提到SyncData------Runtime维护了一张"obj对象 → SyncData"的映射表:

c++ 复制代码
typedef struct alignas(CacheLineSize) SyncData {
    struct SyncData* nextData;              // 链表下一个节点
    DisguisedPtr<objc_object> object;       // 这把锁是给哪个对象用的
    int32_t threadCount;                    // 当前有几个线程在用这把锁
    recursive_mutex_t mutex;                // 真正的锁在的地方
} SyncData;

typedef struct {
    SyncData *data;
    unsigned int lockCount;     // 当前线程对这把锁加了几层
} SyncCacheItem;

每个对象对应一个SyncData,里面包了一个recursive_mutex_t------这就是 @synchronized 是"递归互斥锁"的证据。SyncCacheItem用来记录次数,里面的lockCount是每个线程独立的,各自管理自己的加锁层数:

  • threadCount(在 SyncData 里,全局共享):这把锁有多少个线程在用 → 管理多线程并发
  • lockCount(在 SyncCacheItem 里,每线程独立):当前线程加了几层 → 管理同一线程的递归重入
Runtime查找锁的三级缓存

为了避免每次都遍历全局表,Runtime做了三级缓存:TLS → Thread Cache → Global SyncData List

查找逻辑(传入对象obj):

  1. 查 TLS(线程本地存储,最快)------ tls_get_direct(SYNC_DATA_DIRECT_KEY) 获取当前线程上次锁的 obj,如果就是当前传入的 obj → lockCount++ 返回(极快路径)。若不是则进入第二步
  2. 查 Cache (线程级缓存)------ 遍历当前线程缓存的所有 SyncCacheItem,找到 item->data->object == obj → lockCount++ 返回。还没找到就进入第三步
  3. 查全局链表 (哈希表 + 拉链法)------ 以 obj 为 key 在全局 SyncList 里找。找到 → 复用现有 SyncData,threadCount++。没找到 → posix_memalign 创建新的 SyncData 挂到链表上

最后再把结果存到TLS或Cache,下次走快速路径。

下面给出图解更好的理解

三级缓存是一种查找性能优化,不影响锁的语义。 即使没有缓存、每次走全局链表,锁行为也是一样的。

@synchronized常见的坑

我们可以用以下这段代码来看一下

objc 复制代码
#import <Foundation/Foundation.h>

@interface Test : NSObject
@property (nonatomic, strong) NSMutableArray *array;
@end

@implementation Test
- (instancetype)init {
    self = [super init];
    if (self) {
        _array = [NSMutableArray array];
    }
    return self;
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        Test *obj = [[Test alloc] init];

        dispatch_queue_t queue =
        dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

        // 线程A
        dispatch_async(queue, ^{
            @synchronized(obj.array) {
                NSLog(@"A进入");
                sleep(5);
                NSLog(@"A退出");
            }
        });
        sleep(1);
        // 把锁对象换掉
        obj.array = [NSMutableArray array];
        NSLog(@"数组已替换");
        // 线程B
        dispatch_async(queue, ^{
            @synchronized(obj.array) {
                NSLog(@"B进入");
                NSLog(@"B退出");
            }
        });

        [[NSRunLoop currentRunLoop] run];
    }
}

如果A和B是同一把锁,打印出来的流程应该是A进入退出之后才开始B的打印,但是实际运行结果如图:

我们可以看到,B在A还没退出的时候就已经进入了------即表明A拿的锁不是B的锁。

@synchronized 是基于对象地址建立锁映射的,因此不要锁定可能发生变化的对象(如 NSMutableArray、NSString 属性等)。如果锁对象被重新赋值,Runtime 会认为是另一个对象,从而创建新的 SyncData 和新的 mutex,导致多个线程实际上拿到的是不同的锁,失去同步效果。

另外如果锁对象变成 nil,@synchronized(nil) 不会加锁 ,临界区将完全暴露给并发访问。通常应该使用专门的、生命周期固定的锁对象(如 NSObject *lockObj = [NSObject new]),而不是直接用可变属性。

条件锁

条件锁本质上也是互斥锁,但它额外提供了"等待条件满足再继续"的能力。普通互斥锁只关心锁有没有被占用,条件锁还关心某个条件是否成立------条件不满足时线程主动休眠,条件满足后被唤醒继续执行。

NSCondition 和 NSConditionLock 都属于这一类。

NSCondition(条件锁)

NSCondition 是一种特殊类型的同步机制,用来管理不同线程间的执行顺序,让某些线程能在满足特定条件的时候才会继续。

在底层他是 pthread_mutex_t + pthread_cond_t 的封装,本质上它既是一把互斥锁,又是一个条件变量的通知机制:

  • 锁(pthread_mutex_t):保护共享数据的访问,保证检查条件时的数据一致性
  • 条件变量(pthread_cond_t):根据条件决定线程是继续运行还是阻塞等待

部分方法

objc 复制代码
@interface NSCondition : NSObject <NSLocking>
- (void)lock;
- (void)unlock;
- (void)wait;                                    // 阻塞等待通知
- (BOOL)waitUntilDate:(NSDate *)limit;           // 带超时的等待
- (void)signal;                                  // 唤醒一个等待的线程
- (void)broadcast;                               // 唤醒所有等待的线程
@end

下面是一个完整的生产者-消费者模型,演示 NSCondition 的标准用法:

objc 复制代码
#import <Foundation/Foundation.h>

@interface ConditionQueue : NSObject
@property (nonatomic, strong) NSCondition *cond;
@property (nonatomic, strong) NSMutableArray *items;
@end

@implementation ConditionQueue

- (instancetype)init {
    if (self = [super init]) {
        _cond = [[NSCondition alloc] init];
        _items = [NSMutableArray array];
    }
    return self;
}

// 消费者:取数据
- (id)dequeue {
    [self.cond lock];
    while (self.items.count == 0) {
        [self.cond wait];        // 队列空了,休眠等待
    }
    id obj = self.items.firstObject;
    [self.items removeObjectAtIndex:0];
    NSLog(@"取出: %@, 剩余: %lu", obj, (unsigned long)self.items.count);
    [self.cond unlock];
    return obj;
}

// 生产者:放数据
- (void)enqueue:(id)obj {
    [self.cond lock];
    [self.items addObject:obj];
    NSLog(@"放入: %@, 总数: %lu", obj, (unsigned long)self.items.count);
    [self.cond signal];          // 唤醒一个等待的消费者
    [self.cond unlock];
}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        ConditionQueue *queue = [[ConditionQueue alloc] init];
        dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);

        // 消费者线程:先启动,但队列为空会 wait
        dispatch_async(globalQueue, ^{
            for (int i = 0; i < 5; i++) {
                [queue dequeue];
                sleep(1);
            }
        });

        // 生产者线程:每隔 0.5s 放一个数据
        dispatch_async(globalQueue, ^{
            for (int i = 1; i <= 5; i++) {
                sleep(0.5);
                [queue enqueue:[NSString stringWithFormat:@"数据-%d", i]];
            }
        });

        [[NSRunLoop currentRunLoop] run];
    }
    return 0;
}

运行结果大致如下:

总结一下执行流程:

  1. 消费者先启动,拿到锁后检查队列 → 队列为空 → 调用 wait 进入休眠 。wait 做了两件事:原子地释放锁,然后让线程进入休眠等待------"释放锁"和"进入休眠"是一步完成的,不会有中间状态被其他线程插入
  2. 生产者拿到锁(此时锁已被 wait 释放),放入数据 → 调用 signal 唤醒消费者 → unlock
  3. 被唤醒的消费者自动重新获得锁,从 wait 返回,继续执行取出逻辑
    wait 的两个关键机制:
  • wait 调用时:原子地释放锁 + 进入休眠(不是先解锁再休眠,中间没有缝隙)
  • wait 返回时:自动重新获得锁再返回(线程醒来时已经持有锁了,不需要再手动 lock)

这两个机制是理解 NSCondition 的关键

注意:wait 返回后要重新检查条件 由于可能存在"虚假唤醒"(spurious wakeup),标准写法是 while (条件不满足) { wait; } 而不是 if (条件不满足) { wait; }。这也是为什么上面的 dequeue 方法用了 while 而不是 if。

NSConditionLock(条件锁)

NSConditionLock 本质上是一个带状态值(condition)的互斥锁。它不仅能保证同一时间只有一个线程进入临界区,还能控制线程必须在某个状态下才能获得锁。

普通锁在lock之后只关心锁有没有被占用,NSConditionLock关心两个条件:1) 锁有没有被占用;2) condition 是否匹配。只有两个条件都满足才会继续执行。

和NSCondition的区别

NSCondition是我们自己管理状态,lock、wait、signal等操作全都是自己写,但NSConditionLock是系统帮我们管理状态,比如:

objc 复制代码
[lock lockWhenCondition:1];

...

[lock unlockWithCondition:2];
//系统内部自动wait、signal、状态判断

所以,NSCondition 更灵活,NSConditionLock 更简单。比如控制三个线程严格按顺序执行:

objc 复制代码
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSConditionLock *lock = [[NSConditionLock alloc] initWithCondition:0];
        dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

        //线程A:condition = 0 时可以执行
        dispatch_async(queue, ^{
            [lock lockWhenCondition:0];
            NSLog(@"A");
            [lock unlockWithCondition:1];
        });

        //线程B:condition = 1 时可以执行
        dispatch_async(queue, ^{
            [lock lockWhenCondition:1];
            NSLog(@"B");
            [lock unlockWithCondition:2];
        });

        //线程C:condition = 2 时可以执行
        dispatch_async(queue, ^{
            [lock lockWhenCondition:2];
            NSLog(@"C");
            [lock unlock];
        });

        [[NSRunLoop currentRunLoop] run];
    }
    return 0;
}

输出结果一定按顺序 A → B → C。因为A执行完 condition 变成 1,B 才能获得锁;B 执行完 condition 变成 2,C 才能获得锁。本质上类似一个状态机

NSConditionLock 最大的坑

由上我们知道了它的输出是严格受 condition 控制的,所以不可避免就引来了一个问题:如果一个线程只执行了 [lock lockWhenCondition:1] 但是没人执行 unlockWithCondition:1,这个线程就会一直阻塞------虽然这不是死锁(锁本身没有被循环等待),但 condition 条件永远不满足,效果类似于死锁。

所以 unlockWithCondition 很重要。如果前一个线程我们直接用 [lock unlock],condition 保持不变,下一个线程的 [lock lockWhenCondition:1] 可能永远也进不来。

unlock 只释放锁;unlockWithCondition 不仅释放锁,还会修改 condition 状态值

并且,NSConditionLock 不是递归锁,同一个线程第二次上锁还是会卡死,其内部依然是互斥锁逻辑。

源码层面最核心的是这一段:

c++ 复制代码
// 加锁时检查:锁被占用 或 condition 不匹配 → 等待
while (_thread != nil || _value != condition)
{
    [_cond wait];
}

意思是如果锁被占用或者condition不匹配那就等待,也就是我们之前说的那两个条件。

解锁部分:

c++ 复制代码
_value = condition;
[_cond broadcast];

修改状态,唤醒所有等待线程。

信号量 --- dispatch_semaphore

dispatch_semaphore 是 GCD 提供的信号量机制,在性能榜单中仅次于已废弃的 OSSpinLock,是实际开发中性能最高的线程安全方案之一

基本原理

信号量维护一个整数值计数器,提供了三个核心操作:

  • dispatch_semaphore_create(value) --- 创建信号量,初始值为 value
  • dispatch_semaphore_wait(sema, timeout) --- 计数器 -1;如果结果 < 0,阻塞当前线程
  • dispatch_semaphore_signal(sema) --- 计数器 +1;如果有线程在等待,唤醒一个

当初始值为 1 时,信号量等价于一把互斥锁:

objc 复制代码
dispatch_semaphore_t sema = dispatch_semaphore_create(1);

dispatch_async(queue, ^{
    dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);  // 值 -1 = 0,不阻塞
    // 临界区代码
    dispatch_semaphore_signal(sema);                       // 值 +1 = 1
});

当初始值大于 1 时,它可以控制最大并发数------这是信号量区别于其他锁的最大优势。

一:控制最大并发数

比如限制同时最多 3 个网络请求在执行:

objc 复制代码
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        dispatch_semaphore_t sema = dispatch_semaphore_create(3);
        dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

        for (int i = 0; i < 10; i++) {
            dispatch_async(queue, ^{
                dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
                NSLog(@"任务 %d 开始", i);
                sleep(2);  // 模拟耗时操作
                NSLog(@"任务 %d 完成", i);
                dispatch_semaphore_signal(sema);
            });
        }

        [[NSRunLoop currentRunLoop] run];
    }
    return 0;
}

运行结果:

我们发现同一时间只有 3 个任务在执行,第 4 个任务必须等到前面有任务完成之后才能开始。这就是信号量控制并发的经典用法。

二:异步操作转同步

objc 复制代码
- (NSString *)fetchDataSync {
    __block NSString *result = nil;
    dispatch_semaphore_t sema = dispatch_semaphore_create(0);

    [self fetchDataAsync:^(NSString *data) {
        result = data;
        dispatch_semaphore_signal(sema);
    }];

    dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
    return result;
}

创建 value=0 的信号量,wait 时值变为 -1 < 0,线程阻塞。异步回调完成后 signal 使值 +1 = 0,唤醒等待线程继续执行。这样就实现了"等待异步结果后同步返回"。

注意:千万不要在主线程这样做! 主线程被 wait 阻塞后,如果异步回调也需要主线程执行(比如回到主队列),就会形成死锁。

dispatch_semaphore 和 NSCondition 的对比

dispatch_semaphore NSCondition
底层 内核信号量 pthread_mutex + pthread_cond
控制方式 整数值计数器 条件判断 + 等待/唤醒
唤醒粒度 signal 唤醒一个等待线程 signal 唤醒一个 / broadcast 唤醒全部
最大特点 控制并发数 条件逻辑灵活
性能 更高 较低(多一层 mutex 封装)

性能为什么高

dispatch_semaphore 是 GCD 基于内核信号量高度优化的实现。它绕过了 pthread_mutex 的封装开销,直接使用更底层的同步原语。因此它在性能榜上仅次于已废弃的 OSSpinLock(自旋锁),是所有推荐使用的同步方案中性能最优秀的一批

dispatch_once --- 一次性执行

dispatch_once 是 GCD 提供的另一种线程安全机制,保证一段代码在整个应用生命周期中只执行一次,且线程安全。最常见的用途是实现单例模式:

objc 复制代码
+ (instancetype)sharedInstance {
    static MyClass *instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[MyClass alloc] init];
    });
    return instance;
}

底层原理

dispatch_once 内部基于原子操作实现,不是传统的"加锁":

  • 首次调用:执行 block,同时将 onceToken 原子地标记为"执行中"状态,block 执行完毕后再标记为"已完成"
  • 后续调用:原子读取 onceToken 的状态,发现已完成后直接返回,不加锁也不阻塞
  • 并发首次调用:如果多个线程同时首次调用,只有一个线程执行 block,其余线程阻塞等待它完成(内部用信号量等机制协调)

所以 dispatch_once 的性能非常高------完成之后每次调用只是一个原子读操作。

和锁的区别

普通锁是"每次访问都互斥",dispatch_once 是"只保证初始化这一次的互斥"。单例创建之后,后续所有对 sharedInstance 的调用都只需原子读取 onceToken 后直接返回,没有任何锁开销。

dispatch_once 是 Apple 推荐的线程安全单例写法,比 @synchronized + 静态标志位的方案更快,也比传统的 +load 方法中初始化更懒加载友好。

自旋锁

什么是自旋锁

自旋锁和互斥锁最大的区别在于:拿不到锁时,互斥锁让线程"睡觉等",自旋锁让线程"站着等"

互斥锁拿不到锁时,线程进入睡眠状态,CPU 可以去处理其他任务。等锁释放后,系统唤醒线程,这个过程涉及上下文切换,有一定开销。

自旋锁拿不到锁时,线程反复检查锁变量是否可用(while 循环),一直占用 CPU 但不让出时间片。好处是避免了上下文切换开销,坏处是空转浪费 CPU。

结论:自旋锁适合临界区代码非常短(微秒级)的场景;互斥锁适合临界区代码较长(毫秒级以上)的场景。

OSSpinLock(已废弃)

OSSpinLock 是 iOS 早期的自旋锁实现:

objc 复制代码
#import <libkern/OSAtomic.h>

OSSpinLock spinLock = OS_SPINLOCK_INIT;
OSSpinLockLock(&spinLock);
// 临界区
OSSpinLockUnlock(&spinLock);

为什么被废弃 ------ 优先级反转

这个问题相当经典,OSSpinLock 的问题在于"忙等待"机制和 iOS 的线程优先级调度产生了冲突:

  1. 一个低优先级线程先拿到自旋锁
  2. 一个高优先级线程随后尝试拿锁,开始忙等待(while 循环占满 CPU)
  3. 因为高优先级线程一直在跑,调度器不给低优先级线程分配 CPU 时间片
  4. 低优先级线程拿不到 CPU 就无法执行完临界区、无法释放锁
  5. 高优先级线程永远在等一个不会释放的锁 -> 优先级反转死锁

这就是 OSSpinLock 被弃用的根本原因------在 iOS 的多优先级调度环境下,它的忙等待策略天然不安全。

os_unfair_lock(OSSpinLock 的替代,但已不是自旋锁)

iOS 10 / macOS 10.12 之后,Apple 推出了 os_unfair_lock 作为 OSSpinLock 的 API 替代:

objc 复制代码
os_unfair_lock unfairLock = OS_UNFAIR_LOCK_INIT;
os_unfair_lock_lock(&unfairLock);
// 临界区
os_unfair_lock_unlock(&unfairLock);

os_unfair_lock 不是传统意义上的自旋锁。 竞争失败时,它不会像 OSSpinLock 那样 while 循环忙等待,而是让线程进入内核等待(休眠),行为上已经属于互斥锁的范畴。Apple 保留了 "lock" 的命名沿袭但改变了底层行为------本质上是用互斥锁的策略解决了 OSSpinLock 的优先级反转问题。

它的实现比完整的 pthread_mutex 更轻量,没有优先级继承等复杂特性(因此叫 "unfair"),适合临界区代码极短的场景。命名为 "unfair" 也暗示了它不保证公平性------等待时间最长的线程不一定最先获得锁。

atomic

atomic 是属性修饰符层面的线程安全机制------通过对 getter/setter 加锁,保证单次读写的原子性。

atomic 的底层实现随系统版本变化。 早期版本可能使用自旋锁,后续版本中 Runtime 内部同步机制经过了多次调整。作为开发者,我们不需要关心里面具体是 spinlock_tos_unfair_lock 还是其他同步原语------只需要理解:Atomic 通过 Runtime 内部的同步机制来保证 getter 和 setter 的原子性,不同系统版本的实现细节可能有差异,但对外保证的行为是一致的。

atomic 到底安不安全

这个问题的答案其实我们刚开始学习的时候就知道了,不安全,更完整的答案是:atomic 只能保证 setter/getter 的单次调用安全,不保证复合操作安全

objc 复制代码
@property (atomic, assign) NSInteger count;

// 安全------单次 setter 操作
self.count = 10;

// 安全------单次 getter 操作
NSInteger a = self.count;

// 不安全!------复合操作:读 → +1 → 写,三步之间都可能被其他线程插入
self.count = self.count + 1;

常见的不安全场景:

  • self.count += 1self.count++(读-改-写)
  • self.array = [self.array arrayByAddingObject:obj](读-改-写)
  • 对可变容器内容(如 NSMutableArray addObject:)没有任何保护

atomic 只保证属性指针的读写本身,不保证对象内容的安全。NSMutableArray 的 addObject: 不会因为属性被标记 atomic 而变得线程安全。

日常开发通常使用 nonatomic,因为在大多数 UI 操作都在主线程的场景下,atomic 的加锁开销反而拖慢性能,且给开发者一种"我已经线程安全了"的错觉。

读写锁

读写锁把访问者分为读者和写者:

  • 多个读者可以同时读(共享访问)
  • 写者写时独占(排斥其他读者和写者)
  • 读和写互斥

这种设计的核心思想是:读操作不会修改数据,多个读同时进行是安全的;但写操作必须独占。

在 iOS 中有两种实现方式:POSIX 的 pthread_rwlock_t 和 GCD 的 dispatch_barrier_async

pthread_rwlock_t

objc 复制代码
#import <pthread.h>

pthread_rwlock_t rwLock;
pthread_rwlock_init(&rwLock, NULL);

// 读
pthread_rwlock_rdlock(&rwLock);
// ... 读取操作 ...
pthread_rwlock_unlock(&rwLock);

// 写
pthread_rwlock_wrlock(&rwLock);
// ... 写入操作 ...
pthread_rwlock_unlock(&rwLock);

pthread_rwlock_destroy(&rwLock);

dispatch_barrier_async(栅栏函数)

这是实际开发中更常用的方案,利用并发队列 + 栅栏函数实现多读单写,我们来看下面这个例子

objc 复制代码
#import <Foundation/Foundation.h>

@interface ReadWriteDemo : NSObject
@property (nonatomic, strong) dispatch_queue_t concurrentQueue;
@property (nonatomic, strong) NSMutableDictionary *data;
@end

@implementation ReadWriteDemo

- (instancetype)init {
    if (self = [super init]) {
        _concurrentQueue =
        dispatch_queue_create("rw.queue",
                              DISPATCH_QUEUE_CONCURRENT);

        _data = [NSMutableDictionary dictionary];
    }
    return self;
}

- (void)read:(NSString *)key {

    dispatch_async(self.concurrentQueue, ^{

        NSLog(@"开始读 %@", key);

        sleep(2);

        NSLog(@"结束读 %@", key);
    });
}

- (void)write:(id)obj forKey:(NSString *)key {

    dispatch_barrier_async(self.concurrentQueue, ^{

        NSLog(@"========开始写========");

        sleep(2);

        self.data[key] = obj;

        NSLog(@"========结束写========");
    });
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        ReadWriteDemo *demo =
        [[ReadWriteDemo alloc] init];

        // 三个读
        [demo read:@"A"];
        [demo read:@"B"];
        [demo read:@"C"];

        // 一个写
        [demo write:@"Tom"
             forKey:@"name"];

        // 再来两个读
        [demo read:@"D"];
        [demo read:@"E"];

        [[NSRunLoop currentRunLoop] run];
    }
    return 0;
}
@end

运行结果:

这里就很好的展示了barrier 会拦住后面的任务,等待前面的任务执行完

说明了写执行期间,后面的读全部被阻塞

栅栏函数的执行规则

  • dispatch_barrier_async 提交的 block 会等待当前队列中所有已提交的 block 执行完毕再执行
  • 在这个 barrier block 执行期间,队列不会调度新的 block
  • barrier block 执行完后,队列恢复正常调度

所以写入时能保证:所有之前的读取都已完成,所有后续的读取都被拦截,写操作真正"独占"。

两种方案对比

pthread_rwlock_t dispatch_barrier_async
层级 POSIX 底层 API GCD 高层封装
用法复杂度 需手动 init/lock/unlock/destroy 只需创建并发队列
读操作 rwlock_rdlock 可能阻塞 dispatch_sync 等待返回
写操作 rwlock_wrlock 阻塞等待 dispatch_barrier_async 异步不阻塞调用线程
适用场景 需要阻塞式同步返回 异步写 + 同步读的典型场景

在实际 iOS 开发中,dispatch_barrier_async 方案更常见,因为它和 GCD 生态天然融合,且异步写不阻塞当前线程。

总结与选型

性能对比总表

底层 类型 递归 性能
OSSpinLock(废弃) 自旋忙等待 自旋锁 最高(但已废弃)
os_unfair_lock 休眠等待 互斥变体 极高
dispatch_semaphore 内核信号量 信号量 --- 极高
pthread_mutex(NORMAL) pthread 非递归互斥
NSLock 封装 pthread_mutex 非递归互斥
pthread_mutex(RECURSIVE) pthread 递归互斥 中高
NSRecursiveLock 封装 pthread_mutex 递归互斥 中高
NSCondition pthread_mutex + pthread_cond 条件锁
NSConditionLock 封装 NSCondition 条件锁 中低
@synchronized recursive_mutex + 哈希表 递归互斥 最低

场景选型速查

场景 推荐方案 原因
简单临界区保护 NSLock 易用,性能好
嵌套/递归加锁 @synchronized 或 NSRecursiveLock 支持同一线程重入
追求极致性能(短临界区) os_unfair_lock 最轻量的互斥实现
控制最大并发数 dispatch_semaphore 唯一支持并发数控制
生产者-消费者/等待条件 NSCondition 条件变量灵活
多线程按顺序执行 NSConditionLock 条件值匹配天然适合
多读单写 dispatch_barrier_async GCD 生态融合好
异步转同步 dispatch_semaphore(0) 简单直接
单例初始化 dispatch_once 一次执行,后续无锁开销
快速开发/不追求极致性能 @synchronized 写法最简单、自带异常保护

互斥锁 vs 自旋锁 一句话总结

拿不到锁时 CPU 消耗 适用场景
互斥锁 睡眠等待 低(CPU 可以干别的) 临界区耗时较长
自旋锁 while 循环等 高(空转浪费 CPU) 临界区极短(微秒级)

死锁的四个必要条件

这是 iOS 多线程面试的基础知识:

  1. 互斥条件:资源只能被一个线程持有
  2. 持有并等待:线程已经持有一个资源,又在等待另一个资源
  3. 不可剥夺:资源不能被强制拿走,只能由持有者主动释放
  4. 循环等待:线程 A 等 B 的资源,B 等 C 的资源,C 等 A 的资源,形成闭环

四个条件同时满足才发生死锁。破坏任意一个即可预防。

面试高频问题

Q1: @synchronized(nil) 会怎样?

不会加锁。objc_sync_enter(nil) 什么都不做,临界区完全暴露给并发访问。这是最常见也最隐蔽的 bug------锁对象不小心变成 nil 后,开发者以为还在加锁保护,实际上锁完全失效。

Q2: 下面代码线程安全吗?

objc 复制代码
@property (atomic, assign) NSInteger count;
self.count = self.count + 1;

不安全。atomic 只保证单次 getter/setter 原子性,self.count + 1 是读-改-写三步,中间可以被其他线程插入。需要用锁保护整个复合操作。

Q3: OSSpinLock 为什么被废弃?

优先级反转。低优先级线程持锁 → 高优先级线程忙等待占满 CPU → 调度器不给低优先级线程时间片 → 锁永远不释放 → 高优先级线程饿死。

Q4: NSLock 和 NSRecursiveLock 有什么区别?

NSLock 是非递归锁,同一线程重复 lock 会死锁。NSRecursiveLock 是递归锁,内部维护计数器,同一线程可以多次 lock,但 lock 和 unlock 次数必须一一对应。

Q5: dispatch_semaphore 和 NSCondition 的区别?

信号量是计数器机制,适合控制并发数、简单的线程同步。NSCondition 是条件变量机制,信号量不关心"条件"是什么,NSCondition 天然适合生产者-消费者模型。性能上信号量更高。

Q6: 自旋锁和互斥锁什么时候各用哪个?

临界区代码非常短(几微秒到几十微秒)用自旋锁,因为上下文切换开销比空转更贵。临界区代码较长(毫秒级或更久)用互斥锁,让出 CPU 做有意义的工作。iOS 开发中大部分场景推荐用互斥锁。

Q7: @synchronized 性能最低,为什么还大量使用?

因为方便。写法简洁、自动匹配 lock/unlock、@finally 保证异常安全、支持递归重入、不需要手动管理锁对象生命周期。在没有性能瓶颈的场景下,代码可读性和健壮性比性能更重要。

总结

iOS 锁种类多,从底层往上数:只有三种基本锁:互斥、自旋、读写锁。

其余都是封装产物:条件锁、递归锁、信号量、一次性。底层都靠原子操作撑着,上层各自解决不同的问题。

对于锁我们实际应用中该怎么选择:

简单用 NSLock,嵌套用 @synchronized;

等条件用 NSCondition,按顺序用 NSConditionLock;

控制并发用 dispatch_semaphore,单例用 dispatch_once;

多读单写用 dispatch_barrier,追求极致用 os_unfair_lock。

90% 的场景 NSLock 够用,碰到递归才换 @synchronized。

锁 vs 串行队列

还有一个小疑问对比------既然串行队列也能保证线程安全,为什么要用锁?

串行队列
原理 互斥:拿到锁才能进去 排队:任务按顺序一个一个执行
粒度 保护某一段临界区代码 保护整个队列的所有任务
性能 临界区外不受影响,并发度高 所有任务串行化,并发度低
适合场景 只保护少量关键代码 整个业务流程必须顺序执行
常见例子 多线程修改同一个数组 数据库读写、文件操作

一句话:锁保护的是"点",串行队列保护的是"线" 如果只有一两行关键代码需要保护,用锁;如果整个业务逻辑天然就是顺序依赖的,用串行队列。

锁的本质很简单------用性能换安全,关键在于根据场景选对锁、用对方式从而避开一些坑。shuo