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        ← 最后打印

关键点

  1. for 循环立即执行完,不等待任务完成。
  2. 5 个任务在不同线程并发执行,顺序不确定。
  3. 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>
所有任务完成,继续执行   ← 所有任务完成后才打印

关键点

  1. dispatch_apply同步的,会阻塞当前线程直到所有任务完成。
  2. 任务在多个线程并发执行,顺序不确定。
  3. 当前线程也会参与执行一部分任务(所以可能看到主线程也在执行任务),这是 GCD 的优化——不浪费当前线程。
  4. 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_applydispatch_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_applyenumerateObjectsWithOptions 每个任务独立、耗时、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 有什么区别?

答案

  1. dispatch_apply 是同步的,会等待所有任务完成;for + dispatch_async 是异步的,立即返回。
  2. dispatch_apply 的 index 是独立参数,没有变量捕获问题;for + dispatch_async 需要手动用局部变量捕获。
  3. dispatch_apply 更高效,GCD 内部做了优化(当前线程参与、线程池复用)。
  4. 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,因为:

  1. 代码更简洁,不需要手动管理 index 和数组访问。
  2. 底层就是 dispatch_apply,性能一样。
  3. 是 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。

posted @ 2017-07-12 16:06  Mr.陳  阅读(1199)  评论(0)    收藏  举报