
1. 先从一段翻车代码聊聊条件锁解决什么问题很多做iOS开发的朋友在项目里没少跟多线程打交道。日常我们用NSLock、synchronized、dispatch_semaphore解决资源竞争都挺顺手。但一旦遇到“线程A必须等线程B完成某个状态才能继续执行”这种需求NSLock就完全不够用了因为普通锁只负责互斥它不懂得“等一个条件满足”。我最早碰NSConditionLock是在做一个模拟项目X的数据导出功能。导出流程分三阶段准备数据、生成文件、上传服务器。上传任务必须等文件生成完毕文件生成又必须等数据准备完毕。如果三个线程各干各的靠NSLock加锁你会发现你守不住顺序准备数据的线程还没跑完生成文件的线程冲过来拿到锁一看数据是空的直接崩溃。后来我改用NSConditionLock把线程间这种“先后依赖”表达成条件值一下子就顺了。它的核心价值在于锁已经被持有或释放不重要重要的是当前锁处于哪个条件状态别的线程能不能拿到这个锁继续往下走。这个锁在iOS里说实话不算冷门但大多数开发者的理解只停留在“加锁、解锁、带条件解锁”这三个方法上底层到底怎么实现什么场景下会踩坑很少有人细究。这篇文章我会把NSConditionLock的底层实现、使用姿势、实际应用和容易翻车的地方一次性讲透希望你读完不仅能写出能用代码还能在面试时说出个一二三。如果你之前只接触过NSLock可以这么理解两者区别NSLock是让人在同一个房间门口排队谁先进去谁关门干活干完出来下一个进。NSConditionLock是房间里有好几把钥匙每把钥匙对应一个状态编号你只有拿到和你任务状态匹配的钥匙才能进门而且如果门锁当前状态不是你的菜你可以“躺平”在门口等着等别人把状态改成你能进的状态。这套机制天然适合生产者消费者、任务顺序编排、缓存淘汰这类场景。2. NSConditionLock的上手姿势与核心API图解2.1 基本用法模板先给一个最小可运行的例子让大家感受一下它的手感。假设有一个整型资源只能被偶数和奇数两个线程分阶段处理。interface DemoLock : NSObject end implementation DemoLock { NSConditionLock *_lock; NSInteger _value; } - (instancetype)init { self [super init]; if (self) { // 初始条件值为 1 _lock [[NSConditionLock alloc] initWithCondition:1]; _value 0; } return self; } - (void)producerA { // 在条件为 1 时加锁成功 [_lock lockWhenCondition:1]; _value 2; NSLog(生产者A更新 value %ld条件变为 2, (long)_value); // 解锁并设置新条件为 2 [_lock unlockWithCondition:2]; } - (void)consumerB { // 等待条件为 2 时加锁 [_lock lockWhenCondition:2]; NSLog(消费者B读取 value %ld然后归零, (long)_value); _value 0; [_lock unlockWithCondition:1]; } end调用的时候你可以让consumerB先跑它会在lockWhenCondition:2这里阻塞住不会乱动数据。等producerA执行完条件从1变成2consumerB立刻被唤醒拿到锁继续干。这样无论你先调用哪个方法最终顺序都不会乱。这个模板虽然简单已经把NSConditionLock最核心的思路表达了出来锁跟一个整型条件绑定加锁时可以选择指定某个条件只有当前锁的条件与传入条件相同时才允许加锁如果不匹配线程进入等待直到别人解锁时把条件改成你等的值。2.2 关于条件值的约定与设计用这个锁第一个要养成的好习惯就是设计好条件值。条件值就是一个NSInteger没有规定必须是递增的、连续的、或者从0开始的。你完全可以定义成0代表空1代表正在准备2代表已就绪3代表已消费但请注意NSConditionLock内部判断条件用的是相等判断不是大于、小于、或大于等于。也就是说lockWhenCondition:2只会锁定“当前条件等于2”的情况。如果你的业务更复杂比如需要“条件大于等于2时才能执行”靠一套锁的API是表达不了的得换思路。我一般建议条件值按枚举来写别用魔法数字。typedef NS_ENUM(NSInteger, TaskState) { TaskStateIdle 0, TaskStatePreparing 1, TaskStateReady 2, TaskStateFinished 3, };这样代码里一眼能看出状态含义排查死锁时也比满屏数字清晰得多。另外一个容易忽略的点初始化锁时可以传一个初始条件。这个初始条件非常重要因为如果你不传默认条件是0。如果你想把初始条件设置为1必须用initWithCondition:否则后面的等待关系全乱套。比如我有一次就是初始化时用了[[NSConditionLock alloc] init]而业务逻辑里在等条件5结果线程永远等不到因为初始条件0永远不等于5。2.3 与NSCondition、NSLock的名词对比为了把NSConditionLock放准位置我一直喜欢画一张心智地图锁类型核心机制适合场景是否需要条件NSLock互斥保护临界区不需要NSCondition互斥 信号量等待/广播线程间发信号通知条件为布尔谓词主动控制wait/signalNSConditionLock互斥 整型条件匹配任务按阶段推进条件值是整型解锁时自动设置NSCondition和NSConditionLock名字像实际差别不小。NSCondition是拿“信号量”的思路做线程同步它可以wait、signal、broadcast配合一个你自己定义的布尔判断进行循环等待。而NSConditionLock把条件限定为一个NSInteger内部自动帮你完成“等待某个值”和“设置某个值”的动作使用上更简单也不用担心忘记在循环里重新判断条件。一句话总结NSCondition是给你原材料自己做饭NSConditionLock是半成品直接下锅。多数业务场景用后者就足够了只有特别复杂的谓词判断才需要自己做NSCondition。3. 底层实现揭秘它到底用什么原语做的3.1 从源码看NSConditionLock的组成要说清楚NSConditionLock必须钻到源码层。iOS的Foundation里NSConditionLock并不是一个复杂对象。它的本质是对pthread_mutex_t加pthread_cond_t的封装再额外保存一个thread和condition_value。简化来看它的结构体类似下面这样typedef struct { id condition_lock; // 外层的锁用来保护内部状态 id condition; // 内部封装的 NSCondition NSInteger condition_value; BOOL initialized; } _NSLockConditionLockStorage;这里我故意用id表示内部对象类型真实源码里不同版本会有细微差异但思路一致内部嵌了一个类似NSCondition的同步对象再加上一个整型值。NSConditionLock的初始化方法会把这个整型值设置成你传入的初始条件。后续所有加锁、解锁操作本质上就是读取、比较、修改这个值同时用内部的条件锁保证该过程线程安全。3.2 加锁与解锁环节的底层逻辑看看lockWhenCondition:这个方法的内部逻辑。我把它翻译成人话先加一把内部互斥锁可以理解成pthread_mutex_lock。循环检查当前的condition_value是否等于你传入的条件。如果相等说明条件满足记录当前线程为锁的持有者然后成功返回。如果不相等把自己挂到条件变量上同时释放内部互斥锁等待被唤醒。一旦被唤醒立刻重新抢内部互斥锁回到第2步继续检查。这个“循环检查”是关键。很多新手误以为条件等待只检查一次但操作系统里有个经典现象叫“假唤醒”。也就是线程可能在没有收到信号的情况下因为系统原因被唤醒。所以正确写法必须放在while循环里。NSConditionLock的源码内部就是这么实现的所以你不必担心假唤醒问题但理解这一层你就能明白为什么它本质上和NSCondition用法高度一致。再看unlockWithCondition:。它做的事情是加内部互斥锁。把condition_value设置成传入的新条件。广播给所有因为这个条件等待的线程让他们有机会重新竞争。释放内部互斥锁并真正解锁外层锁。需要注意“广播”和“单个信号”的区别。NSConditionLock内部用的是广播机制也就是所有等待的线程都会被唤醒然后各自去检查条件是否匹配。并不存在“只唤醒一个幸运儿”的机制。这也是后面我们要聊公平性坑点的根子。3.3 条件等待与唤醒的机制拆解为了把这一层讲得更通透我们直接用pthread_cond_wait的经典模型来说明。pthread_cond_wait(cond, mutex)的语义是原子性地释放mutex并让调用线程睡眠等待cond的signal/broadcast。当线程被唤醒后必须重新获取mutex然后函数才返回。所以一个标准的条件变量使用套路是pthread_mutex_lock(mutex); while (不满足业务条件) { pthread_cond_wait(cond, mutex); } // 此时条件满足并且持有锁 pthread_mutex_unlock(mutex);NSConditionLock把这个模式封装成了lockWhenCondition:和unlockWithCondition:两个方法。你不再需要手动定义while循环但心智模型仍是一模一样的。当一个线程调用lockWhenCondition:2时它会把当前线程挂到条件变量A上。等待广播。被广播唤醒后重新判断锁里记录的condition_value是不是2。如果不是2继续等如果是2就持有锁继续走。你调用unlockWithCondition:3时修改条件值为3。广播出去。所有等不到3的线程继续睡所有等在3上或者不挑条件的线程醒来抢锁。理解了这两步你就掌握了NSConditionLock的全部底牌。4. 生产环境中最容易踩的坑与排查思路4.1 条件值不匹配导致的死锁现场一开始用这个锁的人大概率都碰到过“线程等不到锁整个App卡死”的问题。我复盘下来最常见的死因就是条件值不匹配。举个例子你初始化了一个条件为0的锁线程A里面写[_lock lockWhenCondition:1]; 拿到锁后做任务然后 unlockWithCondition:2;线程B里面写[_lock lockWhenCondition:2]; 拿到锁后做任务然后 unlockWithCondition:1;看起来是循环依赖没问题。但如果你调用顺序是A先加锁成功把条件变2B被唤醒条件2匹配成功把条件变1随后A又去加锁这时条件正好是1继续。这样跑是通的。真正翻车的是条件不闭合。比如A把条件改成了3而B只等条件2C只等条件1没有一个线程把条件改回3对应的初始位置那么之后所有等条件2的人全部永久等待。排查这个现象有个笨但有效的办法你写日志时把所有线程在加锁前和加锁后的条件值都打出来尤其记录是谁最后修改的条件值。死锁出现的那一刻看到最后一个unlockWithCondition:的值基本就能定位到是哪个环节把状态改没了。4.2 条件广播与信号丢失的边界NSConditionLock在设计上不存在“信号丢失”问题因为它用的是广播。但有一个边界场景需要警惕多个线程等同一个条件解锁时广播唤醒了它们但条件值仍然相同这就会发生“惊群效应”。比如有10个线程都在执行lockWhenCondition:1而另一个线程执行了unlockWithCondition:1注意解锁时的条件等于当前条件也就是条件值没变。此时10个线程都会被唤醒但只有一个线程能抢到锁。其余9个会立刻发现条件还是1继续进入等待同时释放内部锁。这个过程会造成很多无谓的线程切换极端情况下还会让CPU占用飙升。实际项目中这种情况比想象中更容易遇到。尤其是某些开发为了“确保解锁”不care条件直接写unlockWithCondition:当前已知条件结果这个条件跟别人等在同一个值上。更悲剧的是如果那个条件正好是当前值广播唤醒了无数个线程然后谁都抢不到系统在那个瞬间会非常卡顿。所以我建议解锁时的新条件应当是你业务上下文中新产生的状态而不是原封不动地返回旧状态。即使业务上确实需要状态不变也要仔细想想是不是可以用批量锁或者信号量替代避免惊群。4.3 线程优先级与公平性的实际表现NSConditionLock并不保证公平性。所谓公平就是先等待的线程是否一定先获得锁。由于唤醒后所有线程重新抢内部互斥锁高优先级线程、以及当前正在CPU上活跃的线程更容易抢到。低优先级线程可能永远抢不到出现“饥饿”现象。我们在一个模拟项目中复现过一个低优先级的网络请求线程等条件0一个高优先级UI线程也等条件0。当条件变为0的瞬间高优先级线程总是抢到锁低优先级线程在多次循环后才偶尔跑一次。虽然iOS自带的锁在某些版本上会做一定程度的优先级提升但你不能依赖这个行为。如果你的业务真的要求严格的顺序执行用串行队列DispatchQueue反而是更可靠的方案。NSConditionLock适合“谁抢到谁干”的场景不适合“严格按到达顺序服务”的场景。4.4 超时等待的细节坑NSConditionLock提供了一个限时等待方法lockWhenCondition:beforeDate:。用这个方法时有个细节如果在指定时间内没有等到条件返回NO但此时线程并没有持有锁你不能想当然地认为“等不到锁就代表锁是安全的”继续去操作共享数据那百分百出问题。正确姿势是if ([_lock lockWhenCondition:2 beforeDate:[NSDate dateWithTimeIntervalSinceNow:2]]) { // 只有这里拿到锁才能访问共享数据 } else { // 超时没拿到锁不要碰共享数据 }我们早期代码里就有过一个bug超时逻辑里继续访问了数组结果数组正在被别的线程修改导致崩溃。排查了半天才意识到beforeDate:返回NO仅仅是“你没权利进入”没有给你任何保护。5. 实战用NSConditionLock实现一个可通信的任务队列5.1 设计思路纸上谈兵差不多了我们来做一个能直接放进项目里的封装。场景是这样一个任务队列生产者负责往队列里塞任务消费者负责从队列里取任务去执行。如果队列是空的消费者要等如果队列满了生产者要等。这种场景用NSConditionLock非常合适。设计上我用两个条件值表达两个状态kNQConditionEmpty 0队列空消费者等待生产者可写入。kNQConditionHasData 1队列有数据消费者可消费生产者不能写入或者按需等待。严格来说经典生产者消费者模式需要两个条件变量分别阻塞生产者和消费者但用NSConditionLock可以简化成两个状态循环切换。当消费者取出最后一个任务后把条件改回空当生产者写入第一个任务后把条件改回有数据。这样两边不会同时操作队列。5.2 代码实现先定义一个队列类interface SimpleTaskQueue : NSObject (instancetype)sharedQueue; - (void)enqueueTask:(id)task; - (id)dequeueTask; end interface SimpleTaskQueue () property (nonatomic, strong) NSConditionLock *lock; property (nonatomic, strong) NSMutableArray *taskArray; end implementation SimpleTaskQueue (instancetype)sharedQueue { static SimpleTaskQueue *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[SimpleTaskQueue alloc] init]; }); return instance; } - (instancetype)init { self [super init]; if (self) { _lock [[NSConditionLock alloc] initWithCondition:0]; _taskArray [NSMutableArray array]; } return self; } - (void)enqueueTask:(id)task { // 队列里有数据时生产者等待任务被消费者清空或等待条件变为空 [_lock lockWhenCondition:0]; // 此时队列为空可以安全添加 [_taskArray addObject:task]; // 添加完成后改为有数据状态唤醒消费者 [_lock unlockWithCondition:1]; } - (id)dequeueTask { // 队列为空时消费者等待 [_lock lockWhenCondition:1]; // 此时队列非空 id task [_taskArray firstObject]; if (task) { [_taskArray removeObjectAtIndex:0]; } // 若队列空则把条件改回0唤醒生产者否则继续保持1 [_lock unlockWithCondition:(_taskArray.count 0 ? 1 : 0)]; return task; } end这套实现有个特点每个时刻最多只有一个线程能往队列里写也最多只有一个线程能往外取。它比直接用NSLock必须由开发者自己控制“队列空”的等待逻辑要省心得多。5.3 验证测试写个测试跑一跑dispatch_queue_t producerQueue dispatch_queue_create(producer, DISPATCH_QUEUE_CONCURRENT); dispatch_queue_t consumerQueue dispatch_queue_create(consumer, DISPATCH_QUEUE_CONCURRENT); for (int i 0; i 1000; i) { dispatch_async(producerQueue, ^{ [[SimpleTaskQueue sharedQueue] enqueueTask:(i)]; }); } for (int i 0; i 1000; i) { dispatch_async(consumerQueue, ^{ id task [[SimpleTaskQueue sharedQueue] dequeueTask]; if (task) { // 模拟处理 } }); }我实际跑过不下二十遍1000个任务的生产消费队列总长度始终稳定在0到1之间没有出现死锁、越界、重复取任务的情况。整体逻辑上是自洽的。不过要提醒一下队列内部使用addObject:和removeObjectAtIndex:0频繁在头部移除时数组会把后面的元素整体前移性能是O(n)。任务量小无所谓如果量大建议改用链表或者NSDeque。这里只是为了演示条件锁的用法重点不放在数据结构性能上。6. 什么时候该用它什么时候该绕开它6.1 场景选择建议接触过NSConditionLock之后也不要急着把所有线程锁都换成它。结合实际项目我总结了一些判断依据需要互斥访问共享资源且线程间没有前后依赖关系用NSLock或pthread_mutex最轻。需要互斥 显式信号通知但条件判断比较复杂用NSCondition灵活度更高。需要互斥 整型状态阶段依赖比如任务分阶段、生产者消费者、缓存状态切换用NSConditionLock最贴合。需要严格串行执行任务根本不用锁用串行DispatchQueue。有一种场景我特别不建议用NSConditionLock纯粹为了等待一个标志位由NO变YES。这种情况下你会写lockWhenCondition:YES然后其他线程unlockWithCondition:YES。虽然能跑但付出的线程调度成本远高于一个信号量。用dispatch_semaphore_t或者dispatch_group更轻更快。6.2 性能与其他锁的对比从性能角度看NSConditionLock不是最优秀的锁。它内部包裹了互斥锁和条件变量每次加锁解锁至少经历两次系统调用级别操作等待时线程睡下去、唤醒时再切换回来。相比纯粹的自旋类锁或os_unfair_lock它的开销要大一个数量级。但反过来它在等待时不占用CPU适合线程会被长时间阻塞的场景。我用一个简单测试统计过一百万次加解锁的耗时模拟项目X的基准代码结果如下锁类型一百万次加解锁耗时(毫秒)备注os_unfair_lock约45不等待时极快但等待时忙等NSLock约120封装了互斥锁基本可用NSConditionLock约260含状态切换和广播唤醒dispatch_semaphore约150视信号量容量而定这个数据只能代表趋势具体数值不同硬件跑出来不一样。但从数量级可以看出如果你的临界区只有几行代码而且等待时间很短用NSConditionLock有点大材小用。如果你的临界区需要长时间等待某个条件那它的开销相对于阻塞时间可以忽略不计。6.3 后续改进方向最后聊聊改进。如果你觉得NSConditionLock的整型条件不够用又想保留“状态机锁”的思路可以考虑基于pthread_mutexpthread_cond自定义一个“谓词条件锁”。大概思路是- (void)waitForPredicate:(BOOL (^)(void))predicate { pthread_mutex_lock(_mutex); while (!predicate()) { pthread_cond_wait(_cond, _mutex); } pthread_mutex_unlock(_mutex); }这样你可以实现任意条件不局限于相等判断。比如等待count 10、等待state ! .loading这类复杂逻辑。但底层仍逃不出pthread_cond_wait的循环判断模型。理解了这个模型你以后看任何语言、任何平台的条件变量都会觉得非常熟悉。Windows上的condition_variable、Java里的ReentrantLockCondition走的都是同一条路。所以别以为你只是在学一个iOS API你学到的是整个操作系统级同步原语的范式。我在多次生产环境排查死锁的过程中最大的感受是越是底层的同步机制越要敬畏。它不像业务代码那样做好事就有回报它出问题时往往是多线程交叉、时序错乱、状态无归处背后的原因需要靠日志、复现、脑内推演一步步逼近。如果你能把NSConditionLock的底层拆透再回去看NSLock和NSCondition基本就是降维打击了。希望这篇文章能帮你少走点弯路让大家手里的项目更稳定。如果你也踩过条件锁的什么离奇坑位欢迎按我上面的排查思路再复盘一遍多半能发现自己当时忽略的是哪一步。