多线程技术综述(二):iOS/MacOS中的多线程技术
1. 目录
- iOS/MacOS中的多线程技术
- POSIX线程
- NSThread
- GCD
- 任务与分发队列的概念
- 分发队列的类型
- 任务的执行方式
- GCD实验
- 其他常用的GCD方法
- 小结
- NSOperaion、NSOperationQueue
- 参考资料
2. iOS/MacOS中的多线程技术介绍
2.1 POSIX线程
pthread(POSIX Threads),是用C语言编写的一套多线程接口,可在Unix/Linux/Windows等系统跨平台使用。在实际开发中,直接使用这套接口的场景较少,主要原因如下。首先,纯C语言的接口与Objective-C和Swift交互还需考虑数据适配问题;其次,这套接口需要手动管理线程的生命周期,较为麻烦。
2.2 NSThread
NSThread是苹果公司采用Objective-C对phread的一个封装,使用难度相对pthread更为简单,但还是需要程序手动管理多线程的生命周期。事实上,手动管理线程是非常繁琐的事情,很容易出错。例如,不知不觉创建了大量的线程,导致大量的计算资源被浪费。因此,在实际开发中也较少直接使用。
2.3 GCD
引用自维基百科的介绍[10]
Grand Central Dispatch (GCD or libdispatch), is a technology developed by Apple Inc. to optimize application support for systems with multi-core processors and other symmetric multiprocessing systems. It is an implementation of task parallelism based on the thread pool pattern. The fundamental idea is to move the management of the thread pool out of the hands of the developer, and closer to the operating system. The developer injects "work packages" into the pool oblivious of the pool's architecture. This model improves simplicity, portability and performance.
根据上述介绍,它解决了前文中pthread和NSThread需要手动管理线程的问题。GCD将繁琐的线程的管理工作交给操作系统,以提升使用的简单性和程序的运行性能。
文章[8]概括了使用GCD的四种好处,分别为:
- GCD 可用于多核的并行运算;
- GCD 会自动利用更多的CPU内核(比如双核、四核);
- GCD 会自动管理线程的生命周期(创建线程、调度任务、销毁线程);
- 程序员只需要告诉GCD想要执行什么任务,不需要编写任何线程管理代码。
3. GCD的详细介绍
3.1 任务与分发队列的概念
接下来,需要引入GCD中两个重要的基本概念,即任务和分发队列。任务可以简单理解为一个待运行的代码块,例如从网络上下载一张图的代码。当任务的数量多了,就需要考虑它们的执行顺序,分发队列正是管理这些任务的容器。了解这两个概念后,开发者只需创建好任务和队列即可,其他如开辟多少个线程、每个任务应该在哪个线程中运行等细节,由GCD自动完成。
3.2 分发队列的类型
分发队列(Dispatch queues)可分为「串行」和「并发」两种。我们期望「串行分发队列」中的任务一个接一个执行,而「并行分发队列」中的任务尽可能同时执行。
GCD提供了两种默认的串行和并发队列,可直接获取,Objective-C的Swift的写法分别为:
1 | /* Objective-C 写法 */ |
1 | /* Swift 写法 */ |
除了系统默认提供的队列,还可以自己创建队列,写法如下:
1
2
3
4/* Objective-C 中的定义 */
dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr);
其中,label
为线程的名字,主要是为了调试时方便观察。attr
用于决定创建串行还是并行队列,参数分别为:
- DISPATCH_QUEUE_SERIAL
- DISPATCH_QUEUE_CONCURRENT
在Swift中的创建队列的写法如下:
1 | /* Swift的写法 */ |
在Swift中调用GCD,需要注意的是qos
参数。其中qos
在Apple官方文档中的定义如下:
Use quality-of-service classes to communicate the intent behind the work that your app performs. The system uses those intentions to determine the best way to execute your tasks given the available resources. For example, the system gives higher priority to threads that contain user-interactive tasks to ensure that those tasks are executed quickly. Conversely, it gives lower priority to background tasks, and may attempt to save power by executing them on more power-efficient CPU cores. The system determines how to execute your tasks dynamically based on system conditions and the tasks you schedule.
比较有趣的是,系统会根据任务的qos等级来动态调度系统资源。例如,会给予qos等级为user-interactive(用户交互)的任务更高的优先级,尽可能快的执行完。相反地,给予qos为background的的任务较低的优先级,把它放到更省能源的CPU核心当中执行。更多qos等级的介绍可以查看官方文档[13]
3.3 任务的执行方式
任务的执行方式可分为「同步」和「异步」两种,同步方式会阻塞线程,等待任务代码执行完毕后执行后续代码,而异步方式不会阻塞线程。需要注意的是,异步方式具有开启新线程的能力,而同步方式没有,这个会在后面的表格中体现。
在Objective-C中使用C语言函数执行同步或异步任务,其函数定义分别为:
1
2
3
4
5// 同步执行
dispatch_sync(dispatch_queue_t queue, dispatch_block_t block);
// 异步执行
dispatch_async(dispatch_queue_t queue, dispatch_block_t block);DispatchQueue
类,下面是同步和异步的调用方法:
1 | // 同步执行 |
在主线程下,按照任务的执行方式和分发队列的组合,得到以下的表格:
并发队列 | 手动创建的串行队列 | 主队列(串行) | |
---|---|---|---|
同步(sync) | 没有开启新线程,在当前线程串行执行任务 | 没有开启新线程,在当前线程串行执行任务 | 死锁UI卡住 |
异步(async) | 有开启新线程的能力,并发执行任务 | 有开启新线程的能力,串行执行任务 | 没有开启新线程,串行执行任务。主队列与主线程绑定。 |
3.4 GCD实验
问题1:并发队列中既有同步任务,又有异步任务,会如何执行?
1 | // 问题1对应的代码 |
本机的运行结果为:
可以看出,对于并发队列中的所有任务,GCD把同步任务分配到了主线程,并保持串行执行;把异步任务分配到了不同的线程并发执行,所以执行顺序不固定。最后,同步任务和异步任务出现交叉执行的原因是,系统不断地在主线程和其他线程间进行切换。
问题2:GCD在什么情况下会发生死锁现象?
实际上,我认为这个地方GCD的设计有一些绕,因此只需记住一些结论就好。注:
接下去用「执行方」代表调用DispatchQueue.global().sync {}
这类GCD操作的地方。
1.异步(async),与串行/并行队列组合,不会发生死锁。这是因为,异步任务不会阻塞当前线程,执行方永远不需要等这个异步任务结束,异步任务只有在与执行方同一个串行队列时,才需要等待执行方的任务结束,因此尚未构成互死锁中的相互等待。
2.同步(sync),与并行队列组合不会发生死锁。因为执行方虽然可能需要等待同步任务执行结束,但是同步任务处在并行队列中并不需要等待执行方的任务结束,因此也尚未构成死锁中的相互等待。
3.同步(sync),与串行可能会发生死锁,下面是三个例子的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26override func viewDidLoad() {
super.viewDidLoad()
// 例子1: 死锁❌
// 执行方 和 同步任务 是同个串行队列(主队列)
DispatchQueue.main.sync {
print("ok")
}
// 例子2: 正常运行
// 执行方和同步任务不是同一个串行队列
// 执行方(主队列) 和 同步任务(自建串行队列)
let serialQ = DispatchQueue(label: "com.test.serial")
serialQ.sync {
print("ok")
}
// 例子3: 死锁❌
serialQ.async {
// 新线程
// 执行方 和 同步任务 是同个串行队列(com.test.serial)
serialQ.sync {
print("ok")
}
}
}
3.5 其他常用的GCD方法
DispatchGroup
Groups allow you to aggregate a set of tasks and synchronize behaviors on the group. You attach multiple work items to a group and schedule them for asynchronous execution on the same queue or different queues. When all work items finish executing, the group executes its completion handler. You can also wait synchronously for all tasks in the group to finish executing.
简单地说,将一些任务放到一个组当中,当组内所有任务执行结束后,发起一个回调通知。下面是一段Swift的示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23override func viewDidLoad() {
super.viewDidLoad()
let group = DispatchGroup()
DispatchQueue.global().async(group: group, execute: {
sleep(3)
print("1")
})
DispatchQueue.global().async(group: group, execute: {
sleep(1)
print("2")
})
group.notify(queue: .main) {
print("3")
}
print("4")
}
/*
输出结果:
4
2
1
3
*/
1 | // 创建组 |
可以看出几个结论:
- 最先输出4,说明notify以异步的方式追加任务到主队列中,没有阻塞线程。
- 接着等待耗时任务2、1执行完,再执行3。
除了上述的方法,还可以手动调用enter()和leave()函数进组。下面是一段Swift示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27override func viewDidLoad() {
super.viewDidLoad()
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async(execute: {
sleep(3)
print("1")
group.leave()
})
group.enter()
DispatchQueue.global().async(execute: {
sleep(1)
print("2")
group.leave()
})
group.notify(queue: .main) {
print("3")
}
print("4")
}
/*
输出结果:
4
2
1
3
*/
其中,在C语言中对应的定义为: 1
2
3
4// 进入组
void dispatch_group_enter(dispatch_group_t group);
// 离开组
void dispatch_group_leave(dispatch_group_t group);
这种方式较为灵活,可以满足一些复杂的场景。但也更容易出错,比如调用了enter()但是忘记调用leave(),使得notify没有被正确调用。
DispatchSemaphore
A dispatch semaphore is an efficient implementation of a traditional counting semaphore. Dispatch semaphores call down to the kernel only when the calling thread needs to be blocked. If the calling semaphore does not need to block, no kernel call is made.
根据官方文档的定义,DispatchSemaphore是对「传统计数信号量」的实现,所以它的使用应该和其他语言或系统平台中一样。严格来讲,它与GCD(Grand Central Dispatch)的概念关系应该不大,它主要用于解决多线程场景下的同步、并发等问题,也可在由NSThread创建的线程中使用。
最主要的一个应用场景是「异步转同步」,下面是Swift的示例代码。
1 | private func loadUserData(completion: @escaping ((_ userId: Int) -> Void)) { |
DispatchSemaphore在Objective-C中对应的函数分别为: 1
2
3
4
5
6
7
8// 创建信号量
dispatch_semaphore_t dispatch_semaphore_create(intptr_t value);
// 信号量 = 0 时等待,信号量 > 0时,进入下一步,并将信号量 减 1。
intptr_t dispatch_semaphore_wait(dispatch_semaphore_t dsema,
dispatch_time_t timeout);
// 信号量 加 1
intptr_t dispatch_semaphore_signal(dispatch_semaphore_t dsema);
事实上,上面的代码也可以用闭包嵌套的方式实现,不过嵌套层数多了代码就没有那么直观。
DispatchSemaphore还能满足线程加锁、控制最大并发数等需求,不过在Apple的开发框架中有比它更好的选择,因此不详细展开。
栅栏函数(barrier)
执行栅栏任务时,会先等待并发队列中已有任务执行完,再执行其自身任务;对于追加在栅栏任务后的新任务,会等待栅栏任务执行完成。
下图是对栅栏函数的一个直观认识,写任务为栅栏,它在执行期间独占时间片,并行队列中不并发;在读任务执行时期,并行队列并发。下面是一个多读单写的应用场景,queue.async(flags: .barrier) {
为追加栅栏任务的方法,Swift示例代码如下:
1 | class ViewController: NSViewController { |
在C语言中,栅栏函数定义为:
1 | void dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block); |
上述Swift示例代码的输出结果如下,可以看出与预期一致: 1
2
3
4
5我喜欢
我喜欢
我喜欢游泳
我喜欢游泳
我喜欢游泳queue.async(flags: .barrier) {
改为常规的异步任务queue.async {
,执行结果如下,与预期不一致。
1
2
3
4
5我喜欢游泳
我喜欢游泳
我喜欢游泳
我喜欢游泳
我喜欢游泳
3.6 小结
GCD就是一个调度中心,开发者把各个任务进入不同类型的队列中,GCD负责安排这些任务的执行顺序逻辑,并派发到各个线程当中。当同步任务和串行队列结合时,要格外注意,执行方和同步任务在同一串行队列时会发生死锁。
4 NSOperaion、NSOperationQueue
4.1 基本理解
在实际应用开发中,GCD已经可以满足大部分应用场景,也是最主流的多线程开发套件。NSOperaion(操作)、NSOperationQueue(队列)是对多线程更高层的一种封装,相比于GCD,使用起来更加符合简单,更加符合直觉。下面用一个例子体会它们的特点,Swift示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45override func viewDidLoad() {
super.viewDidLoad()
// 第一个操作
let op1 = BlockOperation {
print("🍎op1: 1,Thread:\(Thread.current)")
}
op1.addExecutionBlock {
sleep(1)
print("🍎op1: 2,Thread:\(Thread.current)")
}
op1.addExecutionBlock {
sleep(1)
print("🍎op1: 3,Thread:\(Thread.current)")
}
op1.addExecutionBlock {
sleep(5)
print("🍎op1: 4,Thread:\(Thread.current)")
}
op1.completionBlock = {
print("🍎op1 finished...,Thread:\(Thread.current)")
}
// 第二个操作
let op2 = BlockOperation {
print("🍊op2: 1,Thread:\(Thread.current)")
}
op2.addExecutionBlock {
sleep(1)
print("🍊op2: 2,Thread:\(Thread.current)")
}
op2.completionBlock = {
print("🍊op2 finished...,Thread:\(Thread.current)")
}
// 队列
let queue = OperationQueue()
queue.addOperation(op1)
queue.addOperation(op2)
}
在这个例子中,分别创建了两个操作,op1
和op2
,并把这两个操作加入到了队列中。
从代码及运行结果来看,可以得出以下几个结论:
(一)Operation可以添加多个执行块,第一个是初始化的时候,第二个是通过addExecutionBlock
追加,第三个是通过completionBlock
添加操作完成后的回调。
(二)op2
的执行块并未严格在op1
的执行块后运行,而是一种所有执行块都并发的方式。这说明,OperationQueue()
默认是并发队列。
(三)Operation的completionBlock
会在其Operation所有执行块结束后调用,且不在主线程。
4.2 maxConcurrentOperationCount
OperationQueue的maxConcurrentOperationCount属性用于控制队列中操作的最大并发量。
下面在示例子代码中将队列的maxConcurrentOperationCount设置为1,代码如下:
1 |
|
重新运行后,结果如下:
结合上面的运行结果,可以得出maxConcurrentOperationCount=1时的几个结论:
(一)op2
的所有执行块严格在op1
后面,Operation呈现串行的模式。
(二)op1
中的执行块并未严格按顺序,且以一种多线程并发的方式运行,同理op2
也是。这说明maxConcurrentOperationCount=1只限制Operation串行,并不限制Operation内部的执行块也要串行。
4.3 操作依赖
队列中各个操作(Operation)默认会并发执行。但在某些应用场景下,操作与操作之间可能会存在一些执行上的前后依赖关系。
比如有操作op1
、op2
、op3
,要求op1
在op2
和op3
之后执行,op2
和op3
可以并发。下面是Swift的实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58override func viewDidLoad() {
super.viewDidLoad()
// 第一个操作
let op1 = BlockOperation {
sleep(1)
print("🍎op1: 1,Thread:\(Thread.current)")
}
op1.addExecutionBlock {
sleep(1)
print("🍎op1: 2,Thread:\(Thread.current)")
}
op1.completionBlock = {
print("🍎op1 finished...,Thread:\(Thread.current)")
}
// 第二个操作
let op2 = BlockOperation {
sleep(1)
print("🍊op2: 1,Thread:\(Thread.current)")
}
op2.addExecutionBlock {
sleep(1)
print("🍊op2: 2,Thread:\(Thread.current)")
}
op2.completionBlock = {
print("🍊op2 finished...,Thread:\(Thread.current)")
}
// 第三个操作
let op3 = BlockOperation {
sleep(1)
print("🍌op3: 1,Thread:\(Thread.current)")
}
op3.addExecutionBlock {
sleep(1)
print("🍌op2: 2,Thread:\(Thread.current)")
}
op3.completionBlock = {
print("🍌op2 finished...,Thread:\(Thread.current)")
}
// op1 依赖 op3
op1.addDependency(op3)
// op1 依赖 op2
op1.addDependency(op2)
// 队列
let queue = OperationQueue()
queue.addOperation(op1)
queue.addOperation(op2)
queue.addOperation(op3)
}
通过代码和运行结果可以得出如下结论:
(一)由于设置了op1
依赖op2
和op3
,op1
的所有执行块严格在op2
和op3
所有执行块后面运行。
(二)op2
和op3
为并发运行,没有严格的前后顺序关系。
(三)op2
和op3
执行完成后,轮到op1
执行,op1
内的所有执行块也是并发运行的。
参考资料
[7] iOS多线程:『pthread、NSThread』详尽总结 [8] iOS多线程:『GCD』详尽总结 [9] iOS多线程:『NSOperation、NSOperationQueue』详尽总结 [10] Grand Central Dispatch [11] RunLoop总结:RunLoop 与GCD 、Autorelease Pool之间的关系 [12] Dispatch | Apple Developer Documentation [13] DispatchQoS.QoSClass | Apple Developer Documentation [14] DispatchGroup | Apple Developer Documentation [15] DispatchSemaphore | Apple Developer Documentation [16] Semaphore (programming)