iOS多线程技术3 - GCD的详细用法

文章目录

  • 进程 & 线程 & 队列
  • 三种多线程技术对比
  • 任务和队列
  • 任务的创建和执行
  • 死锁现象
  • 线程间的通信
  • GCD 中的一些常用方法
    • dispatch_after
    • dispatch_once
    • dispatch_barrier
    • dispatch_group
      • dispatch_group_notify
    • dispatch_apply
    • dispatch_semaphore
  • 多线程中需要注意的一些问题

建议大家在学习本节 GCD 知识之前,先看一下我之前写的两篇博客:iOS多线程技术1 - NSThread的一般用法, iOS多线程技术2 - NSOperation和NSOperationQueue的详细用法,这样可以更加清楚的去了解 iOS 开发中几种多线程技术的不同表现形式和优缺点。


进程 & 线程 & 队列

工欲善其事,必先利其器,在进入 GCD 学习之前,我们有必要先了解一下多线程的一些概念。

• 进程:进程是程序在计算机上的一次执行活动。例如打开一个 app,就开启了一个进程,一个进程可以并发多个线程。

• 线程:线程就是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个线程同一时间只能执行一个任务,平时我们所说的多任务并发实际上是多个线程并发去执行多个任务。

在 iOS 程序中,主线程(又被称为 UI 线程)的主要任务是处理 UI 事件、显示和刷新 UI(也只有主线程才有直接修改 UI 的能力),耗时操作都要放在子线程(异步线程)中进行处理。

开启子线程处理耗时操作,可以有效提高程序的执行效率和 CPU 资源的利用率。但是开启线程会占用一定的内存(主线程的堆栈大小是 1M,第二个线程开始都是 512KB,并且该值不能通过编译器开关或线程 API 函数来更改),会降低程序的性能,所以一般不要同时开启过多的子线程。

  • 同步线程:同步线程会阻塞当前线程去执行线程内的任务,执行完之后才会返回当前线程。

  • 异步线程:异步线程不会阻塞当前线程,会开启子线程并在子线程中执行任务。

• 串行队列:在串行队列中,线程任务按先后顺序逐个执行(前边的任务执行之后才会依次执行后边的任务)。

• 并发队列:在并发队列中,多个任务按添加顺序并发执行(不用等待前边的任务执行完再执行后边的任务)。

关于并发队列,虽然多个任务是按照添加顺序并发执行的,但由于每个任务所在的线程什么时候去执行任务无法确定,所以一般看起来并发执行的多个任务的执行顺序是无序的。

并发和并行的区别:并行是基于多核设备的,并行一定是并发,但并发不一定是并行。


三种多线程技术对比

• NSThread(抽象层次:低)

优点:轻量级,简单易用,可以直接操作线程对象。

缺点: 需要自己管理线程的生命周期。线程同步对数据的加锁会有一定的系统开销。

了解 NSThread 可查看 iOS多线程技术1 - NSThread的一般用法

• NSOperation(抽象层次:中)

优点:不需要关心线程管理,数据同步的事情,可以把精力放在要执行的操作上。基于 GCD,是对 GCD 的封装,比 GCD 更加面向对象。

缺点: NSOperation 是个抽象类,使用它必须使用它的子类,可以实现它或者使用它定义好的两个子类 NSInvocationOperation、NSBlockOperation。

了解 NSThread 可查看 iOS多线程技术2 - NSOperation和NSOperationQueue的详细用法

• GCD(抽象层次:高)

优点:是 Apple 开发的一个多核编程的解决方法,简单易用,效率高,速度快,基于 C 语言,更底层更高效,并且不是 Cocoa 框架的一部分,自动管理线程生命周期(创建线程、调度任务、销毁线程)。
缺点: 使用 GCD 的场景如果很复杂,就有非常大的可能遇到死锁问题。

GCD 抽象层次最高,使用也简单,因此,苹果官方推荐开发者使用 GCD。


任务和队列

任务:GCD 中的任务是存放于 block 中的一段执行代码,类似于 NSOperation 对象(一个封装了执行代码的操作对象),block 最终都会在某个队列中执行。

队列:即存放任务的的队列。队列是一种特殊的线性表,采用 FIFO(先进先出)原则,即新任务总是被插入到队列的末尾,而读取任务的时候总是从队列的头部开始读取。每读取一个任务,就从队列中释放一个任务。


队列

在 GCD 中有两种队列:即串行队列和并发队列。两者都符合 FIFO原则,两者的主要区别是执行顺序不同,以及开启线程数不同。

串行队列:只开启一个线程,一个任务执行完毕后,再执行下一个任务。

串行队列的创建方法是 dispatch_queue_create(const char *_Nullable label, dispatch_queue_attr_t _Nullable attr),返回值为 dispatch_queue_t 类型,即 GCD 中的线程类型。

第一个参数:指定生成返回的队列的名称。

第二个参数:指定为 NULL 或 nil 或 DISPATCH_QUEUE_SERIAL 时,生成串行队列。指定为 DISPATCH_QUEUE_CONCURRENT,生成并行队列。

在 iOS 开发中,还有一个特殊的串行队列,就是主线程队列(可通过 dispatch_get_main_queue() 方法获取主线程队列)。

