将来的自己,会感谢现在努力的自己!

0%

iOS多线程深度解析:NSThread、NSOperation与GCD

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
2
3
4
5
6
7
8
// 方式1:类方法自动创建并启动
[NSThread detachNewThreadSelector:@selector(task) toTarget:self withObject:nil];

// 方式2:实例化后手动启动
NSThread *thread = [[NSThread alloc] initWithTarget:self
selector:@selector(task)
object:nil];
[thread start];

2.2 线程生命周期控制

  • 启动:调用 start 后线程进入就绪状态,等待CPU调度执行。
  • 退出selector 执行完毕后线程自动销毁。
  • 取消:调用 [thread cancel] 仅设置线程的取消状态,需在线程内部定期检查 [[NSThread currentThread] isCancelled] 来响应退出。
  • 显式退出[NSThread exit] 可以强制终止当前线程,但容易导致资源泄漏,不推荐。

2.3 线程间通信

1
2
3
4
5
6
7
8
9
10
// 回到主线程更新UI
[self performSelectorOnMainThread:@selector(updateUI)
withObject:nil
waitUntilDone:NO];

// 指定某一线程执行任务
[self performSelector:@selector(task)
onThread:otherThread
withObject:nil
waitUntilDone:NO];

2.4 缺点

  • 线程数量需手动控制,过多线程会导致性能下降。
  • 线程安全、锁、同步问题完全由开发者处理,代码容易杂乱。
  • 缺乏依赖、优先级、取消等高级功能。

现如今的开发中,除了需要精确控制线程生命周期(如RunLoop常驻线程)的少数场景,NSThread已基本被NSOperation和GCD取代。


三、NSOperation:面向对象的高级抽象

3.1 核心类

  • NSOperation:抽象类,代表一个任务。通常使用其子类 NSBlockOperation 或自定义。
  • NSOperationQueue:操作队列,负责调度NSOperation的执行,内部自动管理线程。

3.2 基本用法

1
2
3
4
5
6
7
8
9
10
NSBlockOperation *op1 = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"任务1 - %@", [NSThread currentThread]);
}];
NSBlockOperation *op2 = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"任务2 - %@", [NSThread currentThread]);
}];

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperation:op1];
[queue addOperation:op2];

3.3 任务依赖

可以轻松建立任务间的依赖关系,保证执行顺序。

1
2
[op2 addDependency:op1]; // op2 等待 op1 完成后才执行
[queue addOperations:@[op1, op2] waitUntilFinished:NO];

3.4 最大并发数

1
queue.maxConcurrentOperationCount = 3; // 最多同时执行3个操作
  • 设置为 1 时,队列退化为串行队列
  • 使用 NSOperationQueueDefaultMaxConcurrentOperationCount 由系统根据硬件动态决定。

3.5 取消操作

1
2
[op cancel];             // 单个操作取消
[queue cancelAllOperations]; // 取消所有操作
  • 若操作尚未开始执行,取消后就不会再被执行。
  • 若操作已开始执行,需要在操作内部通过 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
2
dispatch_queue_t serialQ = dispatch_queue_create("com.my.serial", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t concurrentQ = dispatch_queue_create("com.my.concurrent", DISPATCH_QUEUE_CONCURRENT);

重要认知:队列只决定任务如何排队和调度,并不直接等于线程。

4.2 任务提交方式:sync 与 async 的行为铁律

提交任务到队列有两种函数:dispatch_sync(同步)和dispatch_async(异步)。它们决定了当前线程的行为和任务执行的线程

dispatch_sync

  • 绝对不会开启新线程,任务一定在当前线程上执行。
  • 阻塞当前线程,直到任务执行完毕才继续向下执行。

dispatch_async

  • 可能开启新线程(取决于队列类型)。
  • 不阻塞当前线程,提交任务后立即返回,后续代码继续执行。

基于这两种提交方式和两种队列类型,可以得到四种经典组合,其行为总结如下:

组合 是否开启新线程 任务在哪个线程执行 当前线程是否阻塞 多个任务的执行顺序
sync + 串行队列 ❌ 不开启 当前线程 🔴 阻塞 顺序执行
sync + 并发队列 ❌ 不开启 当前线程 🔴 阻塞 顺序执行
async + 串行队列 ✅ 开启 1 条新线程 新子线程(串行执行) 🟢 不阻塞 顺序执行(FIFO)
async + 并发队列 ✅ 开启 多条新线程 线程池分配的多条线程 🟢 不阻塞 并发执行,执行顺序不确定

记忆口诀同步不开新线程,任务就地解决;异步才能开线程,串行开一条,并发开一堆。

一个简单验证:

1
2
3
4
5
6
7
8
9
10
11
dispatch_queue_t serialQ = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL);

