0%

iOS 多线程学习笔记

最近几天陆陆续续把 raywenderlich 出品的《Concurrency by tutorials》 看完了,写篇文章总结一下。

Q1:什么是并发?并发和并行的区别?

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。
在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。
我相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。

Q2:什么是 GCD(Grand Central Dispatch)?

GCD 是 Apple 对 C 语言 libdispatch 库的实现。它的目标是使需要执行的任务入队列,然后以某种方式执行队列中的任务。GCD 在实现中使用线程,所以开发者就无需亲自管理线程,亲自创建销毁线程是比较复杂的事情。

Q3:什么是串行队列?什么是并发队列?

  1. 串行队列和并发队列都是先进先出(FIFO),区别在于其队列中任务的执行方式。
  2. 串行队列中,下一个任务会等待上一个任务结束后才会执行。
  3. 并发队列中,不会等待上一个任务完全执行结束,就会立即调用执行下一个任务。

需要注意的是:这里的 FIFO 是以任务开始的时间为基准的,而不是任务完成的时间。
队列中任务的执行方式是与使用同步函数还是异步函数相关。
负责更新 UI 的 main queue 就是一个串行队列。

Q4:什么是同步任务?什么是异步任务?

被塞入队列里的任务要么以同步的方式运行,要么以异步的方式运行。
当任务以同步的方式运行时,当前队列会被阻塞,直到该任务执行完毕,才会执行之后的代码。
当任务以异步的方式运行时,任务入队列,当前队列不会被阻塞,继续执行代码。

Q5:GCD,Operation,BlockOperation 三者该怎么选择?

Operation 是建立于 GCD 之上的。GCD 往往适用于在后台运行的轻量任务。当遇到需要执行重复次数多且耗时的任务时,比如修改图片景深等,建议选择 Operation。Operation 默认是同步执行的,如果想要异步执行 Operation,还需要更多的工作,可以参考 官方文档 中 Subclassing Notes 一节。Operation 还提供给我们跟踪任务运行状态以及取消任务的功能。

介于两种情况之间呢,BlockOperation 是个好选择,但要注意的是 BlockOperation 默认是并发执行的。如果想要 BlockOperation 串行执行,我们得额外建立一个 dispatch queue。

Q6:该如何选择六种不同的 QoS?

当使用并发队列时,Apple 提供六种不同的全局并发队列,即有六种不同的 QoS。不同的 QoS 代表队列中任务不同的优先级。

.userInteractive

对应的任务一般都是 UI 更新以及动画效果相关的。提交到这类队列的任务应该是瞬时完成的,不然可能会有些卡顿出现。

.userInitiated

对应的任务一般用户从 UI 触发的可以异步执行的任务,比如点击一个 button 打开一个 pdf 文档。该队列中的任务应该在几秒或更少的时间内完成。

.utility

对应的任务往往是较长时间的网络相关的任务。这种任务往往需要在 UI 上显示一个 HUD。该队列中的任务应该在几秒到几分钟的时间内完成。

.background

对应的任务往往是用户无法直接感知到的任务,比如与远端服务器同步,执行备份等。这类任务往往不需要人机交互且对运行时间的长短不太敏感。

.default and .unspecified

几乎很少会去主动使用这两种 QoS,知道它俩的存在就行。

Q7:什么时候使用 DispatchWorkItem?

使用 DispatchWorkItem 的原因之一可能是我们需要在这个任务运行前或运行中取消它。
对这个 DispatchWorkItem 调用 cancel() 函数,可能会出现以下两种情况:

  1. 如果这个任务还未开始执行,则这个任务会被移除队列。
  2. 如果这个任务正在执行,它的 isCancelled 属性会被设置为 true。

多个 DispatchWorkItem 之间还可以添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let backgroundWorkItem = DispatchWorkItem {
print("backgroundWorkItem start!")
Thread.sleep(forTimeInterval: 5)
print("backgroundWorkItem finish!")
}

let updateUIWorkItem = DispatchWorkItem {
print("update UI start")
Thread.sleep(forTimeInterval: 2)
print("update UI finish")
}

backgroundWorkItem.notify(queue: .main, execute: updateUIWorkItem)
dispatchQueue.async(execute: backgroundWorkItem)

但是,如果觉得需要取消任务的执行或者指明任务间的依赖,还是建议使用 Operation。

Q8:什么时候使用 Dispatch Group?

有这么一些任务,我们需要知道它们何时都完成了,在这种情况下,建议使用 Dispatch Group。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInitiated)

queue.async(group: group) {
print("Start job 1")
Thread.sleep(until: Date().addingTimeInterval(10))
print("End job 1")
}

queue.async(group: group) {
print("Start job 2")
Thread.sleep(until: Date().addingTimeInterval(2))
print("End job 2")
}

if group.wait(timeout: .now() + 20) == .timedOut {
print("I got tired of waiting")
} else {
print("All the jobs have completed")
}

group.notify(queue: .main) {
print("all task finished!")
}

网络请求回调往往是异步的,当 group 中的任务是异步任务时,我们可以利用 enter() 和 leave() 来表示任务进入 group 和在 group 中的任务已经完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let group = DispatchGroup()
group.enter()
// 网络请求回调函数1里调用 leave()
requestForXXX().finally {
group.leave()
}
group.enter()
// 网络请求回调函数2里调用 leave()
requestForXXX().finally {
group.leave()
}
group.enter()
// 网络请求回调函数3里调用 leave()
requestForXXX().finally {
group.leave()
}
group.notify(queue: .main) {
// 三个网络请求均完成后,需要执行的操作
// ...
}