并发队列:可以开启多个线程,同时执行多个任务。(并发队列的并发功能只有在异步(dispatch_async)方法中才有效。

GCD 中创建并发队列有两种方式:

一种即为上述 dispatch_queue_create(const char *_Nullable label, dispatch_queue_attr_t _Nullable attr) 方法并将第二个参数指定为 DISPATCH_QUEUE_CONCURRENT

另外一种是获取全局并发队列 dispatch_get_global_queue(long identifier, unsigned long flags)。第一个参数表示队列优先级,一般使用 DISPATCH_QUEUE_PRIORITY_DEFAULT。第二个参数暂时没用,用 0 即可。

全局并发队列由整个进程共享,有高、中(默认)、低、后台四个优先级别。

// 并发队列优先级参数可选:
#define DISPATCH_QUEUE_PRIORITY_HIGH 2
#define DISPATCH_QUEUE_PRIORITY_DEFAULT 0
#define DISPATCH_QUEUE_PRIORITY_LOW (-2)
#define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN

任务的创建和执行

GCD 提供了同步执行任务和异步执行任务两种方法:

// 同步执行
void dispatch_sync(dispatch_queue_t queue, DISPATCH_NOESCAPE dispatch_block_t block);

// 异步执行
void dispatch_async(dispatch_queue_t queue, dispatch_block_t block);

第一个参数需要传入一个队列,第二个参数是一个 block,block 内即为任务的执行代码。由此可见,创建并执行一个任务可以分为四种情况:

1、同步执行并发队列
2、同步执行串行队列
3、异步执行并发队列
4、异步执行串行队列


1、同步执行并发队列

/**
 * 同步执行并发队列
 * 特点:在当前线程中执行任务,不会开启新的线程,顺序依次执行任务。
 */
+ (void)syncConcurrentQueue {
    NSLog(@"current thread -- %@", [NSThread currentThread]); // 打印当前线程
    
    NSLog(@"task -- begin");
    
    // 创建一个并发队列
    dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_sync(queue, ^{
        // 任务 1
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task1 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_sync(queue, ^{
        // 任务 2
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task2 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_sync(queue, ^{
        // 任务 3
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task3 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    NSLog(@"task -- end");
}
NSLog(@"-- start -- ");
    
// 同步执行并发队列
[GCDSummary1 syncConcurrentQueue];

NSLog(@"-- end --");

运行后查看打印信息:

9-09-20 00:11:13.534254+0800 GCDSummary[8995:321602] -- start --
2019-09-20 00:11:13.534489+0800 GCDSummary[8995:321602] current thread -- <NSThread: 0x60000111cec0>{number = 1, name = main}
2019-09-20 00:11:13.534595+0800 GCDSummary[8995:321602] task -- begin
2019-09-20 00:11:14.536035+0800 GCDSummary[8995:321602] task1 -- <NSThread: 0x60000111cec0>{number = 1, name = main}
2019-09-20 00:11:15.537558+0800 GCDSummary[8995:321602] task2 -- <NSThread: 0x60000111cec0>{number = 1, name = main}
2019-09-20 00:11:16.539100+0800 GCDSummary[8995:321602] task3 -- <NSThread: 0x60000111cec0>{number = 1, name = main}
2019-09-20 00:11:16.539347+0800 GCDSummary[8995:321602] task -- end
2019-09-20 00:11:16.539605+0800 GCDSummary[8995:321602] -- end --

可以看到,同步执行并发队列会阻塞当前线程,不会开启新线程,任务按照添加顺序依次执行,上一个任务结束之后,下一个任务才开始执行。


2、同步执行串行队列

/**
 * 同步执行串行队列
 * 特点:在当前线程中执行任务,不会开启新的线程,任务是串行的,顺序依次执行任务。
 */
+ (void)syncSerialQueue {
    NSLog(@"current thread -- %@", [NSThread currentThread]); // 打印当前线程
    
    NSLog(@"task -- begin");
    
    // 创建一个并发队列
    dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_SERIAL);
    
    dispatch_sync(queue, ^{
        // 任务 1
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task1 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_sync(queue, ^{
        // 任务 2
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task2 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_sync(queue, ^{
        // 任务 3
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task3 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    NSLog(@"task -- end");
}
NSLog(@"-- start -- ");

// 同步执行串行队列
[GCDSummary1 syncSerialQueue];

NSLog(@"-- end --");

运行后查看打印信息:

2019-09-20 00:14:53.346764+0800 GCDSummary[9049:323621] -- start --
2019-09-20 00:14:53.347006+0800 GCDSummary[9049:323621] current thread -- <NSThread: 0x600002d8a340>{number = 1, name = main}
2019-09-20 00:14:53.347121+0800 GCDSummary[9049:323621] task -- begin
2019-09-20 00:14:54.348429+0800 GCDSummary[9049:323621] task1 -- <NSThread: 0x600002d8a340>{number = 1, name = main}
2019-09-20 00:14:55.349962+0800 GCDSummary[9049:323621] task2 -- <NSThread: 0x600002d8a340>{number = 1, name = main}
2019-09-20 00:14:56.351489+0800 GCDSummary[9049:323621] task3 -- <NSThread: 0x600002d8a340>{number = 1, name = main}
2019-09-20 00:14:56.351749+0800 GCDSummary[9049:323621] task -- end
2019-09-20 00:14:56.351891+0800 GCDSummary[9049:323621] -- end --

可以看到,同步执行串行队列会阻塞当前线程,不会开启新线程,任务按照添加顺序依次执行,上一个任务结束之后,下一个任务才开始执行。


3、异步执行并发队列

/**
 * 异步执行并发队列
 * 特点:可以开启多个线程,并行执行任务。
 */
+ (void)asyncConcurrentQueue {
    NSLog(@"current thread -- %@", [NSThread currentThread]); // 打印当前线程
    
    NSLog(@"task -- begin");
    
    // 创建一个并发队列
    dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_async(queue, ^{
        // 任务 1
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task1 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_async(queue, ^{
        // 任务 2
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task2 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_async(queue, ^{
        // 任务 3
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task3 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    NSLog(@"task -- end");
}
NSLog(@"-- start -- ");

// 异步执行并发队列
[GCDSummary1 asyncConcurrentQueue];

NSLog(@"-- end --");

运行后查看打印信息:

2019-09-20 00:17:39.583801+0800 GCDSummary[9095:325209] -- start --
2019-09-20 00:17:39.584022+0800 GCDSummary[9095:325209] current thread -- <NSThread: 0x600001d96900>{number = 1, name = main}
2019-09-20 00:17:39.584108+0800 GCDSummary[9095:325209] task -- begin
2019-09-20 00:17:39.584210+0800 GCDSummary[9095:325209] task -- end
2019-09-20 00:17:39.584295+0800 GCDSummary[9095:325209] -- end --
2019-09-20 00:17:40.585894+0800 GCDSummary[9095:325268] task2 -- <NSThread: 0x600001df3680>{number = 4, name = (null)}
2019-09-20 00:17:40.585894+0800 GCDSummary[9095:325267] task1 -- <NSThread: 0x600001ddccc0>{number = 3, name = (null)}
2019-09-20 00:17:40.585894+0800 GCDSummary[9095:325269] task3 -- <NSThread: 0x600001dae880>{number = 5, name = (null)}

可以看到,异步执行并发队列不会阻塞当前线程,可以开启新线程,任务是并发执行的。


4、异步执行串行队列

/**
 * 异步执行串行队列
 * 特点:会开启新的线程,任务是串行的,顺序依次执行任务。
 */
+ (void)asyncSerialQueue {
    NSLog(@"current thread -- %@", [NSThread currentThread]); // 打印当前线程
    
    NSLog(@"task -- begin");
    
    // 创建一个串行队列
    dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_SERIAL);
    
    dispatch_async(queue, ^{
        // 任务 1
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task1 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_async(queue, ^{
        // 任务 2
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task2 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    dispatch_async(queue, ^{
        // 任务 3
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"task3 -- %@", [NSThread currentThread]); // 打印当前线程
    });
    
    NSLog(@"task -- end");
}
NSLog(@"-- start -- ");

// 异步执行串行队列
[GCDSummary1 asyncSerialQueue];

NSLog(@"-- end --");

运行后查看打印信息:

2019-09-20 00:21:03.705704+0800 GCDSummary[9135:326909] -- start --
2019-09-20 00:21:03.705934+0800 GCDSummary[9135:326909] current thread -- <NSThread: 0x600002be1cc0>{number = 1, name = main}
2019-09-20 00:21:03.706030+0800 GCDSummary[9135:326909] task -- begin
2019-09-20 00:21:03.706172+0800 GCDSummary[9135:326909] task -- end
2019-09-20 00:21:03.706256+0800 GCDSummary[9135:326909] -- end --
2019-09-20 00:21:04.710225+0800 GCDSummary[9135:326949] task1 -- <NSThread: 0x600002b8cc00>{number = 3, name = (null)}
2019-09-20 00:21:05.711022+0800 GCDSummary[9135:326949] task2 -- <NSThread: 0x600002b8cc00>{number = 3, name = (null)}
2019-09-20 00:21:06.713446+0800 GCDSummary[9135:326949] task3 -- <NSThread: 0x600002b8cc00>{number = 3, name = (null)}

可以看到,异步执行串行队列不会阻塞当前线程,可以开启新线程,任务按照添加顺序依次执行,上一个任务结束之后,下一个任务才开始执行。

根据以上信息,我们发现:凡是同步操作都会阻塞当前线程且不会开启新线程,凡是异步操作都不会阻塞当前线程且会开启新线程,凡是串行队列都会依次执行任务,只有在并行队列中异步操作才可以并发执行任务

由此,我们可以把四种情况总结到一个表格中:

并发队列 串行队列
同步执行 阻塞当前线程
无法开启新线程
任务依次执行
阻塞当前线程
无法开启新线程
任务依次执行
异步执行 不阻塞当前线程
可以开启新线程
任务并发执行
不阻塞当前线程
可以开启新线程
任务依次执行

注意:异步执行串行队列有一个特殊情况不会开启新线程,就是回到主线程队列异步执行任务,这种情况下任务都是在主线程中执行的。


死锁现象

我们知道,死锁的产生是因为当前线程中某个追加任务需要在当前线程中同步执行。当前线程的任务需要等待这个追加的任务同步执行完才能执行,而这个追加的任务需要等待当前线程的任务执行完才能执行,以至于两个任务相互等待,造成死锁。

下面我们通过 2 个具体的例子来直观的感受死锁的产生原因:

1、主线程队列中的死锁现象:

NSLog(@"-- start -- ");
    
dispatch_sync(dispatch_get_main_queue(), ^{
    NSLog(@"task in main queue");
});
    
NSLog(@"-- end --");

运行后查看打印信息:

2019-09-20 16:40:58.020882+0800 GCDSummary[17048:603509] -- start --
(lldb) 

并且代码段处发生 crash:
在这里插入图片描述可以看到,在主线程队列中获取主线程队列并同步执行任务造成了死锁现象。

在这段代码中,NSLog(@"-- start -- "); 执行之后,需要同步执行 dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"task in main queue"); });,而 dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"task in main queue"); }); 需要先等待主线程任务执行完(等待 NSLog(@"-- end --"); 的执行),这样就造成了相互等待的死锁现象。

2、串行队列中的死锁现象:

在前边有提及,主线程队列其实是串行队列的一种,只是系统对其功能进行了特殊化处理,专门用来处理 UI 更新等。所以,死锁现象的产生本质上就是因为在串行队列中同步操作使用不当造成的。

下面例子中,我们创建一个串行队列,在其中嵌套一个同步操作:

NSLog(@"-- start -- ");
    
dispatch_queue_t serialQueue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_SERIAL);

dispatch_async(serialQueue, ^{
    NSLog(@"async operation begin");
        
    // 在 serialQueue 中同步执行一个操作
    dispatch_sync(serialQueue, ^{
        NSLog(@"sync operation in serialQueue");
    });
        
    NSLog(@"async operation end");
});
    
NSLog(@"-- end --");

运行后查看打印信息:

2019-09-20 20:48:39.529188+0800 GCDSummary[20114:665685] -- start --
2019-09-20 20:48:39.529355+0800 GCDSummary[20114:665685] -- end --
2019-09-20 20:48:39.529375+0800 GCDSummary[20114:665738] async operation begin
(lldb) 

并且代码段处发生 crash:
在这里插入图片描述和上面情况一样,此处也是在同步执行的代码处崩溃,所以,在开发过程中我们要特别注意对同一串行队列的同步执行情况。

如果在一个串行队列中嵌套了一个同步操作,且该同步操作也是放进了该串行队列,那么就会引起死锁现象。


线程间的通信

线程间的通信比较常用的就是主线程切换到子线程,子线程返回到主线程。

一般情况下,当我们需要处理一些数据或者封装模型时,我们会创建一个子线程来执行这些耗时操作,当任务完成后,再回到主线程刷新 UI。下面我们就对此进行模拟:

// 当前线程为主线程
NSLog(@"current thread -- %@", [NSThread currentThread]);
    
// 需要处理一些耗时操作(比如获取本地数据)时,创建子线程处理
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    NSLog(@"get local data... -- %@", [NSThread currentThread]);
    [NSThread sleepForTimeInterval:2]; // 模拟耗时操作
    NSLog(@"get local data successfully");
        
    // 返回主线程
    dispatch_async(dispatch_get_main_queue(), ^{
        // 根据拿出的数据更新 UI
        NSLog(@"update UI... -- %@", [NSThread currentThread]);
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"update UI successfully");
    });
});

运行后查看打印信息:

2019-09-20 23:44:14.985220+0800 GCDSummary[22326:722508] current thread -- <NSThread: 0x6000009d9000>{number = 1, name = main}
2019-09-20 23:44:14.985533+0800 GCDSummary[22326:722562] get local data... -- <NSThread: 0x60000098e6c0>{number = 3, name = (null)}
2019-09-20 23:44:16.989427+0800 GCDSummary[22326:722562] get local data successfully
2019-09-20 23:44:16.989915+0800 GCDSummary[22326:722508] update UI... -- <NSThread: 0x6000009d9000>{number = 1, name = main}
2019-09-20 23:44:17.991351+0800 GCDSummary[22326:722508] update UI successfully

可以看到,在主线程中成功创建了子线程并执行了一些耗时操作,耗时操作执行完后又成功回到了主线程并刷新了用户界面。


GCD 中的一些常用方法

dispatch_after

有时我们需要延迟执行一些任务,这时我们就可以使用 GCD 提供的 dispatch_after 方法进行实现。

注意:dispatch_after 方法并不是在指定时间之后开始执行任务,而是在指定时间之后将任务追加到主线程队列中。所以严格来说,这个指定时间并不绝对准确,但如果我们只是想要大致延迟一段时间执行任务的话,dispatch_after 方法将会非常便捷。

// 开启子线程
dispatch_async(dispatch_queue_create(nil, DISPATCH_QUEUE_CONCURRENT), ^{
    NSLog(@"current thread -- %@", [NSThread currentThread]);
        
    // 3 秒后执行任务
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"do something -- %@", [NSThread currentThread]);
    });
});

运行后查看打印信息:

2019-09-21 00:38:02.818511+0800 GCDSummary[23043:741520] current thread -- <NSThread: 0x600000fed2c0>{number = 3, name = (null)}
2019-09-21 00:38:05.819058+0800 GCDSummary[23043:741431] do something -- <NSThread: 0x600000fb35c0>{number = 1, name = main}

可以看出,3 秒后回到主线程执行了操作。


dispatch_once

dispatch_once 方法可以保证在整个程序(APP)运行期间,block 中的代码只被执行一次,所以我们一般会用来编写单例类。

#import 

@interface Singleton : NSObject

// 类方法获取单例对象
+ (instancetype)shared;

@end


#import "Singleton.h"

@implementation Singleton

+ (instancetype)shared {
    static Singleton *instance = nil;
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    
    return instance;
}

@end
// 使用 shared 方法创建 3 个实例对象
Singleton *instance1 = [Singleton shared];
Singleton *instance2 = [Singleton shared];
Singleton *instance3 = [Singleton shared];
// 打印 3 个对象的地址
NSLog(@"%p, %p, %p", instance1, instance2, instance3);

运行后查看打印信息:

2019-09-22 00:26:54.715915+0800 GCDSummary[33096:987305] 0x6000019602f0, 0x6000019602f0, 0x6000019602f0

可以看到,3 个实例对象的地址相同,说明通过 GCD 的 dispatch_once 方法,我们成功创建了单例类(对象的实例化方法 instance = [[self alloc] init]; 只执行了一次)。


dispatch_barrier

假如现在有几组任务,我们并不关心每组中的各个任务的执行顺序,但是我们要求这几组任务按顺序分批进行,也就是说第一组任务全部执行之后,再启动第二组任务,以此类推。在这种情况下,我们使用 dispatch_barrier 函数将会非常高效,dispatch_barrier 函数又称作栅栏函数,顾名思义,就是像栅栏一样可以把不同任务分开。

在下面例子中,我们假设有两组任务,第一组包含任务 1 和任务 2,第二组包含任务 3 和任务 4,要求第一组任务执行完后再开始执行第二组任务:

NSLog(@"-- start --");
    
// 创建一个并行队列
dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_CONCURRENT);
    
// 向队列中追加任务
dispatch_async(queue, ^{
    [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
    NSLog(@"task1 -- %@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
    [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
    NSLog(@"task2 -- %@", [NSThread currentThread]);
});
    
// 使用栅栏函数
dispatch_barrier_async(queue, ^{
    [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
    NSLog(@"barrier task -- %@", [NSThread currentThread]);
});
    
// 在栅栏函数后边继续追加任务
dispatch_async(queue, ^{
    [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
    NSLog(@"task3 -- %@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
    [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
    NSLog(@"task4 -- %@", [NSThread currentThread]);
});
    
NSLog(@"-- end --");

运行后查看打印信息:

2019-09-22 01:09:31.383838+0800 GCDSummary[33672:1003194] -- start --
2019-09-22 01:09:31.384070+0800 GCDSummary[33672:1003194] -- end --
2019-09-22 01:09:32.388449+0800 GCDSummary[33672:1003251] task1 -- <NSThread: 0x60000154f100>{number = 4, name = (null)}
2019-09-22 01:09:32.388455+0800 GCDSummary[33672:1003252] task2 -- <NSThread: 0x600001574540>{number = 3, name = (null)}
2019-09-22 01:09:33.389078+0800 GCDSummary[33672:1003252] barrier task -- <NSThread: 0x600001574540>{number = 3, name = (null)}
2019-09-22 01:09:34.390192+0800 GCDSummary[33672:1003251] task4 -- <NSThread: 0x60000154f100>{number = 4, name = (null)}
2019-09-22 01:09:34.390226+0800 GCDSummary[33672:1003252] task3 -- <NSThread: 0x600001574540>{number = 3, name = (null)}

可以看到,task1、task2 行完成后,开始执行 dispatch_barrier_async 方法中的任务,之后追加的 task3 和 task4 是在 dispatch_barrier_async 方法中的任务执行完成后才开始执行的。

简而言之,在一个并发队列中,执行完栅栏前边的操作后,才执行栅栏操作,最后再执行栅栏后边的操作。

特别注意:

在使用栅栏函数时,使用自定义队列才有意义。如果使用的是串行队列,那么栅栏函数的作用将无法体现,所有任务会按照追加顺序依次执行;如果使用的是全局并发队列,那么栅栏函数的作用将仅仅是一个 dispatch_syncdispatch_async函数的作用(取决于你使用的是 dispatch_barrier_sync 还是 dispatch_barrier_async)。

更多 dispatch_barrier 的用法请移步 GCD中dispatch_barrier的使用方法。


dispatch_group

在实际开发中,我们经常会遇到这样的问题:有几个不同的任务,当所有任务完成后需要根据这些任务的结果去执行下一步操作(例如:下载多张图片,全部下载后进行拼接并显示出来)。我们利用上边刚刚讲过的 dispatch_barrier 方法或许可以达到此类目的,但下边要讲的 dispatch_group 相关方法才更适合解决类似问题。

在编写测试代码之前,我们还要了解一下 dispatch_group_notify

dispatch_group_notify

dispatch_group_notify 可以监听 group 中所有任务的完成状态,当所有任务都执行完成后,会追加任务到 group 中,并执行该任务。下面我们通过具体示例来详细了解 dispatch_group 的使用方法:

NSLog(@"-- begin --");
    
// 创建一个分组
dispatch_group_t group = dispatch_group_create();
    
// 创建一个并发队列
dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_CONCURRENT);
    
// 向并发队列 queue 中追加任务并添加到分组 group 中
dispatch_group_async(group, queue, ^{
    [NSThread sleepForTimeInterval:1];
    NSLog(@"task1 -- %@", [NSThread currentThread]);
});
dispatch_group_async(group, queue, ^{
    [NSThread sleepForTimeInterval:1];
    NSLog(@"task2 -- %@", [NSThread currentThread]);
});
dispatch_group_async(group, queue, ^{
    [NSThread sleepForTimeInterval:1];
    NSLog(@"task3 -- %@", [NSThread currentThread]);
});
    
// 监听 group 中所有任务的完成状态
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    [NSThread sleepForTimeInterval:2];
    NSLog(@"all tasks are finished -- %@", [NSThread currentThread]);
});
    
NSLog(@"-- end --");

首先,我们创建了一个分组(dispatch_group_t 类型),该分组用来添加一系列的任务;

然后,我们创建了一个并发队列,该并发队列用于接收后续的异步任务(GCD 中的任务必须追加到具体的队列中才能执行);

接着,我们利用 dispatch_group_async 方法将任务追加到并发队列中,并同时添加到分组中,完成了多个任务的异步封装;

最后,我们利用监听函数 dispatch_group_notify 来监听分组中所有任务的完成状态,一旦所有任务执行结束,就会自动追加一个任务到分组中,并在指定的队列中执行任务。

对于上面的代码,这个自动追加到分组中的任务就是:

[NSThread sleepForTimeInterval:2];
NSLog(@"all tasks are finished -- %@", [NSThread currentThread]);

而这个指定的队列就是主队列(dispatch_get_main_queue() 方法获取的主队列)。

运行后查看打印信息:

2019-09-23 00:43:35.344343+0800 GCDSummary[41564:1184001] -- begin --
2019-09-23 00:43:35.344555+0800 GCDSummary[41564:1184001] -- end --
2019-09-23 00:43:36.345277+0800 GCDSummary[41564:1184044] task2 -- <NSThread: 0x6000033fcec0>{number = 3, name = (null)}
2019-09-23 00:43:36.345278+0800 GCDSummary[41564:1184043] task1 -- <NSThread: 0x6000033f7600>{number = 4, name = (null)}
2019-09-23 00:43:36.345282+0800 GCDSummary[41564:1184041] task3 -- <NSThread: 0x6000033fcf00>{number = 5, name = (null)}
2019-09-23 00:43:38.346901+0800 GCDSummary[41564:1184001] all tasks are finished -- <NSThread: 0x60000339e900>{number = 1, name = main}

可以看到,3 个任务都在已创建的子线程 queue 中执行,并且所有任务执行完毕后,才开始执行 dispatch_group_notify 中追加的任务,并且追加的任务是在指定的主线程中执行的。

对比 dispatch_barrier 方法,我们发现,dispatch_group 更适合用于多任务监听,dispatch_group 不但加入了分组的概念,而且在多任务完成后提供了更加灵活的方法方便用户指定一个队列执行要追加的任务。

在 group 的使用中,还可以利用 dispatch_group_waitdispatch_group_enterdispatch_group_leave 等函数来达到编程目的,更多 dispatch_group 的用法请移步 GCD中dispatch_group的使用方法


dispatch_apply

当我们需要进行循环遍历时,例如遍历一个数组,我们一般会使用 For-In 循环,For-In 循环会从数组第一个元素开始依次循环遍历到最后一个元素:

NSArray *arr = @[@"a", @"b", @"c", @"d", @"e"];
    
for (NSString *str in arr) {
    NSLog(@"str = %@", str);
}

// 打印:
/*
2019-09-23 21:23:21.387010+0800 GCDSummary[52959:1452344] str = a
2019-09-23 21:23:21.387119+0800 GCDSummary[52959:1452344] str = b
2019-09-23 21:23:21.387205+0800 GCDSummary[52959:1452344] str = c
2019-09-23 21:23:21.387287+0800 GCDSummary[52959:1452344] str = d
2019-09-23 21:23:21.387366+0800 GCDSummary[52959:1452344] str = e
*/

当 for 循环中的任务不多时,我们可以直接使用这种方法遍历某个容器,但是如果每一次循环都要执行一个耗时操作的话该怎么办呢,我们可能会立马想到下边的解决办法:

// 在 for 循环中每一次任务都放到子线程中执行
for (int i = 0; i < 1000; i++) {
    dispatch_async(dispatch_queue_create(nil, DISPATCH_QUEUE_CONCURRENT), ^{
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"%d -- %@", i, [NSThread currentThread]);
    });
}

这种办法可取吗?答案当然是不可取!如果每一次循环都要开启一个子线程,那线程的无限制开启将会耗费非常大的资源,效率低下,还可能造成应用假死甚至程序崩溃。

然而 GCD 给我们提供了一种快速迭代方法 dispatch_applydispatch_apply 函数是 dispatch_sync 函数和 Dispatch Group 的关联 API,该函数按照指定的次数将指定的任务追加到指定队列中,并等待全部任务执行结束,系统会根据实际情况自动分配和管理线程。

我们使用 dispatch_apply 方法模拟上面的 for 循环任务:

dispatch_async(dispatch_queue_create(nil, DISPATCH_QUEUE_CONCURRENT), ^{
    dispatch_apply(1000, dispatch_queue_create(nil, DISPATCH_QUEUE_CONCURRENT), ^(size_t index) {
        [NSThread sleepForTimeInterval:1]; // 模拟耗时操作
        NSLog(@"%zu -- %@", index, [NSThread currentThread]);
    });
});

两种方法运行后查看打印信息(可自行测试),可以看到,使用 dispatch_apply 方法循环打印还没有直接在 for 循环中不断开启线程速度快。但是我们应当注意,for 循环中不断开启线程造成了大量线程的开启(模拟器测试开启了70个左右的子线程),根本无法顾忌系统性能,而 dispatch_apply 方法则可以智能管理线程,始终是五六个子线程循环使用,这也是导致速度较慢的原因。实际开发中如果不考虑系统性能肆意开启子线程,可能会出现线程拥堵(假死)、程序崩溃的情况。

下面,我们对上面例子中的数组使用 dispatch_apply 方法进行迭代:

NSLog(@"-- begin --");
    
NSArray *arr = @[@"a", @"b", @"c", @"d", @"e"];
    
dispatch_queue_t queue = dispatch_queue_create("com.jarypan.gcdsummary", DISPATCH_QUEUE_CONCURRENT);

/*
arr.count    指定重复次数  这里数组内元素个数是 5 个,也就是重复 5 次
queue        追加任务的队列
index        带有参数的 Block, index 的作用是为了按执行的顺序区分各个 Block
*/
dispatch_apply(arr.count, queue, ^(size_t index) {
    NSLog(@"index = %zu, str = %@ -- %@", index, arr[index], [NSThread currentThread]);
});

NSLog(@"-- end --");

运行后查看打印信息:

2019-09-23 22:28:53.914873+0800 GCDSummary[53815:1472829] -- begin --
2019-09-23 22:28:53.915211+0800 GCDSummary[53815:1472880] index = 3, str = d -- <NSThread: 0x600002761780>{number = 5, name = (null)}
2019-09-23 22:28:53.915212+0800 GCDSummary[53815:1472882] index = 2, str = c -- <NSThread: 0x600002765240>{number = 4, name = (null)}
2019-09-23 22:28:53.915211+0800 GCDSummary[53815:1472883] index = 1, str = b -- <NSThread: 0x60000275fb80>{number = 3, name = (null)}
2019-09-23 22:28:53.915216+0800 GCDSummary[53815:1472829] index = 0, str = a -- <NSThread: 0x600002732900>{number = 1, name = main}
2019-09-23 22:28:53.915350+0800 GCDSummary[53815:1472880] index = 4, str = e -- <NSThread: 0x600002761780>{number = 5, name = (null)}
2019-09-23 22:28:53.915476+0800 GCDSummary[53815:1472829] -- end --

可以看到,dispatch_apply 方法中的任务是并发执行的,但是阻塞了当前线程(主线程),当所有任务执行完毕后才打印了“-- end --”。所以在我们使用 dispatch_apply 方法时,应当在外边套上一层 dispatch_async

另外,当 index 等于 0 的时候(第一个任务执行时),任务一定是在当前线程(该例中的当前线程是主线程)中执行,这一点可以理解为除第一个任务的其他任务都是依照和第一个任务并发的目的放进了并发队列里。

注意:dispatch_apply 方法非常适合大量字典数据转模型。

更多 dispatch_semaphore 的用法请移步 GCD中dispatch_apply函数的使用方法


dispatch_semaphore

Dispatch Semaphore(信号量) 是持有计数的信号,该信号是多线程编程中的计数类型信号。信号类似于高速收费站的栏杆,可以通过时抬起栏杆,不可以通过时放下栏杆。在 Dispatch Semaphore 中使用了计数来实现该功能:计数小于 0 时等待,阻塞当前线程。计数为 0 或大于 0 时,唤醒线程,继续执行线程中的代码。

Dispatch Semaphore 提供了三种方法来改变信号量的值:

  • dispatch_semaphore_create
  • dispatch_semaphore_wait
  • dispatch_semaphore_signal

dispatch_semaphore_t dispatch_semaphore_create(long value);

dispatch_semaphore_create 函数可以创建新的用于计数的信号量,参数即为初始化的信号的值,如果传入的值小于 0 的值,将会导致返回值为 NULL。

long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);

dispatch_semaphore_wait 函数会使传入的信号量(第一个参数)的值减 1,如果结果值小于 0,则此函数在返回(return)前将一直等待直到信号出现;

第二个参数是指定多长时间超时,系统提供了 2 种时间定义方便我们使用:DISPATCH_TIME_NOWDISPATCH_TIME_FOREVER

返回值为 0 代表函数执行成功,为非 0 时代表函数执行超时。

例如:当前信号量的值是 0,然后调用 dispatch_semaphore_wait 函数并将这个信号量作为参数传入,那么信号量的值就变成了 -1,也就是小于 0 了,此时 dispatch_semaphore_wait 函数将无法返回(return),会一直等待,直到出现信号或者到达超时时间来解除阻塞,然后才能返回 long 类型的值。利用这一特点可以人为阻塞线程。

long dispatch_semaphore_signal(dispatch_semaphore_t dsema);

dispatch_semaphore_signal 函数会使传入的信号量的值加 1,如果信号量的前一个值小于 0,则此函数在返回(return)前会唤醒一个等待的线程。所以 dispatch_semaphore_signal 函数一般会和 dispatch_semaphore_wait 函数配合使用,先利用 dispatch_semaphore_wait 函数阻塞线程,再利用 dispatch_semaphore_signal 函数唤醒这个线程继续执行下去。

如果线程被唤醒,该函数将返回非 0 值。否则,返回 0。

Dispatch Semaphore 在实际开发中主要用于:

保持线程同步,将异步执行任务转换为同步执行任务;
在需要频繁开启子线程时限制子线程的数量;
保证线程安全,为线程加锁等。

下面,我们通过一个例子来熟悉一下这三个函数的使用方法:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSLog(@"-- begin -- %@", [NSThread currentThread]);
        
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        
    __block int number = 0;
        
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        [NSThread sleepForTimeInterval:2]; // 模拟耗时操作
        NSLog(@"task -- %@", [NSThread currentThread]);
            
        number = 10;
            
        long count =  dispatch_semaphore_signal(semaphore);
        NSLog(@"return value of dispatch_semaphore_signal: %ld", count);
    });
        
    long count = dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"return value of dispatch_semaphore_wait: %ld", count);
        
    NSLog(@"-- end -- number is now %d", number);
});

我们来逐句分析上面例子中各行代码的作用:

1、使用 dispatch_async 函数创建一个全局并发队列异步执行任务;
2、打印当前线程(子线程);
3、创建一个信号量,初始值设为 0;
4、创建一个 __block 修饰的局部变量并设置其初始值为 0;
5、使用 dispatch_async 函数创建一个全局并发队列异步执行任务:

线程休眠 2 秒
打印当前线程
修改 number 的值
使用 dispatch_semaphore_signal 函数增加信号量的值
打印 dispatch_semaphore_signal 函数的返回值

6、使用 dispatch_semaphore_wait 函数减少信号量的值;
7、打印 dispatch_semaphore_wait 函数的返回值;
8、打印 number 的值。

如果你已经理解了前面所讲的关于信号量的三个函数的作用,那么我们就可以先来分析一下这段代码会有怎样的表现:

首先,NSLog(@"-- begin -- %@", [NSThread currentThread]); 会打印当前线程的信息,然后 dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); 创建了一个信号量,初始值为 0,我们创建了一个变量 number;

代码继续往下走,我们开启了异步线程任务,但不会影响当前线程的执行,继续向下, long count = dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); 会使信号量减 1,变为了 -1,此时 dispatch_semaphore_wait 函数无法返回, 当前线程执行到这里形成了等待;

而前边开启的子线程任务还是可以执行的:线程休眠了 2 秒,打印当前线程的信息,修改 number 的值为 10,long count = dispatch_semaphore_signal(semaphore); 的执行会使信号量加 1,因为之前信号量已经变为了 -1,此时加1 就会成功唤醒等待中的线程,所以返回值为1(返回值为非 0 代表成功唤醒了等待中的线程),下边打印会是 ”return value of dispatch_semaphore_signal: 1“;

等待的线程被唤醒,代码继续执行,此时 dispatch_semaphore_wait 函数可以返回了,并且返回值是 0(返回值为 0 代表函数执行成功),因为线程唤醒后函数成功执行而不是到了超时时间,接着会打印 ”return value of dispatch_semaphore_wait: 0“,再接着打印 ”-- end – number is now 10“,因为此时 number 的值已经在前边的线程中进行了修改。

下面我们看下运行后的打印结果:

2019-09-27 01:37:58.713507+0800 GCDSummary[1624:44221] -- begin -- <NSThread: 0x6000003d4bc0>{number = 3, name = (null)}
2019-09-27 01:38:00.714420+0800 GCDSummary[1624:44223] task -- <NSThread: 0x6000003ee680>{number = 4, name = (null)}
2019-09-27 01:38:00.714932+0800 GCDSummary[1624:44223] return value of dispatch_semaphore_signal: 1
2019-09-27 01:38:00.714939+0800 GCDSummary[1624:44221] return value of dispatch_semaphore_wait: 0
2019-09-27 01:38:00.715404+0800 GCDSummary[1624:44221] -- end -- number is now 10

可以看到,打印结果和我们预期的一模一样。

更多 dispatch_semaphore 的用法请移步 GCD中信号量(dispatch_semaphore)的使用方法。


多线程中需要注意的一些问题

• Critical Section(临界代码段)

指的是不能同时被两个线程访问的代码段。例如一个变量,被并发进程访问后可能会改变其变量值,造成数据污染(数据共享问题)。

• Race Condition (竞态条件)

当多个线程同时访问共享数据时,会发生争用情形,第一个线程读取并改变了一个变量的值,第二个线程也读取并改变了这个变量的值,两个线程同时操作了该变量,此时他们会发生竞争来看哪个线程会最后写入这个变量的值,最后被写入的值将会被保留下来。

• Deadlock (死锁)

两个线程都要等待对方完成任务后才能执行自身的任务时,就会导致死锁现象。

• Thread Safe(线程安全)

一段线程安全的代码(对象),可以同时被多个线程或并发的任务调度,不会产生问题,非线程安全的代码只能按次序被访问。

所有 Mutable 对象都是非线程安全的,所有 Immutable 对象都是线程安全的,使用 Mutable 对象时,一定要用同步锁来同步访问(@synchronized)。

• 互斥锁

能够防止多线程抢夺造成的数据安全问题,但是需要消耗大量的资源。

atomic:原子属性,为 setter 方法加锁,将属性以 atomic 的形式来声明,该属性变量就能支持互斥锁了。

nonatomic:非原子属性,不会为 setter 方法加锁,声明为该属性的变量,客户端应尽量避免多线程争夺同一资源。

• Context Switch (上下文切换)

当一个进程中有多个线程来回切换时,context switch 用来记录执行状态,这样的进程和一般的多线程进程没有太大差别,但会产生一些额外的开销。

关于线程安全的问题,我会在另一篇文章中进行总结。


参考文章:
iOS中GCD的使用小结
iOS 多线程:『GCD』详尽总结
iOS进阶之多线程–NSThread详解
dispatch_barrier_sync 和dispatch_barrier_async的区别

你可能感兴趣的:(iOS深入学习)