iOS多线程面试深度解析:NSOperation、NSThread与锁机制实战

发布时间:2026/10/14 0:28:36
iOS多线程面试深度解析:NSOperation、NSThread与锁机制实战 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的时候队列是串行执行的吗答案是行为上看起来是串行的但它和真正的串行队列有本质区别。串行队列是系统保证一个任务执行完才执行下一个而maxConcurrentOperationCount1只是限制了同时执行的操作数量为1操作的执行顺序仍然受优先级和依赖关系影响。也就是说如果你add了三个操作优先级不同即使maxConcurrentOperationCount1执行顺序也可能和你add的顺序不一样。我在实际项目里踩过这个坑。当时做一个图片下载队列为了控制内存占用把maxConcurrentOperationCount设成了2结果发现下载顺序和用户点击顺序不一致用户点第一张图还没出来第三张图先出来了。后来查了半天才意识到是优先级的问题——默认所有operation的queuePriority都是normal但系统内部可能会根据isReady状态做调整。解决办法是显式设置queuePriority或者干脆用依赖关系来保证顺序。1.2 NSOperation的状态机isReady、isExecuting、isFinishedNSOperation内部有一套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依赖BB又依赖A这两个operation永远不会执行而且系统不会报错只会静默卡死。我建议在addDependency之前自己先想清楚依赖关系是不是一个DAG。如果业务逻辑复杂到你自己都理不清那说明你的任务拆分有问题应该考虑把粒度调粗一点。另外依赖关系是跨队列生效的——你可以把operation A加到queue1operation B加到queue2然后让B依赖A。这个特性在做复杂任务编排的时候特别有用比如网络请求在queue1数据库写入在queue2UI更新在主队列通过依赖关系串起来。还有一个细节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变回isExecutingisFinished一旦为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最高短临界区、高频加锁不能递归不能跨线程unlockdispatch_semaphore高并发控制、异步同步化注意死锁风险NSLock中一般互斥场景不能递归NSRecursiveLock中递归/嵌套调用性能略低于NSLocksynchronized低简单场景、代码简洁优先性能差锁粒度大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的依赖、取消、并发控制都写了一遍锁的几种类型也对比测试了性能面试的时候明显感觉底气不一样。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询