这里需要注意的一点是: enter() 和 leave() 调用的次数要相匹配。

Q9:什么时候使用信号量(Semaphores)?

当需要控制访问共享资源的线程在某个范围内时,或者举个更具体的例子:当从网络上下载数据时,可以利用信号量来控制同时最多多少个下载任务进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInteractive)

let semaphore = DispatchSemaphore(value: 4)

for i in 1...10 {
semaphore.wait()
queue.async(group: group) {

defer {
semaphore.signal()
}

print("Downloading image \(i)")

// Simulate a network wait
Thread.sleep(forTimeInterval: 3)

print("---Downloaded image \(i)")
}
}

group.wait()

Q10:什么时候使用 Thread barrier ?

当面对多读单写的时候,手动加锁可能是比较复杂的事情,我们可以使用 GCD 提供的 thread barrier。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private let threadSafeCountQueue = DispatchQueue(label: "...", attributes: .concurrent)

private var _count = 0

public var count: Int {
get {
return threadSafeCountQueue.sync {
return _count
}
}

set {
threadSafeCountQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self._count = newValue
}
}
}

Q11: 如何写一个异步的 Operation?

  1. 继承父类 Operation
  2. 手动管理 Operation 的状态
  3. 根据 官方文档,覆写(override)指定的父类方法
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
class AsyncOperation: Operation {
enum State: String {
case ready, executing, finished

fileprivate var keyPath: String {
return "is\(rawValue.capitalized)"
}
}

// 手动触发 KVO
var state = State.ready {
willSet {
willChangeValue(forKey: newValue.keyPath)
willChangeValue(forKey: state.keyPath)
}
didSet {
didChangeValue(forKey: oldValue.keyPath)
didChangeValue(forKey: state.keyPath)
}
}

// Override properties
override var isReady: Bool {
return super.isReady && state == .ready
}

override var isExecuting: Bool {
return state == .executing
}

override var isFinished: Bool {
return state == .finished
}

override var isAsynchronous: Bool {
return true
}

// 无需调用 super.start()
override func start() {
if isCancelled {
state = .finished // 手动管理状态
return
}

main()
state = .executing // 手动管理状态
}
}

AsyncOperation 严格算是个抽象类。当具体实现时,记得继承 AsyncOperation 。举个例子,把从网络下载图片的请求封装成一个 Operation:

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
final class NetworkImageOperation: AsyncOperation {
var image: UIImage?

private let url: URL
private let completion: ImageOperationCompletion
...
...
...
// 覆写 main() ,在 main() 中写所需执行的代码
override func main() {
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }

defer { self.state = .finished } // 手动管理 Operation 状态

if let completion = self.completion {
completion(data, response, error)
return
}

guard error == nil, let data = data else { return }

self.image = UIImage(data: data)
}.resume() //千万别他妈再忘了!!!
}
}

Q12: 什么时候使用 Operation 依赖?

当多个任务之间有先后次序时,我们可以选择使用 Operation 依赖,而 GCD 却不是个好选择。

1
2
3
4
5
6
7
8
9
let downloadOp = NetworkImageOperation(url: urls[indexPath.row])
let tiltShiftOp = TiltShiftOperation()
tiltShiftOp.addDependency(downloadOp)

...
...

queue.addOperation(downloadOp)
queue.addOperation(tiltShiftOp)

但没有处理好任务之间的依赖,是有可能导致死锁。

Q13: Thread sanitizer 有什么用?

Thread sanitizer 可以帮助开发者发现多线程编程中的竞态条件(race condition),简称 TSan。让我来举一个 race condition 的例子:首先定义变量 var count = 0 ,然后并发执行 count += 1count -= 1 就可能出现 race condition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// count += 1 背后的实现可以理解为:
register1 = count
register1 = register1 + 1
count = register1

// count -= 1 背后的实现可以理解为:
register2 = count
register2 = register2 + 1
count = register2

// 所以并发执行 count += 1 和 count -= 1 时,底层的代码执行顺序可能是任意的
T0: 执行 register1 = count // register1 = 5
T1: 执行 register1 = register1 + 1 // register1 = 6
T2: 执行 register2 = count // register2 = 5
T3: 执行 register2 = register2 - 1 // register2 = 4
T4: 执行 count = register1 // count = 6
T5: 执行 count = register2 // count = 4

// 两个进程并发操作 count,且运行结果依赖于操作的先后次序,这种情况称为 race condition。

再继续看一下更具体的例子:
首先先开启 Thread Sanitizer。

运行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
final class MainViewController: UIViewController {
var counter = 0

override func viewDidLoad() {
super.viewDidLoad()

let queue = DispatchQueue(label: "q")
queue.async {
for _ in 1 ... 10000 {
Thread.sleep(forTimeInterval: 0.1) // 强制切换线程
self.counter += 1
}
}

DispatchQueue.main.async {
for _ in 1 ... 10000 {
self.counter += 1
}
}
}
}

Race condition 发生了:

需要注意的是:TSan 仅支持在模拟器下运行。

结语

读完后,为 iOS 多线程方面打下了一点点基础,还是要在工作中多实践才有更深的感悟。