iOS多线程深度解析:NSThread、NSOperation与GCD
前言
在iOS开发中,多线程编程是保证应用流畅度的核心手段。若将耗时任务放在主线程执行,界面会直接卡死,用户体验极差。为了应对这一需求,iOS先后提供了三套多线程方案:
- NSThread:最底层,直接操作线程对象,开发者需手动管理生命周期。
- NSOperation / NSOperationQueue:面向对象封装,支持依赖、取消、最大并发数等高级特性。
- GCD(Grand Central Dispatch):基于C语言的轻量级任务调度框架,自动管理线程池,性能最优。
本文将深入剖析这三种方案的核心原理、使用场景以及易错点,尤其侧重GCD中同步/异步、串行/并发队列与线程之间的关系,以及执行顺序的不确定性和死锁机制——这些内容常常是开发者从“会用”到“理解”的分水岭。
一、多线程基础概念
1.1 进程与线程
- 进程:一个正在运行的应用程序,拥有独立的内存空间。
- 线程:进程中执行代码的基本单元,同一进程内的线程共享内存空间。
iOS应用中,主线程负责UI事件处理与界面渲染,任何耗时操作都应移至后台线程执行。
1.2 多线程带来的问题
- 线程安全:多线程同时访问同一资源可能导致数据错乱。
- 死锁:多个线程相互等待对方释放资源,导致程序卡死。
- 执行顺序不确定:不同线程上的代码执行顺序无保障,若无同步措施,结果可能与书写顺序完全不同。
理解这些挑战是正确使用多线程技术的前提。
二、NSThread:轻量级但需慎用
2.1 基本使用
1 | // 方式1:类方法自动创建并启动 |
2.2 线程生命周期控制
- 启动:调用
start后线程进入就绪状态,等待CPU调度执行。 - 退出:
selector执行完毕后线程自动销毁。 - 取消:调用
[thread cancel]仅设置线程的取消状态,需在线程内部定期检查[[NSThread currentThread] isCancelled]来响应退出。 - 显式退出:
[NSThread exit]可以强制终止当前线程,但容易导致资源泄漏,不推荐。
2.3 线程间通信
1 | // 回到主线程更新UI |
2.4 缺点
- 线程数量需手动控制,过多线程会导致性能下降。
- 线程安全、锁、同步问题完全由开发者处理,代码容易杂乱。
- 缺乏依赖、优先级、取消等高级功能。
现如今的开发中,除了需要精确控制线程生命周期(如RunLoop常驻线程)的少数场景,NSThread已基本被NSOperation和GCD取代。
三、NSOperation:面向对象的高级抽象
3.1 核心类
NSOperation:抽象类,代表一个任务。通常使用其子类NSBlockOperation或自定义。NSOperationQueue:操作队列,负责调度NSOperation的执行,内部自动管理线程。
3.2 基本用法
1 | NSBlockOperation *op1 = [NSBlockOperation blockOperationWithBlock:^{ |
3.3 任务依赖
可以轻松建立任务间的依赖关系,保证执行顺序。
1 | [op2 addDependency:op1]; // op2 等待 op1 完成后才执行 |
3.4 最大并发数
1 | queue.maxConcurrentOperationCount = 3; // 最多同时执行3个操作 |
- 设置为
1时,队列退化为串行队列。 - 使用
NSOperationQueueDefaultMaxConcurrentOperationCount由系统根据硬件动态决定。
3.5 取消操作
1 | [op cancel]; // 单个操作取消 |
- 若操作尚未开始执行,取消后就不会再被执行。
- 若操作已开始执行,需要在操作内部通过
self.isCancelled判断并提前返回。
3.6 NSOperation vs GCD 对比
| 维度 | NSOperation | GCD |
|---|---|---|
| 实现方式 | Objective-C 对象 | C语言API |
| 依赖关系 | 内置 addDependency |
需借助 Group/Barrier 模拟 |
| 取消操作 | 内置支持 | 自行实现 |
| 最大并发数 | 直接设置 maxConcurrentOperationCount |
用信号量间接控制 |
| 性能 | 稍重,但高级功能丰富 | 极轻量,性能最优 |
经验法则:需要依赖、取消、并发数控制时用 NSOperation;追求极致性能、简单任务分发用 GCD。
四、GCD:性能至上的任务调度引擎
GCD 是基于 C 语言的底层多线程 API,核心思想是将任务(Block)添加到队列(Dispatch Queue)中,由系统自动管理线程的创建和调度。理解 GCD 的关键在于理清三个角色的关系:队列、任务提交方式、线程。
4.1 队列:任务的排班规则
队列分两种:
- 串行队列(Serial Queue):任务按 FIFO 顺序一个接一个执行,同一时间最多只执行一个任务。
- 并发队列(Concurrent Queue):任务也按 FIFO 取出,但可以同时开启多个线程并发执行。
系统预置的队列:
- 主队列(Main Queue):特殊的串行队列,所有任务都在主线程上执行,用于UI更新。
- 全局并发队列(Global Queue):系统提供的并发队列,有不同优先级供选择。
开发者也可创建自定义的串行或并发队列:
1 | dispatch_queue_t serialQ = dispatch_queue_create("com.my.serial", DISPATCH_QUEUE_SERIAL); |
重要认知:队列只决定任务如何排队和调度,并不直接等于线程。
4.2 任务提交方式:sync 与 async 的行为铁律
提交任务到队列有两种函数:dispatch_sync(同步)和dispatch_async(异步)。它们决定了当前线程的行为和任务执行的线程。
dispatch_sync
- 绝对不会开启新线程,任务一定在当前线程上执行。
- 阻塞当前线程,直到任务执行完毕才继续向下执行。
dispatch_async
- 可能开启新线程(取决于队列类型)。
- 不阻塞当前线程,提交任务后立即返回,后续代码继续执行。
基于这两种提交方式和两种队列类型,可以得到四种经典组合,其行为总结如下:
| 组合 | 是否开启新线程 | 任务在哪个线程执行 | 当前线程是否阻塞 | 多个任务的执行顺序 |
|---|---|---|---|---|
sync + 串行队列 |
❌ 不开启 | 当前线程 | 🔴 阻塞 | 顺序执行 |
sync + 并发队列 |
❌ 不开启 | 当前线程 | 🔴 阻塞 | 顺序执行 |
async + 串行队列 |
✅ 开启 1 条新线程 | 新子线程(串行执行) | 🟢 不阻塞 | 顺序执行(FIFO) |
async + 并发队列 |
✅ 开启 多条新线程 | 线程池分配的多条线程 | 🟢 不阻塞 | 并发执行,执行顺序不确定 |
记忆口诀:同步不开新线程,任务就地解决;异步才能开线程,串行开一条,并发开一堆。
一个简单验证:
1 | dispatch_queue_t serialQ = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL); |
4.3 主队列的特殊性
- 主队列是一个串行队列,且它的任务固定由主线程执行。
- 向主队列提交任务时,无论是
sync还是async,任务执行线程一定是主线程。
1 | dispatch_async(dispatch_get_main_queue(), ^{ |
主队列 async 是更新UI的标准方式,因为它既保证了在主线程执行,又不会阻塞当前线程。
4.4 执行顺序的不确定性:一个必须打破的直觉
在单线程编程中,代码的顺序就是执行的顺序。但在多线程环境下,不同线程上的代码,若无同步措施,执行顺序完全由操作系统调度决定,没有任何保证。这个原则经常被忽视,导致隐蔽的并发Bug。
考虑以下代码:
1 | dispatch_queue_t q = dispatch_queue_create("q", DISPATCH_QUEUE_SERIAL); |
直觉上可能认为“2”一定先输出(因为async后紧接着就执行了后面的代码),但实际上,“1”和“2”的先后顺序是不确定的。因为async提交任务后,新线程和当前线程是并发运行的,两个 NSLog 之间没有任何同步点。
- 可能输出
2 1(常见,因为开启新线程有微小的延迟) - 也可能输出
1 2(若当前线程时间片用完,新线程抢先执行)
即使测试100万次都是2 1,逻辑上也绝不能写成依赖于这个顺序的代码。 若需要保证顺序,必须使用sync、DispatchGroup、dispatch_barrier或信号量等同步手段。
4.5 死锁:原理与避坑指南
死锁是GCD中最常见的陷阱之一。其发生的充分必要条件如下:
在一个串行队列(主队列也是串行队列)的执行上下文中,通过
dispatch_sync向同一个串行队列提交任务。
背后的原理:串行队列一次只能执行一个任务。当前任务A正在执行,它调用dispatch_sync向当前队列尾添加任务B,并阻塞当前线程等待任务B完成。但任务B排在了任务A之后,必须等任务A执行完才能被调度——A等B,B等A,形成死锁。
场景1:主队列死锁
1 | // 在主线程执行此段代码 |
- 主线程正在执行当前方法(任务A),遇到
sync向主队列添加任务B并阻塞。 - 任务B必须等主队列当前任务(即A)完成才能执行 → 死锁。
如果在子线程调用dispatch_sync(主队列, block),则不会死锁,因为子线程阻塞等待主线程执行任务,主线程空闲后就会执行。这是合法的。
场景2:自定义串行队列死锁
1 | dispatch_queue_t q = dispatch_queue_create("q", DISPATCH_QUEUE_SERIAL); |
- 任务A在q中执行,遇到
sync向q提交任务B并等待。 - 任务B排在A后面,A不完,B不能开始 → 死锁。
为什么并发队列不会死锁?
1 | dispatch_queue_t conQ = dispatch_queue_create("con", DISPATCH_QUEUE_CONCURRENT); |
并发队列可以同时执行多个任务。当前任务A在执行时,sync提交的任务B可以由其他线程立即并发执行,A只是等待B完成,而B并不依赖A的结束,因此不会相互等待,不会死锁。
避坑铁律:从不在串行队列任务内部用sync往同一个串行队列提交新任务。跨队列的sync是安全的。
4.6 并发控制利器:Barrier、Group、信号量
dispatch_barrier_async:并发队列的写屏障
针对自定义并发队列,barrier可以保证栅栏任务单独执行:它前面的所有任务全部执行完成后才开始执行栅栏任务,且栅栏任务执行期间队列上的其他任务都不会并发执行;栅栏任务结束后,后续任务恢复并发。
1 | dispatch_queue_t conQ = dispatch_queue_create("rw", DISPATCH_QUEUE_CONCURRENT); |
注意:
barrier仅对自定义并发队列有效。在全局并发队列上使用等同于普通async。
Dispatch Group:多任务聚合
dispatch_group可以监听一组异步任务全部完成的事件,然后再执行后续逻辑。
1 | dispatch_group_t group = dispatch_group_create(); |
enter和leave必须严格成对,否则group永远完成不了,或者过早触发导致崩溃。
信号量:控制并发数量
1 | dispatch_semaphore_t sem = dispatch_semaphore_create(3); // 最大并发3 |
信号量将并发数量限制在指定值,超过则阻塞等待,常用于限制网络并发数或保护有限资源。
4.7 dispatch_once 的隐式同步
dispatch_once 保证代码仅执行一次,常用于单例模式。其内部实现相当于在一个全局的串行队列上同步执行,如果在once块内部再次调用同一onceToken的dispatch_once,就会产生死锁(类似串行队列sync自身)。因此应避免嵌套使用。
五、三剑客对比与选型策略
| 维度 | NSThread | NSOperation | GCD |
|---|---|---|---|
| 抽象层级 | 低级(线程) | 中级(操作) | 低级(C接口) |
| 线程管理 | 手动创建/销毁 | 自动队列管理 | 自动线程池 |
| 依赖关系 | 无 | 内置 addDependency | 需借助Group/Barrier |
| 任务取消 | 自行设计 | 内置 cancel 支持 | 自行实现 |
| 最大并发数 | 完全手动 | maxConcurrentOperationCount | 信号量间接控制 |
| 性能开销 | 较高 | 中等 | 最低 |
| 适用场景 | 精确线程控制 | 复杂任务依赖、并发数控制 | 绝大多数异步任务调度 |
选择建议
- 需要面向对象封装、任务依赖、取消、并发数控制 →
NSOperationQueue - 追求极致性能、简单任务调度、底层控制 →
GCD - 极端场景(如RunLoop常驻线程、线程精确管理) →
NSThread
绝大多数App开发中,GCD 已经能覆盖90%以上的多线程需求。理解其队列、同步/异步、线程之间的关系,以及对死锁和不确性顺序的警觉,是写出稳定多线程代码的基石。
结语
多线程编程的本质在于将任务拆解并合理地映射到线程上,同时确保数据安全与执行逻辑的正确性。无论选用哪种技术,底层规律是相通的:同步异步决定当前线程行为与等待关系,串行并发决定任务排班与并行度,而线程只是最终的执行者。
掌握这些规则后,再去使用GCD、NSOperation或NSThread,就不会再被表面API迷惑,而是能自信地推理出任何一段并发代码的真实行为。