1. 从RunLoop到线程调度:为什么NSOperation才是工程里的主力
很多人准备iOS面试时,看到“多线程”三个字,第一反应就是背GCD的队列类型、sync和async的区别。但真正做过两三年项目的人会发现,业务代码里直接裸写GCD的场景其实在减少,取而代之的是NSOperationQueue和NSOperation。这不是因为GCD不好,而是因为工程复杂度上来了之后,你需要的不只是“把任务丢到后台”,你需要的是任务之间的依赖关系、优先级控制、取消能力、并发数限制、执行状态监听。这些东西GCD不是不能做,而是做起来很别扭,而NSOperation天生就是为这些需求设计的。
我见过不少候选人,问他NSOperation和GCD的区别,他能背出“NSOperation是面向对象的,GCD是C语言的”,再往下问“那你们项目里为什么用NSOperationQueue而不是dispatch_queue”,就答不上来了。这个问题其实特别能区分“背题”和“真做过”。真实场景是这样的:你有一个列表页,需要同时发起三个请求——用户信息、消息列表、配置项,等三个都回来之后才能刷新UI。用GCD你要么嵌套回调,要么用dispatch_group,代码写出来回调地狱一层套一层。用NSOperationQueue,你只需要把三个操作addDependency到第四个刷新操作上,代码清晰得像写作文。
所以这一篇我不打算按“NSOperation是什么、怎么用、和GCD什么区别”这种教科书顺序来讲。我按面试官真正会追问的路径来拆:先讲NSOperationQueue的调度逻辑和状态机,再讲NSThread为什么现在几乎只在特定场景出现,最后把锁机制单独拎出来讲清楚——因为锁这块是面试里最容易翻车的,很多人连自旋锁和互斥锁的适用场景都分不清。
1.1 NSOperationQueue的并发模型不是“线程池”那么简单
先纠正一个常见的错误认知。很多人说NSOperationQueue底层就是一个线程池,这话对了一半,但不准确。NSOperationQueue管理的是操作的调度,不是线程本身。它内部确实维护了一个线程池来执行任务,但你在使用层面完全不需要关心线程的创建和销毁。你add一个operation进去,queue根据当前并发数、operation的isReady状态、依赖关系来决定什么时候把它交给某个线程执行。
这里有个关键点面试常考:maxConcurrentOperationCount设为1的时候,队列是串行执行的吗?答案是:行为上看起来是串行的,但它和真正的串行队列有本质区别。串行队列是系统保证一个任务执行完才执行下一个,而maxConcurrentOperationCount=1只是限制了同时执行的操作数量为1,操作的执行顺序仍然受优先级和依赖关系影响。也就是说,如果你add了三个操作,优先级不同,即使maxConcurrentOperationCount=1,执行顺序也可能和你add的顺序不一样。
我在实际项目里踩过这个坑。当时做一个图片下载队列,为了控制内存占用把maxConcurrentOperationCount设成了2,结果发现下载顺序和用户点击顺序不一致,用户点第一张图还没出来,第三张图先出来了。后来查了半天才意识到是优先级的问题——默认所有operation的queuePriority都是normal,但系统内部可能会根据isReady状态做调整。解决办法是显式设置queuePriority,或者干脆用依赖关系来保证顺序。
1.2 NSOperation的状态机:isReady、isExecuting、isFinished
NSOperation内部有一套KVO兼容的状态机,这是它比GCD灵活的核心原因。一个operation的生命周期大致是:初始化 -> isReady(依赖都完成了)-> isExecuting(开始执行)-> isFinished(执行完毕)。你可以通过KVO监听这些状态变化来做一些事情,比如在isFinished变true的时候更新UI。
但这里有个大坑:如果你自定义NSOperation子类,必须手动管理这些状态。很多人写自定义operation的时候,直接在main方法里写业务逻辑,执行完就完了,不手动设置isFinished。结果就是operation永远不结束,队列以为它还在执行,后续依赖它的操作永远不触发。这个bug特别隐蔽,因为你的业务逻辑其实已经跑完了,但队列层面卡住了。
正确的做法是在main方法的最后,手动把isExecuting置为false,把isFinished置为true,并且要保证这两个状态的切换是线程安全的。通常会用@synchronized或者NSLock包一下。如果你用的是start方法而不是main方法,还要处理isAsynchronous的返回值。这块内容面试官如果深挖,能直接看出你有没有真正写过自定义operation。
1.3 依赖关系背后的图调度逻辑
addDependency这个API看起来简单,但它的底层是一个有向无环图的调度。每个operation是一个节点,依赖关系是一条有向边。队列在执行之前会做拓扑排序,确保没有循环依赖。如果你不小心写出了循环依赖,比如A依赖B,B又依赖A,这两个operation永远不会执行,而且系统不会报错,只会静默卡死。
我建议在addDependency之前,自己先想清楚依赖关系是不是一个DAG。如果业务逻辑复杂到你自己都理不清,那说明你的任务拆分有问题,应该考虑把粒度调粗一点。另外,依赖关系是跨队列生效的——你可以把operation A加到queue1,operation B加到queue2,然后让B依赖A。这个特性在做复杂任务编排的时候特别有用,比如网络请求在queue1,数据库写入在queue2,UI更新在主队列,通过依赖关系串起来。
还有一个细节:removeDependency只能在operation开始执行之前调用。一旦operation进入isExecuting状态,再去移除依赖会抛异常。这个在动态调整任务依赖的时候要注意,比如用户取消了某个前置任务,你想把后续任务的依赖去掉,必须在前置任务还没开始执行的时候操作。
2. NSOperation的自定义子类:什么时候值得写,怎么写才不出事
系统提供了NSInvocationOperation和NSBlockOperation两个现成的子类,大部分场景够用了。但有些情况下你必须自定义NSOperation子类,比如你需要复用一套复杂的任务逻辑、需要更细粒度的状态控制、或者需要实现异步操作。这一节我重点讲异步自定义operation的写法,因为这是面试里区分度最高的地方。
2.1 同步operation和异步operation的本质区别
同步operation的main方法执行完,operation就结束了。异步operation的main方法执行完,operation可能还在跑,因为真正的任务是在别的线程上异步执行的。这时候你需要手动管理isExecuting和isFinished的状态,并且要重写isAsynchronous返回true。
为什么这个区别重要?因为NSOperationQueue是根据isAsynchronous来决定怎么调度的。如果isAsynchronous返回false,队列会认为main方法返回就意味着operation结束,它会自动帮你把isFinished置为true。如果返回true,队列就不管了,全靠你自己维护状态。很多人在自定义异步operation的时候忘了重写isAsynchronous,结果就是operation的main方法返回后,队列以为任务结束了,但实际上异步任务还在跑,后续依赖它的操作提前触发了,整个调度逻辑就乱了。
2.2 手写一个可取消的异步下载Operation
我拿一个实际场景来演示:一个支持取消的异步图片下载operation。核心逻辑是这样的——在start方法里检查isCancelled,如果已经取消就直接设置isFinished;否则创建NSURLSessionTask发起请求,在回调里处理数据,最后手动设置isExecuting为false、isFinished为true。取消的时候调用cancel方法,把NSURLSessionTask也cancel掉。
这里的关键点是状态切换必须加锁。因为isExecuting和isFinished可能在不同的线程上被读写,不加锁会出现状态不一致。我一般用一个NSLock来保护状态变更,在willChangeValueForKey和didChangeValueForKey之间修改状态值,这样KVO监听才能正常工作。
还有一个容易忽略的点:cancel方法只是设置isCancelled为true,不会自动停止正在执行的任务。你需要在业务代码里主动检查isCancelled,在合适的时机退出。比如在下载的回调里,先判断if (self.isCancelled) return; 再处理数据。如果不做这个检查,用户取消了操作,数据还是会被处理,可能引发UI错乱。
2.3 自定义Operation的KVO合规性检查清单
如果你想让自定义operation支持KVO,必须保证以下几点:isReady、isExecuting、isFinished、isCancelled这几个属性的getter和setter都通过willChangeValueForKey和didChangeValueForKey来触发通知;状态之间的转换要符合逻辑,比如不能从isFinished变回isExecuting;isFinished一旦为true就不能再变回false。这些规则在苹果的文档里有详细说明,但很多人写的时候不看文档,跑起来发现KVO不触发,又找不到原因。
我个人的经验是,自定义异步operation的代码模板基本固定,写熟之后直接套。但每次写的时候我都会检查一遍状态机的完整性,特别是取消路径——用户取消之后,operation能不能正确走到isFinished,依赖它的操作能不能正常触发。这个路径如果没测到,上线之后就是偶现的卡死bug,特别难查。
3. NSThread:被遗忘的线程API,但面试还在问
NSThread现在在实际项目里用得很少了,因为GCD和NSOperation已经覆盖了绝大多数场景。但面试还是会问,因为NSThread能考察你对线程生命周期的理解。GCD和NSOperation都帮你隐藏了线程管理的细节,而NSThread是赤裸裸的,你需要自己创建线程、自己启动、自己管理生命周期。
3.1 NSThread的三种创建方式和线程生命周期
创建NSThread有三种方式:alloc init然后start、detachNewThreadSelector、performSelectorInBackground。这三种方式的区别在于线程的创建时机和是否需要手动start。detachNewThreadSelector和performSelectorInBackground会立即创建并启动线程,而alloc init的方式需要你手动调用start。
线程的生命周期是:创建 -> 启动 -> 执行 -> 退出。执行完入口方法之后,线程就退出了,不能复用。这一点和GCD的线程池不一样,GCD的线程是可以复用的。所以如果你需要频繁执行短任务,用NSThread会频繁创建销毁线程,开销很大。这也是为什么NSThread现在基本只在需要长时间运行的后台线程场景下使用,比如一个常驻的socket监听线程。
3.2 线程间通信:performSelector和port的区别
NSThread提供了performSelector:onThread:withObject:waitUntilDone:来做线程间通信,底层是基于RunLoop的。目标线程必须有一个活跃的RunLoop,否则performSelector不会执行。这个坑很多人踩过——在子线程里调performSelector到另一个子线程,结果发现方法没执行,就是因为目标线程的RunLoop没有跑起来。
还有一种方式是使用NSMachPort或者NSPort来做线程间通信,但这种方式更底层,实际项目里很少用。面试如果问到,能说出performSelector基于RunLoop、需要目标线程RunLoop活跃就够了。
3.3 为什么现在不推荐直接用NSThread
除了线程不能复用之外,NSThread还有一个问题是线程安全完全靠开发者自己保证。GCD和NSOperation至少帮你处理了队列层面的同步,而NSThread你需要自己加锁、自己处理竞态条件。在复杂业务里,直接用NSThread写多线程代码,出bug的概率远高于用GCD或NSOperation。
我个人的建议是:除非你有非常明确的理由(比如需要长时间运行的后台线程、需要精确控制线程优先级),否则不要直接用NSThread。面试的时候如果被问到,能说清楚它的生命周期和适用场景就行,不需要在项目里刻意用它。
4. 锁机制:从自旋锁到信号量,选错了就是性能灾难
锁这块是面试的重灾区,也是实际开发中容易出问题的地方。很多人知道@synchronized、NSLock、dispatch_semaphore,但不知道什么时候该用哪个。选错了锁,轻则性能下降,重则死锁。这一节我把常见的锁按使用场景分类讲清楚。
4.1 自旋锁和互斥锁的本质区别
自旋锁和互斥锁的核心区别在于等待时的行为。自旋锁在等待的时候会忙等,不断检查锁是否可用,不释放CPU。互斥锁在等待的时候会休眠,让出CPU,等锁可用的时候被唤醒。所以自旋锁适合锁持有时间极短的场景,因为忙等的开销小于线程休眠唤醒的开销。如果锁持有时间长,自旋锁会白白浪费CPU。
OSSpinLock是典型的自旋锁,但因为优先级反转的问题,苹果已经不建议使用了。现在推荐用os_unfair_lock替代,它内部会根据情况决定是自旋还是休眠。面试如果问到自旋锁,能说出OSSpinLock的优先级反转问题和os_unfair_lock的替代方案,基本就够了。
4.2 @synchronized的底层实现和性能陷阱
@synchronized用起来最方便,传一个对象就能加锁。但它的性能是几种锁里比较差的,因为底层涉及到objc_sync_enter和objc_sync_exit,内部维护了一个哈希表来管理锁对象。而且@synchronized的锁粒度是对象级别的,如果你用self来加锁,整个对象的其他@synchronized(self)都会互斥,可能造成不必要的等待。
我见过有人在循环里用@synchronized,每次循环都加锁解锁,性能直接崩掉。正确的做法是把锁的范围缩小到真正需要保护的临界区,或者用更轻量的锁。如果只是保护一个计数器的增减,用os_unfair_lock或者atomic属性就够了,没必要上@synchronized。
4.3 NSLock、NSRecursiveLock和NSCondition的适用场景
NSLock是最基础的互斥锁,适合大多数需要互斥的场景。NSRecursiveLock是递归锁,同一个线程可以多次加锁而不会死锁,适合递归调用或者嵌套调用的场景。NSCondition是条件锁,除了互斥之外还提供了wait和signal,适合生产者-消费者模型。
这里有个常见的面试题:NSLock在同一个线程里连续lock两次会怎样?答案是死锁。因为NSLock不是递归锁,同一个线程第二次lock的时候会等待第一次lock释放,但第一次lock就是这个线程持有的,所以永远等不到。这个点很多人不知道,写代码的时候不小心嵌套调用就死锁了。
4.4 dispatch_semaphore:不只是锁,更是流量控制
dispatch_semaphore_t最常用的场景是控制并发数,比如限制同时进行的网络请求数量。它的原理是信号量计数,wait的时候计数减一,如果计数小于0就等待;signal的时候计数加一,如果有等待的线程就唤醒。把信号量初始值设为1,它就变成了一个互斥锁。
但dispatch_semaphore和普通锁有一个重要区别:它可以在一个线程wait,在另一个线程signal。这个特性让它特别适合做异步任务的同步化。比如你发起一个异步请求,想在另一个线程等它完成,就可以用信号量。普通锁做不到这一点,因为普通锁的lock和unlock必须在同一个线程。
不过要注意,在主线程用dispatch_semaphore_wait等待异步任务完成,如果异步任务也在主线程执行,就会死锁。这个坑在面试里经常被问到,实际开发中也经常踩。
4.5 各种锁的性能对比和选型建议
| 锁类型 | 性能 | 适用场景 | 注意事项 |
|---|---|---|---|
| os_unfair_lock | 最高 | 短临界区、高频加锁 | 不能递归,不能跨线程unlock |
| dispatch_semaphore | 高 | 并发控制、异步同步化 | 注意死锁风险 |
| NSLock | 中 | 一般互斥场景 | 不能递归 |
| NSRecursiveLock | 中 | 递归/嵌套调用 | 性能略低于NSLock |
| @synchronized | 低 | 简单场景、代码简洁优先 | 性能差,锁粒度大 |
| NSCondition | 中 | 生产者-消费者 | 必须配合while循环使用 |
选型的原则很简单:临界区越短、加锁越频繁,越要用轻量级的锁。如果只是保护一个属性的读写,用atomic或者os_unfair_lock。如果需要控制并发数,用dispatch_semaphore。如果需要递归加锁,用NSRecursiveLock。如果只是图方便、性能要求不高,用@synchronized也不是不行,但要知道它的代价。
5. 面试实战:多线程问题的排查思路和回答框架
前面讲了这么多原理和API,最后这一节我讲一下面试里怎么把这些知识串起来。面试官问多线程问题,通常不是考你背API,而是考你遇到问题怎么排查、怎么选方案、怎么权衡。我总结了一个回答框架,基本能覆盖80%的多线程面试题。
5.1 从崩溃日志定位多线程问题的完整链路
如果面试官给你一个崩溃日志,说“这个崩溃是偶现的,你怎么排查”,你可以按这个思路走:先看崩溃类型,如果是EXC_BAD_ACCESS,大概率是内存问题,可能是多线程访问了同一个对象导致野指针;如果是死锁,看堆栈里有没有semaphore_wait或者lock相关的调用;如果是数据竞争,看有没有多个线程同时读写同一个变量。
排查手段上,可以用Thread Sanitizer来检测数据竞争,用Zombie Objects来检测野指针,用LLDB的thread backtrace all来看所有线程的堆栈。这些工具在Xcode里都有,面试的时候能说出来,说明你有实际排查经验。
5.2 多线程方案选型的决策树
面试官如果问“你会怎么选多线程方案”,可以按这个决策树来回答:如果只是简单的异步执行,用GCD的async;如果需要任务依赖和优先级控制,用NSOperationQueue;如果需要长时间运行的后台线程,用NSThread;如果需要控制并发数,用dispatch_semaphore或者NSOperationQueue的maxConcurrentOperationCount;如果需要线程间通信,用performSelector或者dispatch_async到目标队列。
这个决策树不是死的,实际选型还要考虑团队的技术栈和代码风格。但面试的时候能说出这个框架,说明你对各种方案的特点有清晰的认识。
5.3 锁的选型和死锁预防的实战经验
锁的选型我前面已经讲了,这里补充一下死锁预防的经验。死锁的四个必要条件是互斥、持有并等待、不可抢占、循环等待。预防死锁就是破坏其中一个条件。最常见的是破坏循环等待——给锁定义顺序,所有线程按顺序加锁。另一个是破坏持有并等待——一次性申请所有需要的锁,而不是一个一个申请。
在实际项目里,我建议尽量避免嵌套加锁。如果发现代码里出现了锁嵌套,先想想能不能重构,把锁的粒度调粗或者调细。如果实在避免不了,一定要保证加锁顺序一致。另外,用dispatch_semaphore的时候,wait和signal一定要配对,否则信号量计数会乱掉,导致永久等待。
5.4 面试官追问“你项目中怎么用的”该怎么答
这个问题最怕答得空泛。我的建议是准备一个具体的场景,比如“我们做一个图片加载库,用NSOperationQueue管理下载任务,maxConcurrentOperationCount设为4来控制并发,用NSLock保护缓存字典的读写,用dispatch_semaphore控制同时解码的图片数量”。然后展开讲为什么这么设计、遇到了什么问题、怎么解决的。这种回答有细节、有取舍、有踩坑经验,面试官一听就知道你真做过。
如果面试官继续追问“为什么maxConcurrentOperationCount设4而不是6”,你可以说“我们测试过,4的时候内存占用和下载速度的平衡最好,6的时候内存峰值太高,容易触发内存警告”。这种回答展示了你有性能测试的意识,比单纯说“随便设的”强太多。
多线程这块内容,光看是记不住的,一定要自己写demo跑一遍。特别是自定义NSOperation和锁的部分,不亲手写一遍,面试的时候细节根本答不上来。我当年准备面试的时候,把NSOperationQueue的依赖、取消、并发控制都写了一遍,锁的几种类型也对比测试了性能,面试的时候明显感觉底气不一样。