
做 iOS 开发锁是绕不过去的坎。从synchronized到NSLock、NSRecursiveLock再到pthread_mutex基本能解决绝大多数多线程互斥问题。但如果你遇到的是“任务之间有依赖关系必须等某个条件满足才能继续往下走”这种场景互斥锁就有点力不从心了。这时候就需要条件锁NSConditionLock上场。这篇笔记是线程锁系列的第四篇专门拆解NSConditionLock。先说明一个常见小坑这个类名正确写法是NSConditionLock中间字母 c 是小写 c很多人在笔记、代码注释、甚至命名文件时随手写成NSconditionLock编译也能过因为 OC 对大小写敏感如果真的写错会直接报错但项目里驼峰拼写不规范容易误导其他人。这个类在 iOS 底层是基于pthread_mutex和pthread_cond条件变量封装的它把“锁”和“条件判断”绑在了一起让开发者能用很直观的lockWhenCondition:这样一段代码实现复杂的线程协作。这篇文章适合两类人一类是刚接触多线程、被各种锁绕晕的新手想搞懂条件锁到底怎么用、和NSCondition有什么区别另一类是已经在项目里用过条件锁但遇到死锁、条件不满足、唤醒丢失等诡异问题想从源码层面把原理彻底吃透的进阶开发者。我尽量把底层实现和实操场景都讲清楚配着代码一起看会比啃源码高效得多。1. 从需求倒推到底什么时候非用条件锁不可1.1 互斥锁的边界它能保证“不同时”但保证不了“有先后”先想一个很简单的业务场景直播间里用户发送弹幕弹幕需要先经过内容审核接口审核通过后才能显示到公屏。如果用NSLock保护弹幕数组确实能保证多个线程不会同时写数组但解决不了“审核完成后才能显示”这个顺序依赖。你可能写这样的代码- (void)handleBarrage:(NSString *)text { [_lock lock]; // 这里只能保护共享资源无法等待审核完成 [_barrageArray addObject:text]; [_lock unlock]; }问题很明显审核是一个异步过程发弹幕的线程根本不知道审核什么时候完成。如果强行轮询检查审核状态既浪费 CPU又容易漏掉状态变化。互斥锁就像一个“单间厕所”——一次只能进一个人但外面排队的人并不知道厕所里的人什么时候办完事。条件锁则更像是“叫号系统”线程可以带着一个条件去等待直到有人广播“符合条件的可以进了”。这就是NSConditionLock的核心价值它比互斥锁多了一个“条件维度”线程不是盲目抢锁而是先检查条件值是否匹配不匹配就挂起等待。这种机制天然适合生产者-消费者模型、任务依赖链、缓存预加载这类场景。1.2 NSConditionLock 的设计直觉给锁贴上“门牌号”我第一次看NSConditionLock的文档时第一反应是这玩意儿也太绕了。后来把它类比成“带门牌号的房间”就好理解了锁本身有一把同时还有一个整数值condition相当于房间的门牌号。调用lockWhenCondition:时线程会说“我要求门牌号等于某值才能让我进去。”如果不等于线程就在门口挂起等待有人改变门牌号后通知它。unlockWithCondition:则是离开房间前顺手把门牌号改成新值并唤醒正在等待的线程。这个设计把“条件”抽象为一个整数而不是一个布尔判断。这个整数怎么定义完全由业务决定0 表示任务未完成1 表示任务完成1 表示缓冲区有空位2 表示缓冲区已满诸如此类。和pthread_cond_waitpthread_cond_signal的底层机制相比NSConditionLock最大的优势是封装了“检查条件并等待”这个原子过程。底层条件变量最坑的鬼打墙是线程先检查条件发现不满足正要去睡眠时条件恰好被另一个线程满足并且发出了信号导致这个线程永远睡过去。条件锁帮你把“检查条件、持有锁、睡眠、释放锁”这几个动作绑成了一个原子操作从源头避免这种唤醒丢失问题。2. API 全解析与核心用法2.1 初始化initWithCondition: 的语义NSConditionLock的初始化方法只有一个NSConditionLock *lock [[NSConditionLock alloc] initWithCondition:0];这个condition:参数代表锁的初始条件值。你可以理解为房间刚开始挂的门牌号是 0。初始化完成后锁是未持有的状态但条件值已经存在。这里有个很多人忽视的细节初始条件值决定了第一批线程能否立即拿到锁。举个例子如果初始化条件设为 1那么一个线程直接调用普通的lock方法就能立刻拿到锁并进入临界区。但如果它调用lockWhenCondition:2即使此刻没有其他线程持有锁它也会被挂起因为当前条件值不匹配。条件锁的“锁”和“条件”是两套独立的状态机锁的占用状态决定“能不能进房间”条件值决定“门牌号是否符合要求”。只有两个条件同时满足线程才能进入临界区。设计初始条件值的时候建议把它定义成你业务里最早的那个状态。比如任务刚开始执行初始条件为 0任务执行完成条件为 1。这样一开始等待条件 1 的消费者会正确地进入等待状态而不是凑巧“白嫖”了锁。2.2 lockWhenCondition: 和 unlockWithCondition: 是一对“情侣方法”lockWhenCondition:的完整语义是- (void)lockWhenCondition:(NSInteger)condition;调用后线程会反复检查两件事锁是否被占用条件值是否等于传入的condition。任何一个不满足线程就阻塞。只有两个条件同时满足方法才返回线程正式持有锁。对应的释放方法- (void)unlockWithCondition:(NSInteger)condition;它做两件事释放锁把锁的条件值设为新值。这两步也是原子的。为什么需要这个方法而不是先unlock再修改条件值因为如果分开做可能出现竞态线程 A 先unlock线程 B 立刻抢到锁看到了一个旧的、未更新的条件值做了错误的判断。unlockWithCondition:把“释放锁”和“更新条件值”打包让其他线程一拿到锁就能看到最新的条件值。这里有个经典的配对陷阱。如果你用lockWhenCondition:1拿到了锁但释放时用了unlockWithCondition:2那么原本在等条件 1 的另一批线程就会被莫名唤醒。示例如下- (void)consumerWork { [_lock lockWhenCondition:1]; // 读取数据处理数据... // 错误示范应该根据业务语义修改条件值 [_lock unlockWithCondition:2]; }假设消费者只有一个用完数据后应该把条件恢复成“缓冲区有空位”而不是随手改成 2。条件值一定是你业务状态的映射不是随便传递的。我建议在项目里用枚举定义条件值typedef NS_ENUM(NSInteger, BufferState) { BufferStateEmpty 0, BufferStateFull 1, };这样unlockWithCondition:的参数语义就非常清晰不会出现“魔法数字”导致的连环误唤醒。2.3 普通 lock/unlock 与 tryLock 家族不要忘记它们的存在除了lockWhenCondition:和unlockWithCondition:NSConditionLock还提供了一组普通方法- (void)lock; - (void)unlock; - (BOOL)tryLock; - (BOOL)tryLockWhenCondition:(NSInteger)condition;lock方法会忽略条件值只要锁没被占用就立刻拿到并原封不动保留当前的条件值。unlock则只释放锁不修改条件值。这两个方法的存在意味着你可以在条件锁上执行“无条件”的临界区代码。tryLockWhenCondition:特别有意思。它和lockWhenCondition:的区别是不阻塞立即返回BOOL。如果条件值匹配且锁未被占用返回YES并持有锁否则返回NO什么都不做。这种非阻塞变体适合在“能拿到就干活拿不到就干别的”的场景使用例如在列表滚动时试图预加载下一批数据如果锁被占用就跳过这轮加载避免卡顿。注意一个细节tryLock不带条件版本在锁被其他线程持有但条件值恰好匹配时会返回NO不会干等。所以它更适合做“机会型”任务而不是“必须等”的任务。如果你需要一个带超时的版本NSConditionLock本身不提供需要用dispatch_semaphore_wait或者pthread_mutex_timedlock实现类似需求。3. 底层实现原理解密3.1 从 GNUstep 源码看 NSConditionLock 的真身由于NSConditionLock在 Foundation 框架里的实现没有完全开源我最初是通过 GNUstep 的源码推断它的实现结构的。GNUstep 是 Foundation 框架的开源替代实现内部逻辑和 Apple 版本高度相似。GNUstep 中NSConditionLock的核心成员只有两个{ NSInteger _condition_value; NSCondition *_condition; }也就是说它内部持有一个NSCondition对象NSCondition封装了pthread_mutex_tpthread_cond_t和一个NSInteger类型的条件值。这个结构非常朴素但极其关键条件值只是一个普通整数所有复杂逻辑都建立在“对这个整数的读写受到锁保护”这一基础上。我们拆解lockWhenCondition:的源码GNUstep 版有删减- (void)lockWhenCondition: (NSInteger)condition { [_condition lock]; while (condition ! _condition_value) { [_condition wait]; } // 走到这里条件满足且锁在手中 }等等你可能注意到一个问题条件满足后代码没有显式释放锁这里的技巧在于NSCondition的wait方法在睡眠时会释放pthread_mutex被唤醒后会重新获得锁。while 循环每次都重新检查条件如果条件不满足会再次wait再次释放锁直到条件满足跳出循环。跳出循环时线程仍然持有pthread_mutex也就是锁还在手中。所以lockWhenCondition:返回后你已经持有了锁可以直接进入临界区。对应的释放方法- (void)unlockWithCondition: (NSInteger)condition { [_condition lock]; _condition_value condition; [_condition broadcast]; [_condition unlock]; }这段代码同样清晰先拿到锁pthread_mutex修改条件值然后broadcast唤醒所有等待线程最后释放锁。看到这里你应该明白NSConditionLock本质上就是NSCondition 条件值判断的语法糖。3.2 条件变量的“广播”语义为什么是 broadcast 而不是 signalNSCondition的broadcast方法对应底层pthread_cond_broadcast会唤醒所有等待在该条件上的线程signal对应pthread_cond_signal只唤醒一个线程。为什么NSConditionLock的unlockWithCondition:要用broadcast而不是signal因为当条件值变化后无法确定哪个等待线程会受益于这个变化。假设三个线程分别等待条件 1、条件 2、条件 3你把条件值从 0 改成 2那么等待条件 2 的线程应该被唤醒。但如果只唤醒一个线程而恰好唤醒的是等待条件 1 的线程它醒来后发现条件不匹配只能再次睡回去白白浪费一次调度。用broadcast则是简单粗暴的唤醒所有人让每个线程自己检查条件是否匹配。不匹配就继续睡匹配的就抢锁干活。代价是可能引起“惊群效应”多个线程同时被唤醒但只有一个能抢到锁不过在pthread_cond的底层调度中这个开销在现代系统上已经优化得很低可以接受。理解了这一点再看NSConditionLock的唤醒过程就通透很多它不关心谁在等什么条件只是把消息广而告之让感兴趣的线程自己来认领。这也解释了为什么条件值最好用少量、明确的枚举——等待的线程越多广播时的无效唤醒就越多性能边际越低。3.3 和 NSCondition 对比什么时候用谁NSCondition和NSConditionLock是对新手杀伤力最大的两个类名字极其相似用法却完全不同。我做了个对比表维度NSConditionNSConditionLock核心机制wait / signal 手动配对lockWhenCondition: / unlockWithCondition: 自动配对条件表示谓词逻辑需自己 while 循环判断整数值相等判断唤醒方式signal 唤醒一个 / broadcast 唤醒全部固定 broadcast临界区保护需要手动加锁wait 自动释放锁获取锁后自动进入临界区上手难度容易踩坑while 循环忘记写、signal 丢失直观条件值匹配即可适用场景单生产者单消费者、精细控制多条件分流、任务依赖举个例子实现一个简单的“等待数据到位再处理”的模型用NSCondition需要这样写NSCondition *condition [[NSCondition alloc] init]; BOOL dataReady NO; // 消费者 [condition lock]; while (!dataReady) { [condition wait]; } // 处理数据... [condition unlock];而用NSConditionLock直接写成NSConditionLock *lock [[NSConditionLock alloc] initWithCondition:0]; // 消费者 [lock lockWhenCondition:1]; // 处理数据... [lock unlockWithCondition:0];后者明显更简洁而且不容易漏写 while 循环。但要注意NSCondition能用谓词表达任意复杂条件例如data.count 10 hasMoreDataNSConditionLock只能表达整数相等。如果你需要的条件是复合逻辑条件锁反而会限制表达力这时候老老实实用NSCondition更合适。4. 两个典型场景的完整落地4.1 生产者-消费者模型用条件锁替代信号量生产者-消费者是最经典的多线程协作问题。假设有一个共享缓冲区数组生产者往里放数据消费者从这里取数据。缓冲区为空时消费者要等待缓冲区满时生产者要等待。我先给出完整可运行的示例。定义两个条件值空和满。但发现一个尴尬的问题——条件锁只能存一个整数值无法同时表达“空”和“满”两个状态。如果用 0 表示空、1 表示满生产者生产完设置成满消费者消费完设置成空同一时间只可能是一个状态两个线程就能配合起来。但缓冲区容量大于 1 时比如可以放 5 个数据只用两个状态就不够精确了。此时建议用“计数”来建模条件值直接等于缓冲区里的数据个数。生产者每放入一个数据条件值加 1消费者每取走一个数据条件值减 1。消费者等到条件值 0 时取数据生产者等到条件值 capacity 时放数据。问题来了NSConditionLock只能判断“等于”无法判断“大于”或“小于”。如果用条件锁实现我们需要枚举所有可能的计数状态。假设 capacity 5就定义 6 个状态0~5。生产者填充后把条件值设为新的计数消费者同样更新计数。实测下来这种写法在 capacity 较大的时候不太优雅。条件数膨胀代码可读性下降。实际情况里如果缓冲区容量不大比如 2 或 3还能接受容量一大我建议改用dispatch_semaphore或者NSCondition配合谓词。这也呼应了前面说的条件锁适合“条件状态数量有限且明确”的场景不擅长“区间判断”场景。4.2 任务按序执行A 完成才能执行 BB 完成才能执行 C条件锁最有价值的地方是表达任务依赖链。假设有三个任务任务 A 执行完才能执行 BB 执行完才能执行 C。用条件锁写非常清晰typedef NS_ENUM(NSInteger, TaskState) { TaskStateIdle 0, TaskStateAFinished 1, TaskStateBFinished 2, TaskStateCFinished 3, }; NSConditionLock *lock [[NSConditionLock alloc] initWithCondition:TaskStateIdle]; // 任务 A - (void)taskA { // 模拟耗时操作 sleep(1); NSLog(Task A done); [lock unlockWithCondition:TaskStateAFinished]; } // 任务 B - (void)taskB { [lock lockWhenCondition:TaskStateAFinished]; sleep(1); NSLog(Task B done); [lock unlockWithCondition:TaskStateBFinished]; } // 任务 C - (void)taskC { [lock lockWhenCondition:TaskStateBFinished]; sleep(1); NSLog(Task C done); [lock unlockWithCondition:TaskStateCFinished]; }这里其实不需要单独的taskA获取锁因为 A 是最早的任务没有前置条件。直接执行完就用unlockWithCondition:修改条件值。B 和 C 则在对应的条件值处等待。还要注意一个细节A 调用unlockWithCondition:时如果 A 之前没有获得过锁会发生什么来看源码unlockWithCondition:的第一步是[_condition lock]也就是它会自己先获取锁再设置条件值再广播再释放。所以无论 A 之前有没有持有锁都能正确执行。这意味着任务链的起点可以用一个“从未加锁”的unlockWithCondition:来启动后续任务。这也是NSConditionLock被设计得比较宽松的一点和NSLock的严格配对不同只要语义清晰不必拘泥于成对调用。但我不建议把这个宽松当成常态。除了链条起点这种特殊情况其他环节最好保证lockWhenCondition:与unlockWithCondition:成对出现逻辑才不容易混乱。4.3 多生产多消费时的注意点实际项目中不太可能只有一个生产者和一个消费者。多生产多消费时条件锁仍然能工作但有几个值得注意的坑。第一个坑是“条件值被覆盖”。假设生产者 A 和 B 同时往缓冲区写数据A 写完后执行unlockWithCondition:1B 写完后执行unlockWithCondition:2。理论上来说这两个条件是等价的但消费者可能在 A 写完、B 没写完时就被唤醒并且去执行了因为它看到的条件值是 1已经满足。如果你的业务要求“两个生产者都写完才能开始消费”那这个逻辑就不对需要改成条件 2 表示“两个生产者都完成”A 和 B 写完后都执行unlockWithCondition:1再由某一方在确认都完成后置为 2。这类状态聚合逻辑建议单独用一个状态管理类而不是靠条件锁的整数值硬啃。第二个坑是“同一个条件值被重复设置”。当消费者消费完执行unlockWithCondition:0而另一个消费者同时也在等条件 1这没问题。但如果有两个消费者都调用unlockWithCondition:0第二次设置就是重复的可能引起额外的唤醒。这一般不是致命问题但在高性能路径上会浪费调度。我见过一个线上项目日志里条件锁的广播频率比实际任务频率高了几倍就是因为多个线程都执行了重复的unlockWithCondition:。当然重复调用不会产生死锁最坏情况只是多余的上下文切换。第三个坑是“锁的公平性”。NSConditionLock不保证公平性也就是说先等待的线程不一定先唤醒。被唤醒的线程要重新竞争pthread_mutex而调度器没有义务按等待顺序分配锁。如果业务强依赖“先来先服务”条件锁并不合适你需要用 FIFO 队列自己控制顺序或者用pthread_mutex 优先级队列的思路。5. 经典误用与排查实录5.1 死锁三兄弟互相等待、条件断链、漏唤醒我整理了三种我自己踩过或帮别人排查过的条件锁死锁模式每种都对应一个真实的“事故现场”。模式一交叉等待ABBA 死锁。线程 A 持有锁 L1等待条件 C1线程 B 持有锁 L2等待条件 C2。但 C1 的解锁条件需要 B 释放C2 的解锁条件需要 A 释放两者互相死不撒手。条件锁本身不解决锁顺序问题只要代码里出现了“持有一把锁再去申请另一把锁/条件”就要警惕循环等待。排查思路是画出线程的持有和等待图找出环路强制约定加锁顺序。模式二条件断链。生产者把所有工作做完后忘了调用unlockWithCondition:而是调用了普通的unlock。条件值永远停在初始状态所有等待线程直接永久等待。这是我见过最多的低级错误。代码审查时特别留意lockWhenCondition:对应的释放语句条件值是否真的更新了。模式三漏唤醒。开发者想当然地认为“条件值已经被外面改了等待线程会被自动唤醒”。自作主张地在别的线程直接修改条件值而不走unlockWithCondition:这在NSConditionLock里其实做不到因为condition属性是readonly的。但也有人为了绕过限制直接用 KVC 去改私有成员引发各种奇奇怪怪的行为。我说句实在话条件锁的唤醒只能通过unlockWithCondition:或broadcast完成任何绕过这个机制的花招都是在给自己埋雷。5.2 unlockWithCondition: 的条件值连环反应unlockWithCondition:会修改条件值这本身是设计好的但它会引起“连环反应”线程 A 设置条件 1唤醒等待条件 1 的线程 BB 执行完后设置条件 2唤醒等待条件 2 的线程 C。这条链如果符合业务预期就没问题最怕的是条件值设置不当把多个等待组的线程同时唤醒。举个例子线程 A 等待条件 1线程 B 等待条件 2。某个线程执行unlockWithCondition:1只应该唤醒 A。但如果之前有人把条件值改成 3而 A 和 B 都在等 1 和 2它们都不会被唤醒直到有人把条件值调回 1 或 2。这种“条件值漂移”会让等待线程的唤醒时间变得不确定排查起来特别折磨人因为代码看起来逻辑都对但就是偶发卡顿。我的建议是条件值的设置必须只在固定的几个位置如任务状态转换点进行而且最好用枚举表示禁止裸传整数。如果条件值需要增减比如用计数表示状态一定要保证增减的原子性——这一点在条件锁内部是安全的因为unlockWithCondition:整个操作持锁不需要额外保护。5.3 性能实测与选型建议用NSConditionLock最常被问的问题就是它性能到底怎么样说实话条件锁的性能在 iOS 锁家族里属于中上水平极端对比下dispatch_semaphore或os_unfair_lock会更快。我模拟过 10 万次生产者-消费者交替执行条件锁和裸写pthread_mutex pthread_cond的耗时差异在 5% 以内和NSLock也比不出明显差距。性能关键不在于锁本身的实现而在于你怎么用它。滥用broadcast唤醒大量无关线程、在锁内部做耗时操作比如磁盘 IO、网络请求、频繁设置条件值导致反复唤醒这些都是性能杀手。锁内的临界区代码务必精简到最小所有 I/O 都挪到锁外执行。选型上我的建议是任务依赖、按条件分流首选NSConditionLock。一对一的等待通知如异步任务完成回调用dispatch_semaphore或NSCondition更直接。复杂谓词条件比如多个变量构成的状态用NSCondition while 循环。高性能计数器场景直接用os_unfair_lock或std::atomic。NSConditionLock不是万能钥匙但它是“条件等待”这个需求里最贴近直觉的抽象。只要条件状态数量可控、状态转换路径清晰它能让多线程代码的意图读起来像单线程一样顺畅。我个人在实际项目里用得最多的地方是启动流程的任务编排——好几个模块要并行初始化但部分模块有依赖关系用条件锁描述“哪个模块完成后才能启动下一个模块”非常直观。如果你也遇到类似的“必须等某件事完成才能继续”的并发场景不妨先把条件状态枚举设计好再用NSConditionLock把流程串起来。