// sync + 串行队列:主线程执行
dispatch_sync(serialQ, ^{
NSLog(@"1 - %@", [NSThread currentThread]); // 线程编号 == 1 (主线程)
});

// async + 串行队列:开启一条非主线程
dispatch_async(serialQ, ^{
NSLog(@"2 - %@", [NSThread currentThread]); // 线程编号 ≠ 1,且多次提交都是同一子线程
});

4.3 主队列的特殊性

  • 主队列是一个串行队列,且它的任务固定由主线程执行
  • 向主队列提交任务时,无论是 sync 还是 async任务执行线程一定是主线程
1
2
3
4
5
6
7
dispatch_async(dispatch_get_main_queue(), ^{
// 该任务会在主线程 RunLoop 空闲时执行,不阻塞当前线程
});

dispatch_sync(dispatch_get_main_queue(), ^{
// 极易死锁,见 4.5 节
});

主队列 async 是更新UI的标准方式,因为它既保证了在主线程执行,又不会阻塞当前线程。

4.4 执行顺序的不确定性:一个必须打破的直觉

在单线程编程中,代码的顺序就是执行的顺序。但在多线程环境下,不同线程上的代码,若无同步措施,执行顺序完全由操作系统调度决定,没有任何保证。这个原则经常被忽视,导致隐蔽的并发Bug。

考虑以下代码:

1
2
3
4
5
dispatch_queue_t q = dispatch_queue_create("q", DISPATCH_QUEUE_SERIAL);
dispatch_async(q, ^{
NSLog(@"1");
});
NSLog(@"2");

直觉上可能认为“2”一定先输出(因为async后紧接着就执行了后面的代码),但实际上,“1”和“2”的先后顺序是不确定的。因为async提交任务后,新线程和当前线程是并发运行的,两个 NSLog 之间没有任何同步点。

  • 可能输出 2 1 (常见,因为开启新线程有微小的延迟)
  • 也可能输出 1 2 (若当前线程时间片用完,新线程抢先执行)

即使测试100万次都是2 1,逻辑上也绝不能写成依赖于这个顺序的代码。 若需要保证顺序,必须使用syncDispatchGroupdispatch_barrier或信号量等同步手段。

4.5 死锁:原理与避坑指南

死锁是GCD中最常见的陷阱之一。其发生的充分必要条件如下:

在一个串行队列(主队列也是串行队列)的执行上下文中,通过dispatch_sync向同一个串行队列提交任务。

背后的原理:串行队列一次只能执行一个任务。当前任务A正在执行,它调用dispatch_sync向当前队列尾添加任务B,并阻塞当前线程等待任务B完成。但任务B排在了任务A之后,必须等任务A执行完才能被调度——A等B,B等A,形成死锁。

场景1:主队列死锁

1
2
3
4
// 在主线程执行此段代码
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"这里永远执行不到");
});
  • 主线程正在执行当前方法(任务A),遇到sync向主队列添加任务B并阻塞。
  • 任务B必须等主队列当前任务(即A)完成才能执行 → 死锁。

如果在子线程调用dispatch_sync(主队列, block),则不会死锁,因为子线程阻塞等待主线程执行任务,主线程空闲后就会执行。这是合法的。

场景2:自定义串行队列死锁

1
2
3
4
5
6
7
dispatch_queue_t q = dispatch_queue_create("q", DISPATCH_QUEUE_SERIAL);
dispatch_async(q, ^{
// 子线程正在执行这个block(任务A)
dispatch_sync(q, ^{ // 向同一个串行队列sync提交任务B
NSLog(@"死锁");
});
});
  • 任务A在q中执行,遇到sync向q提交任务B并等待。
  • 任务B排在A后面,A不完,B不能开始 → 死锁。

为什么并发队列不会死锁?

