iOS开发基础85-GCD 并发遍历:dispatch_group 与 dispatch_apply
GCD 并发遍历完全指南:dispatch_group 与 dispatch_apply 原理与实战
遍历数组是开发中最常见的操作之一。普通的 for 循环是串行的,只能利用一个 CPU 核心,当遍历中包含耗时操作(图片处理、数据计算、网络请求)时,效率很低。GCD 提供了两种并发遍历方式——dispatch_group(异步并发)和 dispatch_apply(同步并发,也叫快速迭代),可以充分利用多核 CPU,让遍历任务并行执行。本文深入解析这两种方式的原理、区别、使用场景和注意事项,并补充线程安全、性能对比和常见坑。
一、为什么需要并发遍历
一句话原理
普通 for 循环是串行的,一个任务执行完才执行下一个,只能用一个 CPU 核心;并发遍历让多个任务同时在多个核心上执行,遍历中包含耗时操作时效率成倍提升。
串行 vs 并发对比
假设数组有 10 个元素,每个元素处理需要 1 秒:
| 方式 | 执行方式 | 总耗时 | CPU 利用率 |
|---|---|---|---|
普通 for 循环 |
串行,一个接一个 | 约 10 秒 | 只用 1 个核心 |
| GCD 并发遍历 | 并行,多个同时执行 | 约 1~2 秒(取决于核心数) | 多个核心同时工作 |
适合并发遍历的场景
- 批量图片处理(压缩、滤镜、裁剪)
- 批量数据计算(大数据集的数学运算)
- 批量网络请求(同时请求多个接口)
- 文件批量处理(读写、转换格式)
- 任何"遍历中每个元素的处理互不依赖"的场景
不适合并发遍历的场景:遍历中每个元素的处理依赖前一个元素的结果(如累加、状态传递),这种情况必须串行。
二、方式一:dispatch_group(异步并发遍历)
一句话原理
用 for 循环把每个遍历任务提交到并发队列,用 dispatch_group 管理所有任务,通过 dispatch_group_notify 在所有任务完成后收到回调。整个过程不阻塞当前线程。
基本用法
NSArray *arr = @[@"1", @"2", @"3", @"4", @"5"];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for (NSInteger i = 0; i < arr.count; i++) {
// ⚠️ 重要:用局部变量捕获 index,避免 block 中 i 被修改
NSInteger index = i;
dispatch_group_async(group, queue, ^{
// 模拟耗时操作
sleep(1);
NSLog(@"处理元素 %ld -- 线程: %@", (long)index, [NSThread currentThread]);
});
}
// 所有任务完成后回调(不阻塞当前线程)
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"所有任务全部完成,刷新 UI");
});
NSLog(@"for 循环结束,继续执行其他代码");
运行结果分析
for 循环结束,继续执行其他代码 ← 立即打印,不阻塞
处理元素 2 -- 线程: <NSThread number=3>
处理元素 0 -- 线程: <NSThread number=4>
处理元素 1 -- 线程: <NSThread number=2>
处理元素 4 -- 线程: <NSThread number=5>
处理元素 3 -- 线程: <NSThread number=6>
所有任务全部完成,刷新 UI ← 最后打印
关键点:
for循环立即执行完,不等待任务完成。- 5 个任务在不同线程并发执行,顺序不确定。
dispatch_group_notify在所有任务完成后才触发。
关键注意点:局部变量捕获
原文的代码直接在 block 中使用 i:
// ⚠️ 不推荐的写法
for (NSInteger i = 0; i < arr.count; i++) {
dispatch_group_async(group, queue, ^{
NSLog(@"%ld", (long)i); // 直接用 i
});
}
虽然在这个特定场景下(block 值捕获)结果通常是正确的,但这不是最佳实践。推荐用局部变量捕获:
// ✅ 推荐的写法
for (NSInteger i = 0; i < arr.count; i++) {
NSInteger index = i; // 局部变量,每次迭代都是独立的
dispatch_group_async(group, queue, ^{
NSLog(@"%ld", (long)index); // 用捕获的局部变量
});
}
更推荐的方式是直接用
dispatch_apply(下一节),它天然解决了这个问题,index 是每个 block 独立的参数。
dispatch_group 的其他用法
等待所有任务完成(阻塞当前线程)
// 阻塞当前线程,直到 group 中所有任务完成
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
NSLog(@"所有任务完成");
dispatch_group_wait会阻塞当前线程,如果在主线程调用会导致 UI 卡顿,慎用。
设置超时
// 最多等待 10 秒,超时后继续执行
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC);
long result = dispatch_group_wait(group, timeout);
if (result == 0) {
NSLog(@"所有任务在超时前完成");
} else {
NSLog(@"等待超时,部分任务可能还在执行");
}
三、方式二:dispatch_apply(同步并发遍历,推荐)
一句话原理
dispatch_apply 是 GCD 专门为并发遍历设计的函数,它把指定次数的 block 提交到并发队列,自动利用多核并行执行,并且同步等待所有任务完成后才返回。它是并发遍历的首选方案。
函数签名
void dispatch_apply(size_t iterations,
dispatch_queue_t queue,
void (^block)(size_t index));
| 参数 | 说明 |
|---|---|
iterations |
遍历次数(通常是数组长度) |
queue |
执行任务的队列(传并发队列才能并行,传串行队列就是串行) |
block |
每个任务执行的 block,index 是当前遍历的下标 |
基本用法
NSArray *arr = @[@"1", @"2", @"3", @"4", @"5"];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// dispatch_apply 会阻塞当前线程,直到所有任务完成
dispatch_apply(arr.count, queue, ^(size_t index) {
// 模拟耗时操作
sleep(1);
NSLog(@"处理元素 %@ -- 线程: %@", arr[index], [NSThread currentThread]);
});
NSLog(@"所有任务完成,继续执行");
运行结果分析
处理元素 2 -- 线程: <NSThread number=4>
处理元素 3 -- 线程: <NSThread number=2>
处理元素 4 -- 线程: <NSThread number=3>
处理元素 1 -- 线程: <NSThread number=1>{name = main} ← 注意:有一个在主线程!
处理元素 5 -- 线程: <NSThread number=4>
所有任务完成,继续执行 ← 所有任务完成后才打印
关键点:
dispatch_apply是同步的,会阻塞当前线程直到所有任务完成。- 任务在多个线程并发执行,顺序不确定。
- 当前线程也会参与执行一部分任务(所以可能看到主线程也在执行任务),这是 GCD 的优化——不浪费当前线程。
index参数是每个 block 独立的,不需要担心变量捕获问题。
dispatch_apply 的核心特点
| 特点 | 说明 |
|---|---|
| 同步等待 | 调用后阻塞当前线程,所有任务完成后才返回 |
| 并发执行 | 任务在并发队列上并行执行,利用多核 |
| 当前线程参与 | 当前线程也会执行一部分任务(不浪费) |
| index 独立 | 每个 block 的 index 是独立参数,无捕获问题 |
| 专门为遍历设计 | 比手动 for + dispatch_async 更高效、更简洁 |
异步执行 dispatch_apply(不阻塞当前线程)
dispatch_apply 默认是同步的,如果不想阻塞当前线程,可以在外面包一层 dispatch_async:
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 在后台线程执行 dispatch_apply,不阻塞主线程
dispatch_apply(arr.count, dispatch_get_global_queue(0, 0), ^(size_t index) {
sleep(1);
NSLog(@"处理元素 %@", arr[index]);
});
// 所有任务完成后,回主线程刷新 UI
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"所有任务完成,刷新 UI");
});
});
NSLog(@"立即返回,不阻塞");
这种写法的效果和
dispatch_group类似,但代码更简洁,因为dispatch_apply天然管理了所有任务的完成时机。
四、两种方式对比
| 对比维度 | dispatch_group + for | dispatch_apply |
|---|---|---|
| 同步/异步 | 异步(不阻塞当前线程) | 同步(阻塞当前线程,可外包 async 变异步) |
| 代码简洁度 | 较繁琐(手动 for 循环、手动 enter/leave 或 group_async) | 简洁(一个函数搞定) |
| index 管理 | 需要手动局部变量捕获 | 天然独立的 index 参数 |
| 当前线程参与执行 | 否(任务都在队列的线程上) | 是(当前线程也执行一部分任务) |
| 完成回调 | dispatch_group_notify |
函数返回即完成(或外包 async 后手动回调) |
| 灵活性 | 高(可以动态添加任务、可以 enter/leave 管理异步任务) | 低(只能固定次数的遍历) |
| 适用场景 | 任务数量不固定、包含异步网络请求、需要动态管理 | 固定次数的同步耗时操作遍历(图片处理、数据计算) |
选择建议
- 固定次数的同步耗时遍历(图片批量处理、数据计算)→ 用
dispatch_apply,简洁高效。 - 任务数量不固定或包含异步操作(网络请求、需要动态 enter/leave)→ 用
dispatch_group。 - 不想阻塞主线程 →
dispatch_apply外包一层dispatch_async,或者直接用dispatch_group。
五、其他并发遍历方式
1. enumerateObjectsWithOptions: NSEnumerationConcurrent
Foundation 框架的 NSArray 提供了并发遍历的方法:
NSArray *arr = @[@"1", @"2", @"3", @"4", @"5"];
[arr enumerateObjectsWithOptions:NSEnumerationConcurrent
usingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
sleep(1);
NSLog(@"处理元素 %@ -- index %lu", obj, (unsigned long)idx);
}];
NSLog(@"遍历完成");
特点:
- 底层就是用
dispatch_apply实现的。 - 同步阻塞,和
dispatch_apply一样。 - 代码更简洁,不需要手动管理 index 和数组访问。
- 只能用于
NSArray/NSSet/NSDictionary等 Foundation 集合。
推荐:遍历 Foundation 数组时,优先用
enumerateObjectsWithOptions:NSEnumerationConcurrent,比手动dispatch_apply更简洁。
2. 自定义并发队列 + for 循环
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.example.concurrent", DISPATCH_QUEUE_CONCURRENT);
for (NSInteger i = 0; i < arr.count; i++) {
NSInteger index = i;
dispatch_async(concurrentQueue, ^{
sleep(1);
NSLog(@"处理元素 %ld", (long)index);
});
}
这种方式和 dispatch_group 类似,但没有 group 管理完成时机,需要自己用计数或 group 来判断完成。不推荐,直接用 dispatch_apply 或 dispatch_group 更好。
六、线程安全与注意事项
1. 并发遍历中的数据竞争
并发遍历最大的坑是多个线程同时修改共享数据,导致数据错乱或崩溃。
// ⚠️ 错误示例:多个线程同时向可变数组添加对象,会崩溃
NSMutableArray *results = [NSMutableArray array];
dispatch_apply(arr.count, queue, ^(size_t index) {
id processed = [self process:arr[index]];
[results addObject:processed]; // 多线程同时写,NSMutableArray 不是线程安全的!
});
解决方案一:用串行队列写数据
// ✅ 正确:读在并发队列,写在串行队列
NSMutableArray *results = [NSMutableArray array];
dispatch_queue_t writeQueue = dispatch_queue_create("com.example.write", DISPATCH_QUEUE_SERIAL);
dispatch_apply(arr.count, queue, ^(size_t index) {
id processed = [self process:arr[index]];
dispatch_async(writeQueue, ^{
[results addObject:processed]; // 写操作在串行队列,线程安全
});
});
// 等待写队列完成
dispatch_sync(writeQueue, ^{});
NSLog(@"结果: %@", results);
解决方案二:用锁保护
NSMutableArray *results = [NSMutableArray array];
NSLock *lock = [[NSLock alloc] init];
dispatch_apply(arr.count, queue, ^(size_t index) {
id processed = [self process:arr[index]];
[lock lock];
[results addObject:processed];
[lock unlock];
});
解决方案三:用线程安全的容器或先存到独立数组最后合并
// 每个线程处理自己的结果,最后合并
NSMutableArray *allResults = [NSMutableArray arrayWithCapacity:arr.count];
// 初始化占位
for (NSInteger i = 0; i < arr.count; i++) {
[allResults addObject:[NSNull null]];
}
dispatch_apply(arr.count, queue, ^(size_t index) {
id processed = [self process:arr[index]];
// 每个线程写不同的位置,不冲突
@synchronized(allResults) {
allResults[index] = processed;
}
});
2. 不要在并发遍历中修改被遍历的数组
// ⚠️ 错误:遍历的同时修改数组,会崩溃
NSMutableArray *mutableArr = [NSMutableArray arrayWithArray:arr];
dispatch_apply(mutableArr.count, queue, ^(size_t index) {
if ([mutableArr[index] isEqualToString:@"3"]) {
[mutableArr removeObjectAtIndex:index]; // 遍历中修改,崩溃!
}
});
正确做法:先复制一份,遍历原数组,修改副本。
3. 注意任务的执行顺序不确定
并发遍历中,任务的执行顺序是不确定的,不要依赖顺序。如果需要按顺序处理结果,应该:
- 用
index把结果存到对应位置(如上面的方案三)。 - 或者所有任务完成后再排序。
4. 注意主线程阻塞
dispatch_apply 是同步的,如果在主线程调用且任务耗时很长,会导致 UI 卡顿。
// ⚠️ 不推荐:在主线程调用耗时的 dispatch_apply
dispatch_apply(1000, queue, ^(size_t index) {
[self heavyProcessing:data[index]]; // 每个任务耗时 1 秒
});
// 主线程被阻塞很久,UI 完全无响应
正确做法:外包一层 dispatch_async,在后台线程执行。
5. 注意队列类型
dispatch_apply 传入串行队列时,任务是串行执行的,不会并发!
dispatch_queue_t serialQueue = dispatch_queue_create("com.example.serial", DISPATCH_QUEUE_SERIAL);
// ⚠️ 串行队列,任务一个接一个执行,不会并发,和普通 for 循环一样
dispatch_apply(arr.count, serialQueue, ^(size_t index) {
sleep(1);
});
// 总耗时 5 秒,不是 1 秒
要并发必须传并发队列(全局队列或自定义并发队列)。
七、性能对比与使用场景
性能对比(1000 个元素,每个处理 10ms)
| 方式 | 总耗时(约) | 说明 |
|---|---|---|
| 普通 for 循环 | 10 秒 | 串行,只用 1 个核心 |
| dispatch_apply(并发) | 1.5~2.5 秒 | 并行,用多个核心(取决于设备核心数) |
| enumerateObjectsWithOptions | 1.5~2.5 秒 | 底层就是 dispatch_apply |
实际加速比取决于 CPU 核心数和每个任务的耗时。任务越耗时,并发收益越大;任务很轻量时,并发的线程调度开销可能超过收益。
什么时候用并发遍历
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 批量图片处理(压缩/滤镜) | dispatch_apply 或 enumerateObjectsWithOptions |
每个任务独立、耗时、CPU 密集 |
| 批量数据计算 | dispatch_apply |
CPU 密集,独立计算 |
| 批量网络请求 | dispatch_group + enter/leave |
异步任务,需要 group 管理完成 |
| 批量文件读写 | dispatch_apply(注意线程安全) |
IO 密集,可并发 |
| 遍历 + 累加/状态传递 | 普通 for 循环 | 有依赖,必须串行 |
| 遍历 + 顺序输出 | dispatch_apply + index 存结果 |
并发处理,按 index 有序输出 |
小任务不建议并发
如果每个任务非常轻量(如只是 NSLog 一下),并发的线程创建和调度开销可能超过并行收益,反而比串行慢。并发遍历适合每个任务有一定耗时的场景。
八、Swift 版本对照
1. DispatchQueue.concurrentPerform(对应 dispatch_apply)
Swift 中 dispatch_apply 变成了 DispatchQueue.concurrentPerform:
let arr = ["1", "2", "3", "4", "5"]
// 同步并发遍历,阻塞当前线程
DispatchQueue.concurrentPerform(iterations: arr.count) { index in
sleep(1)
print("处理元素 \(arr[index]) -- 线程: \(Thread.current)")
}
print("所有任务完成")
2. 异步执行(不阻塞主线程)
DispatchQueue.global().async {
DispatchQueue.concurrentPerform(iterations: arr.count) { index in
sleep(1)
print("处理元素 \(arr[index])")
}
DispatchQueue.main.async {
print("所有任务完成,刷新 UI")
}
}
print("立即返回,不阻塞")
3. DispatchGroup(对应 dispatch_group)
let group = DispatchGroup()
let queue = DispatchQueue.global()
for i in 0..<arr.count {
group.enter()
queue.async {
sleep(1)
print("处理元素 \(arr[i])")
group.leave()
}
}
group.notify(queue: .main) {
print("所有任务完成,刷新 UI")
}
print("立即返回,不阻塞")
4. Array 的并发遍历(对应 enumerateObjectsWithOptions)
Swift 中没有直接的并发遍历方法,需要用 concurrentPerform:
DispatchQueue.concurrentPerform(iterations: arr.count) { index in
let obj = arr[index]
// 处理 obj
}
九、常见问题与坑
Q1:dispatch_apply 会阻塞主线程吗?
答案:会。dispatch_apply 是同步函数,在哪个线程调用就阻塞哪个线程。如果在主线程调用且任务耗时,UI 会卡顿。
解决:外包一层 dispatch_async 到后台队列执行,完成后回主线程刷新 UI。
Q2:dispatch_apply 的任务一定在子线程执行吗?
答案:不一定。dispatch_apply 会让当前线程也参与执行一部分任务,所以如果在主线程调用,可能有部分任务在主线程执行。这是 GCD 的优化,不浪费当前线程。
如果任务涉及 UI 操作,需要注意判断线程,或者统一在后台线程执行 dispatch_apply。
Q3:dispatch_apply 和 for 循环 + dispatch_async 有什么区别?
答案:
dispatch_apply是同步的,会等待所有任务完成;for + dispatch_async是异步的,立即返回。dispatch_apply的 index 是独立参数,没有变量捕获问题;for + dispatch_async需要手动用局部变量捕获。dispatch_apply更高效,GCD 内部做了优化(当前线程参与、线程池复用)。dispatch_apply代码更简洁。
Q4:并发遍历中怎么保证结果有序?
答案:并发遍历的执行顺序不确定,但可以用 index 把结果存到对应位置,保证最终结果有序:
NSMutableArray *results = [NSMutableArray arrayWithCapacity:arr.count];
for (NSInteger i = 0; i < arr.count; i++) {
[results addObject:[NSNull null]]; // 占位
}
dispatch_queue_t writeQueue = dispatch_queue_create("write", DISPATCH_QUEUE_SERIAL);
dispatch_apply(arr.count, queue, ^(size_t index) {
id processed = [self process:arr[index]];
dispatch_async(writeQueue, ^{
results[index] = processed; // 按 index 存,保证有序
});
});
dispatch_sync(writeQueue, ^{});
Q5:并发遍历中网络请求怎么用?
答案:网络请求是异步的,dispatch_apply 不适合(因为 dispatch_apply 的 block 返回时请求还没完成)。应该用 dispatch_group + enter/leave:
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
for (NSInteger i = 0; i < urls.count; i++) {
dispatch_group_enter(group); // 进入 group
[self requestWithURL:urls[i] completion:^(id data) {
// 处理数据
dispatch_group_leave(group); // 请求完成,离开 group
}];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"所有网络请求完成");
});
注意:网络请求的 completion 可能在主线程或后台线程,
leave在哪个线程调用都可以,group 是线程安全的。
Q6:并发遍历的数量有限制吗?
答案:GCD 全局队列有线程池限制(通常 64 个线程左右),但 dispatch_apply 内部会管理线程复用,不会无限创建线程。即使遍历 10000 次,也只会用有限的线程并发执行。
但如果每个任务都阻塞(如 sleep),且数量很大,可能耗尽线程池,导致其他 GCD 任务排队。实际开发中注意任务不要永久阻塞。
Q7:dispatch_apply 可以用在串行队列上吗?
答案:可以,但任务会串行执行(一个接一个),不会并发,效果和普通 for 循环一样。要并发必须用并发队列。
Q8:enumerateObjectsWithOptions:NSEnumerationConcurrent 和 dispatch_apply 哪个好?
答案:遍历 NSArray 时推荐用 enumerateObjectsWithOptions:NSEnumerationConcurrent,因为:
- 代码更简洁,不需要手动管理 index 和数组访问。
- 底层就是
dispatch_apply,性能一样。 - 是 Foundation 原生 API,更符合 Swift/OC 的编码习惯。
但如果遍历的不是 Foundation 数组(如自定义数据结构、固定次数的循环),还是要用 dispatch_apply。
十、总结
- 为什么需要并发遍历:普通 for 循环串行,只用一个核心;遍历中包含耗时操作时,并发遍历能利用多核,效率成倍提升。
- 两种核心方式:
- dispatch_group + for:异步,不阻塞当前线程,适合任务数量不固定或包含异步操作(如网络请求)。
- dispatch_apply:同步,阻塞当前线程,专门为并发遍历设计,简洁高效,适合固定次数的同步耗时操作(图片处理、数据计算)。
- dispatch_apply 的特点:同步等待、并发执行、当前线程也参与执行、index 独立无捕获问题。
- 其他方式:
enumerateObjectsWithOptions:NSEnumerationConcurrent(Foundation 数组推荐,底层就是 dispatch_apply)。 - 线程安全:并发遍历中修改共享数据(NSMutableArray、全局变量)必须加锁或用串行队列写,否则会崩溃或数据错乱。
- 注意事项:不要在主线程调用耗时的 dispatch_apply(UI 卡顿)、不要在遍历中修改被遍历的数组、任务执行顺序不确定、并发队列才能并发、小任务不建议并发(调度开销超过收益)。
- Swift 对应:
DispatchQueue.concurrentPerform(对应 dispatch_apply)、DispatchGroup(对应 dispatch_group)。 - 选择建议:Foundation 数组遍历 →
enumerateObjectsWithOptions:NSEnumerationConcurrent;固定次数同步耗时 →dispatch_apply;异步网络请求 →dispatch_group+ enter/leave。

浙公网安备 33010602011771号