在现代的计算机系统中CPU是多核多线程的架构,理论上我们可以在同一时间同时执行多条指令。而在软件设计中为了达到这个目的就发明了许多的并发编程方案。
其中在iOS中主要存在四个并发编程方案:pthread、NSThread、GCD、NSOperation。这里借用以下这张图来描述它们各自的主要特征。
可以看到pthread和NSThread实际上就是线程这个概念在iOS中的抽象,使用这两个库需要开发者自己去管理线程的声明周期(创建,调度和销毁),一般运用在一些简单场景下。而GCD和NSOperation则是更高级的抽象,在iOS的日常开发中经常被用到。
并发编程可以让我们更高效率的利用多核多芯片的计算性能,但是也带来了资源竞争的问题。在深入学习GCD之前我们先来讨论一下在iOS系统中这个问题的表现。
并发时的资源竞争
我们知道线程在并发调用的时候是无法预估它们之间执行时序先后顺序的,当存在多个线程同时操作同一个资源的时候,这个资源的读写操作就变得不可预估了。
如并发编程:API 及挑战里面的例子,当存在一个整型数值17分别被两条并发线程访问,当没有对这个资源进行保护的时候,这个计数器的数值与预期不一致(计数器实际上被+2但结果仍然是18)。这个问题被称为竞态条件,要解决这个问题需要保证多线程的执行顺序,也就是使用同步工具来保证并发线程的执行顺序。
ps: 通过一些手段保证使用多线程访问时不会触发资源竞争的变量或方法被称作线程安全的变量或方法。关于iOS系统中线程安全的框架/对象可以看这篇文章
根据官方文档——Synchronization的指导,解决资源竞争的问题主要有以下几种同步手段:
原子操作(Atomic Operations):一种简单的同步形式,适用于简单的数据类型。 原子操作的优点是它们不会阻塞竞争线程。 对于简单的操作(例如增加计数器变量),这比使用锁可以带来更好的性能。
内存壁垒和易失性变量(Memory Barriers and Volatile Variables):内存屏障是一种非阻塞同步工具,用于确保内存操作以正确的顺序发生。内存屏障的作用类似于围栏,迫使处理器在允许执行位于屏障之后的加载和存储操作之前,完成位于屏障前面的所有加载和存储操作。易失性变量将另一种类型的内存约束应用于单个变量。由于内存屏障和易失性变量都会减少编译器可执行的优化次数,因此应谨慎使用它们,并且仅在需要确保正确性的地方使用它们。
锁(Locks):锁是最常用的同步工具之一,可以使用锁来保护代码的关键部分,锁一次只能允许一个线程访问。例如,关键代码可能操纵特定的数据结构或一次使用最多支持一个客户端的某些资源。通过在此部分周围加锁,可以排除其他线程进行可能影响代码正确性的更改。
条件(Conditions):条件是一种信号量,同时也可以看做一种特殊的锁。当某个条件为真时,它允许线程彼此发信号。条件通常用于指示资源的可用性或确保任务以特定顺序执行。当线程测试条件时,除非该条件已经为真,否则它将阻塞。它保持阻塞状态,直到其他线程显式更改并发出条件信号为止。条件和互斥锁之间的区别在于,可以允许多个线程同时访问该条件。条件更多的表现得像一个看门人,它根据某些指定的标准让不同的线程通过这个门。
执行选择器事务(Perform Selector Routines):Cocoa框架中可以使用
performSelector:onThread:withObject:waitUntilDone:
相关的方法来在主线程或者其他线程的Runloop中顺序执行代码。
可以看到上面的手段的设计目的都是方便我们使用它们来实现同步线程操作的目的,同样的我们也可以使用信号量来实现。这些同步工具中我们最为常用的应该就是各种锁(locks)了,接下来再继续学习一下关于锁的一些问题。
锁住资源
在iOS系统中锁的类型有以下几种:
锁的类型 | 描述 |
---|---|
Mutex(互斥锁) | 互斥锁是一种信号量,它一次只能授予对一个线程的访问权限。如果正在使用互斥锁,而另一个线程试图获取该互斥锁,则该线程将阻塞,直到该互斥锁被其原始持有者释放为止。如果多个线程竞争同一个互斥锁,则一次只能访问一个。 |
Recursive lock(递归锁) | 递归锁是互斥锁的一种变体。递归锁允许单个线程在释放它之前多次获取该锁(解决了互斥锁被自己多次访问导致的死锁问题)。其他线程将保持阻塞状态,直到锁的所有者以与获取锁相同的次数释放锁。递归锁主要在递归迭代期间使用,但也可以在多个方法各自需要分别获取锁的情况下使用。 |
Read-write Lock(读写锁) | 读写锁也称为共享独占锁。这种类型的锁通常用于较大规模的操作,如果经常读取受保护的数据结构并仅偶尔进行修改,则可以显着提高性能。在正常操作期间,多个读取器可以同时访问数据结构。但是,当线程要写入结构时,它将阻塞,直到所有读取器都释放锁为止,此时,它获取了锁并可以更新结构。当写入线程正在等待锁定时,新的读取器线程将阻塞,直到写入线程完成。iOS系统仅支持使用POSIX线程的读写锁。有关如何使用这些锁的更多信息,请参见pthread手册页。 |
Distributed Lock(分布式锁) | 分布式锁在进程级别提供互斥访问。 与真正的互斥锁不同,分布式锁不会阻止进程或阻止其运行。 它仅报告锁何时繁忙,并让进程决定如何进行。 |
Spin Lock(自旋锁) | 自旋锁反复轮询其锁定条件,直到该条件变为true。自旋锁最常用于多处理器系统,其中锁的预期等待时间很小。在这些情况下,轮询通常比阻塞线程更有效,这需要上下文切换和线程数据结构的更新。由于它们具有轮询性质,因此系统不提供自旋锁的任何实现,但是您可以在特定情况下轻松地实现它们。有关在内核中实现自旋锁的信息,请参见《内核编程指南》。 |
Double-checked Lock(双重检查锁) | 双重检查锁是通过在获取锁之前测试锁定条件来减少获取锁的开销的尝试。 由于双重检查的锁可能不安全,因此系统不会为它们提供明确的支持,因此不建议使用它们。 |
对于应用开发而言最最最常接触到就是互斥锁、递归锁这两种锁了,它们在API层面上又被封装为NSLock、NSRecursiveLock或者@synchronized语法了。
这些锁通过保证线程同步访问的方式保护了资源从而解决了资源竞争的问题,但是它并不能完美解决并发编程中的所有问题,可能还会引发几个问题:
- 死锁(经典场景:使用互斥锁的线程加锁后又再次访问这个锁)
- 资源饥饿
- 线程优先级反转
限于篇幅这里就不继续展开了可在并发编程:API 及挑战这篇文档中找到相关的例子,接下来我们从官方文档——Dispatch来好好学习一下GCD相关的话题。
GCD中任务与调度队列
在文档中对于GCD(libdispatch)设计目的定义是提供系统级别的高效性能调度,通过提交工作以分派系统管理的队列,在多核硬件上同时执行代码。其中GCD通过封装调度队列和任务来隐藏更细节的线程管理、调度。
对于GCD这个框架我们最最最常用的就是下面这两个函数了:1
2dispatch_sync(dispatch_queue_t _Nonnull queue, ^(void)block)
dispatch_async(dispatch_queue_t _Nonnull queue, ^(void)block)
这两个函数的接口含义:某个任务在某个调度队列上同步或者异步执行,而它们最终会在下面这一套流程中的MainThread
或者GCD Thread Pool
中被执行调度。
任务(即代码块)可以通过GCD在串行队列或者并行队列上同步或者异步执行调度。
串行、并行指的是同一个队列间任务与任务之间的关系,如果任务被加入串行队列中则任务与任务之间是串行的关系,意味着必须等上一个任务出队后才能进行调度(main_queue就是一个全局的串行队列),如果任务被加入并行队列中则任务与任务之间是并发的关系,任务可以即刻开始调度执行。
同步、异步指的是任务调度的方式,如果在当前上下文同步调度一个任务则意味必须等到这个任务执行完毕后当前上下文才能继续执行,反之如果在当前上下文异步调度一个任务则无需等到这个任务执行完毕就会继续执行当前上下文。
通过上述四种基本关系组合使用GCD框架的时候最可能遇到的问题是由于不正确的使用方式导致的逻辑死锁(同步调度的时候,比如在A队列又同步调度一个任务到A队列中)。
任务与调度队列的执行方式
在GCD中通过封装一个代码块(Block)来声明一个任务,并且可以控制任务的执行优先级(QOS)、执行时机与执行后回调动作,这些任务一般会放到某个调度队列(FIFO)中进行调度执行。
在GCD中对于任务和队列的控制粒度可以很细,大多数情况下我们可以这么使用GCD来进行并发编程:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"在主线程串行队列同步调度一个任务");
});
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"在主线程串行队列异步调度一个任务");
});
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"在全局并行队列同步调度一个任务");
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"在全局并行队列异步调度一个任务");
});
以上四种执行方式满足我们开发过程中大部分的并发需求,以下还有几种更灵活的任务执行方式。
直接执行任务
在GCD中任务单元是调度执行的最小单元,任务单元封装了要在调度队列或调度组内执行的工作。同时还可以将任务单元用作调度源事件,注册或取消处理程序。1
2
3
4
5
6
7
8
9
10
11
12// 声明一个任务单元
dispatch_block_t block = dispatch_block_create(0, ^{
NSLog(@"这是一个任务");
});
// 取消一个任务,则任务失效,执行时将不会生效
if (dispatch_testcancel(block) == 0) {
dispatch_cancel(block);
};
// 在当前上下文同步执行一个任务
dispatch_block_perform(0, block);
任务单元是工作的执行实体,而调度队列、调度组是工作的执行上下文环境,在GCD中可以选择将任务单元放到特定的调度队列、调度组中进行调度,也可以选择在当前上下文直接执行一个任务单元。
延后执行
GCD中提供一种延后特定时间执行任务的方式。1
2
3
4
5double delayInSeconds = 2.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t) (delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
NSLog(@"延后2秒执行这个任务单元");
});
单次执行任务
在GCD中提供一种调度方式可以让某个任务在程序运行期间有且只执行一次,通常用于实现单例模式。1
2
3
4
5
6
7
8+ (instancetype)shared {
static id shared;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shared = [[self alloc] init];
});
return shared;
}
但是实际上如果深入使用dispatch_once
会发现它实际上并非绝对安全的,在某些场景中是有概率出现程序崩溃的。这要深究到它的实现方式上了,内部实现上有一个CPU分支预测和预执行的机制能够优化dispatch_once
的执行效率,但是在这段代码初始过程中如果同时有多个线程在执行它就可能出现初始化预判断错误导致的线程死锁问题。
执行一组任务
通过任务组可以聚合一组任务并同步组上的行为。我们可以将多个任务单元附加到一个组,并计划它们在同一队列或不同队列上异步执行。当所有块完成执行后,任务组将执行其完成处理程序。当然也可以同步等待组中的所有块执行完毕。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 创建任务组
dispatch_group_t group = dispatch_group_create();
// 进入任务组
dispatch_group_enter(group);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// 离开任务组
dispatch_group_leave(group);
});
// 进入任务组
dispatch_group_enter(group);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// 离开任务组
dispatch_group_leave(group);
});
// 任务组中的任务全部退出后的结束动作
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
});
获取/设置队列上下文数据
在GCD中可以给队列附加一个上下文指针来提供一些额外的信息。当我们在使用GCD同步执行任务的时候有可能会存在死锁现象,对于主线程我们可以使用[NSThread isMainThread]
来判断,那么对于其他线程我们可以间接标记出它所属调度队列然后安全地避开死锁问题。1
2
3
4
5
6
7
8// 定义一个静态常量指针并用它作为这个串行队列的标记
static const void * kQueueSpecificKey = &kQueueSpecificKey;
dispatch_queue_t serialFetchQueue = dispatch_queue_create("com.test", DISPATCH_QUEUE_SERIAL);// 串行队列
dispatch_queue_set_specific(serialFetchQueue, kQueueSpecificKey, (__bridge void * _Nullable)(self), NULL);
// 安全地实现同步调用
dispatch_get_specific(kQueueSpecificKey) != NULL ? block() : dispatch_sync(serialFetchQueue, block);
// 意思是假如现在已经是当前代码上下文已经在这个调度队列中了就直接执行任务
在GCD在执行任务的时候,可以通过下面这段测试代码发现上诉死锁的缘故:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21dispatch_queue_t serialQueue = dispatch_queue_create("com.serial.test", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t conQueue = dispatch_queue_create("com.con.test", DISPATCH_QUEUE_CONCURRENT);
dispatch_sync(serialQueue, ^{
NSLog(@"在com.serial.test串行队列同步执行:%@", [NSThread currentThread]);
});
dispatch_async(serialQueue, ^{
NSLog(@"在com.serial.test串行队列异步执行:%@", [NSThread currentThread]);
});
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"在主串行队列异步执行:%@", [NSThread currentThread]);
});
dispatch_sync(conQueue, ^{
NSLog(@"在com.con.test同步执行:%@", [NSThread currentThread]);
});
dispatch_async(conQueue, ^{
NSLog(@"在com.con.test异步执行:%@", [NSThread currentThread]);
});
得出结论:
调度任务到队列中 | 调度方式 | 这个任务执行时的线程 |
---|---|---|
在主队列上加入一个任务到A串行队列 | 同步 | 在主线程执行,不会切换线程 |
在主队列上加入一个任务到A串行队列 | 异步 | 切换到新线程中执行 |
在主队列上加入一个任务到主队列 | 异步 | 在主线程执行,不会切换线程 |
在主队列上加入一个任务到B并行队列 | 同步 | 在主线程执行,不会切换线程 |
在主队列上加入一个任务到B并行队列 | 异步 | 切换到新线程中执行 |
可以看到加入我们以同步方式调度一个队列任务的时候均是在当前上下文中执行这个任务的而不会切换线程,当且仅当通过异步方式调度一个任务到新的队列的时候才会进行线程切换。
服务质量(QOS)
在GCD中的调度队列中的任务单元是存在执行优先级的(也就是任务的服务质量)。QOS将会对调度队列上执行的工作进行了分类。通过指定任务的质量表明任务对应用程序的重要性。在安排任务时,系统会优先处理服务级别较高的任务。
由于高优先级的任务单元比低优先级的任务单元执行得更快、资源更多,因此与低优先级的任务单元相比,通常需要更多的精力(执行时间分片多)。为应用执行的任务单元准确地指定适当的QoS可确保应用具有更好响应能力和资源利用效率。
执行优先级同样可以从两个维度上指定:任务单元的优先级和队列的优先级。1
2
3
4
5
6
7
8
9
10
11
12
13// 队列优先级
#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
// 任务单元的优先级
QOS_CLASS_USER_INTERACTIVE:表示任务需要被立即执行,用来在响应事件之后更新UI,来提供好的用户体验。
QOS_CLASS_USER_INITIATED:表示任务由UI发起异步执行。适用场景是需要及时结果同时又可以继续交互的时候。
QOS_CLASS_DEFAULT:表示默认优先级
QOS_CLASS_UTILITY:表示需要长时间运行的任务,伴有用户可见进度指示器。经常会用来做计算,I/O,网络,持续的数据填充等任务。
QOS_CLASS_BACKGROUND:表示用户不会察觉的任务,使用它来处理预加载,或者不需要用户交互和对时间不敏感的任务。
QOS_CLASS_UNSPECIFIED:表示未指明,系统根据情况进行选定QOS等级
两个维度的优先级关系基本对应如下:
队列优先级 | 任务单元优先级 |
---|---|
Main Thread | QOS_CLASS_USER_INTERACTIVE |
DISPATCH_QUEUE_PRIORITY_HIGH | QOS_CLASS_USER_INITIATED |
DISPATCH_QUEUE_PRIORITY_DEFAULT | QOS_CLASS_DEFAULT |
DISPATCH_QUEUE_PRIORITY_LOW | QOS_CLASS_UTILITY |
DISPATCH_QUEUE_PRIORITY_BACKGROUND | QOS_CLASS_BACKGROUND |
那么问题来了,假设一个任务单元的执行优先级和队列优先级不一致系统会采用哪个优先级呢?通过下面这段测试代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
sleep(2);
NSLog(@"这是一个低队列优先级的任务");
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(2);
NSLog(@"这是一个默认队列优先级的任务");
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
sleep(2);
NSLog(@"这是一个高队列优先级的任务");
});
dispatch_block_t highBlock = dispatch_block_create_with_qos_class(0, QOS_CLASS_USER_INTERACTIVE, 0, ^{
sleep(2);
NSLog(@"这是一个默认队列优先级的高优先级任务");
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), highBlock);
运行结果可能如下(还有其他结果):
可以知道系统最终会以两者QOS属性中高的那个值作为任务单元执行时的优先级。同时任务单元并不是严格按照优先级的顺序执行的。也就是说GCD中的QOS优先级应该是作为系统线程调度的一个参考量,在CPU时间片轮转调度时参考优先级顺序先进行调度执行,在同一时刻的任务会按照高->中->低的优先级顺序去分配资源。
任务同步
我们知道并发任务的执行时机是不可预测的,但在GCD中可以通过信号量或者屏障实现对并发任务的执行顺序的控制。
Dispatch Semaphore
调度信号量是传统计数信号量的实现。仅当需要阻塞调用线程时,调度信号才调用内核。如果调用信号量不需要阻塞,则不进行内核调用。1
2
3
4
5
6
7
8
9dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(1.0);
NSLog(@"全局队列睡眠1秒后释放一个信号量");
dispatch_semaphore_signal(semaphore);
});
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"主队列等到信号量释放后才执行");
Dispatch Barrier
屏障是一种并发队列任务执行顺序的控制手段。在向并发调度队列添加屏障时,该队列会延迟屏障块(以及屏障之后提交的所有任务)的执行,直到所有先前提交的任务完成执行为止。在完成先前的任务后,队列将自己执行屏障块。屏障块完成后,队列将恢复其正常执行行为。这是一种针对用户自定义并发队列的同步手段,对串行队列和全局队列是无效的。
1 | dispatch_queue_t conQueue = dispatch_queue_create("com.con.test", DISPATCH_QUEUE_CONCURRENT); |
系统事件监控
GCD除了上述的并发编程的能力,它还拥有系统事件监控的能力。具体的它可以接收以下几种事件输入源对象:
- Dispatch Source:协调特定低级系统事件(例如文件系统事件,计时器和UNIX信号)的处理的对象。
- Dispatch I/O:使用基于流或随机访问的语义管理文件描述符上的操作的对象。
- Dispatch Data:一个用于管理基于内存的数据缓冲区,并将其公开为连续的内存块对象。
Dispatch Source
Dispatch Source用于监听系统的底层对象。比如文件描述符、Mach端口、信号量等。主要处理的事件如下表:
触发源 | 代表触发动作 |
---|---|
DISPATCH_SOURCE_TYPE_DATA_ADD | 数据增加 |
DISPATCH_SOURCE_TYPE_DATA_OR | 数据OR |
DISPATCH_SOURCE_TYPE_MACH_SEND | Mach端口发送 |
DISPATCH_SOURCE_TYPE_MACH_RECV | Mach端口接收 |
DISPATCH_SOURCE_TYPE_MEMORYPRESSURE | 内存压力 |
DISPATCH_SOURCE_TYPE_PROC | 进程事件 |
DISPATCH_SOURCE_TYPE_SIGNAL | 信号 |
DISPATCH_SOURCE_TYPE_VNODE | 文件系统对象更改 |
DISPATCH_SOURCE_TYPE_WRITE | 文件系统对象写 |
DISPATCH_SOURCE_TYPE_READ | 文件系统对象读 |
DISPATCH_SOURCE_TYPE_TIMER | 定时器 |
其中我们主要通过这几个方法来实现事件监听:
- dispatch_source_create:创建dispatch source,创建后会处于挂起状态进行事件接收,需要设置事件处理handler进行事件处理。
- dispatch_source_set_event_handler:设置事件处理handler
- dispatch_source_set_cancel_handler:事件取消handler,就是在dispatch source释放前做些清理的事。
- dispatch_resume:唤醒事件监听。
- dispatch_source_cancel:关闭dispatch source,设置的事件处理handler不会被执行,已经执行的事件handler不会取消。
应用——基于GCD的定时器
我们知道NSTimer是通过Runloop实现的,而之前学习到由于Runloop中会受到其他代码执行效率以及模式切换的影响,所以NSTimer是不准确的。在GCD中可以监听系统级的定时器触发源让我们实现精准地定时事件触发。1
2
3
4
5
6
7
8
9dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_DEFAULT, 0);
self.backgroundTaskRecordQueue = dispatch_queue_create("com.test.timer", attr);
self.backgroundTaskRecordTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.backgroundTaskRecordQueue);
dispatch_source_set_timer(self.backgroundTaskRecordTimer, DISPATCH_TIME_NOW, 3.0f * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(self.backgroundTaskRecordTimer, ^{
NSLog(@"Time Triggle")
});
dispatch_resume(self.backgroundTaskRecordTimer);
Dispatch I/O 与 Dispatch Data
此外对于GCD提供的输入输出控制可参考底层并发 API这篇文章里的描述,我们可以通过这项能力提高IO的使用效率。
最后
一套学习下来,再次总结GCD这个框架是苹果开发的高效利用多核性能的并发编程库。它极力地向开发者隐藏线程管理的实现细节(在底层自己维护线程池),通过抽象调度队列和任务这两个概念来方便开发者进行并发编程,同时这套框架还提供系统事件监听机制的能力。
参考
wwdc2015-718
Threading Programming Guide
Concurrency Programming Guide
并发编程:API 及挑战
底层并发 API
GCD整理
GCD源码
GCD学习参考