1
2
3
4
5
6
dispatch_queue_t conQ = dispatch_queue_create("con", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(conQ, ^{
dispatch_sync(conQ, ^{
NSLog(@"安全");
});
});

并发队列可以同时执行多个任务。当前任务A在执行时,sync提交的任务B可以由其他线程立即并发执行,A只是等待B完成,而B并不依赖A的结束,因此不会相互等待,不会死锁。

避坑铁律:从不在串行队列任务内部用sync往同一个串行队列提交新任务。跨队列的sync是安全的。

4.6 并发控制利器:Barrier、Group、信号量

dispatch_barrier_async:并发队列的写屏障

针对自定义并发队列,barrier可以保证栅栏任务单独执行:它前面的所有任务全部执行完成后才开始执行栅栏任务,且栅栏任务执行期间队列上的其他任务都不会并发执行;栅栏任务结束后,后续任务恢复并发。

1
2
3
4
5
6
7
8
9
dispatch_queue_t conQ = dispatch_queue_create("rw", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(conQ, ^{ /* 读操作1 */ });
dispatch_async(conQ, ^{ /* 读操作2 */ });
dispatch_barrier_async(conQ, ^{
// 写操作:独占执行,保证线程安全
});
dispatch_async(conQ, ^{ /* 读操作3 */ });
dispatch_async(conQ, ^{ /* 读操作4 */ });

注意:barrier仅对自定义并发队列有效。在全局并发队列上使用等同于普通async

Dispatch Group:多任务聚合

dispatch_group可以监听一组异步任务全部完成的事件,然后再执行后续逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t conQ = dispatch_get_global_queue(0, 0);

dispatch_group_enter(group);
dispatch_async(conQ, ^{
// 任务A
dispatch_group_leave(group);
});

dispatch_group_enter(group);
dispatch_async(conQ, ^{
// 任务B
dispatch_group_leave(group);
});

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// A和B全部完成,回到主线程更新UI
});

enterleave必须严格成对,否则group永远完成不了,或者过早触发导致崩溃。

信号量:控制并发数量

1
2
3
4
5
6
7
8
9
dispatch_semaphore_t sem = dispatch_semaphore_create(3); // 最大并发3

for (int i = 0; i < 10; i++) {
dispatch_async(conQ, ^{
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
// 执行任务
dispatch_semaphore_signal(sem);
});
}

信号量将并发数量限制在指定值,超过则阻塞等待,常用于限制网络并发数或保护有限资源。

4.7 dispatch_once 的隐式同步

dispatch_once 保证代码仅执行一次,常用于单例模式。其内部实现相当于在一个全局的串行队列上同步执行,如果在once块内部再次调用同一onceTokendispatch_once,就会产生死锁(类似串行队列sync自身)。因此应避免嵌套使用。


五、三剑客对比与选型策略

维度 NSThread NSOperation GCD
抽象层级 低级(线程) 中级(操作) 低级(C接口)
线程管理 手动创建/销毁 自动队列管理 自动线程池
依赖关系 内置 addDependency 需借助Group/Barrier
任务取消 自行设计 内置 cancel 支持 自行实现
最大并发数 完全手动 maxConcurrentOperationCount 信号量间接控制
性能开销 较高 中等 最低
适用场景 精确线程控制 复杂任务依赖、并发数控制 绝大多数异步任务调度

选择建议

  • 需要面向对象封装、任务依赖、取消、并发数控制NSOperationQueue
  • 追求极致性能、简单任务调度、底层控制GCD
  • 极端场景(如RunLoop常驻线程、线程精确管理) → NSThread

绝大多数App开发中,GCD 已经能覆盖90%以上的多线程需求。理解其队列、同步/异步、线程之间的关系,以及对死锁和不确性顺序的警觉,是写出稳定多线程代码的基石。


结语

多线程编程的本质在于将任务拆解并合理地映射到线程上,同时确保数据安全与执行逻辑的正确性。无论选用哪种技术,底层规律是相通的:同步异步决定当前线程行为与等待关系,串行并发决定任务排班与并行度,而线程只是最终的执行者。

掌握这些规则后,再去使用GCD、NSOperation或NSThread,就不会再被表面API迷惑,而是能自信地推理出任何一段并发代码的真实行为。