跳到主要内容

Cocoa 并发编程笔记

· 阅读需 14 分钟

并发所描述的概念就是同时运行多个任务。这些任务可能是以在单核 CPU 上以分时的形式同时运行,也可能是在多核 CPU 上以真正的并行方式来运行。在 iOS/macOS 中,主要提供了 pthread, NSThread, NSOperationQueue, Grand Central Dispatch 和 NSRunloop 等方法实现并发编程。

Thread

线程(thread)是组成进程的子单元,操作系统的调度器可以对线程进行单独的调度。实际上,所有的并发编程 API 都是构建于线程之上的,包括 GCD 和操作队列。多线程可以在单核 CPU 上同时(或者至少看作同时)运行。操作系统将小的时间片分配给每一个线程,这样就能够让用户感觉到有多个任务在同时进行。如果 CPU 是多核的,那么线程就可以真正的以并发方式被执行,从而减少了完成某项操作所需要的总时间。

pthread 是 POSIX 的线程标准,但写起代码比较复杂。NSThread 是 Objective-C 对 pthread 的封装(对应的 Thread 为 Swift 对 pthread 的封装),更便于在 Cocoa 环境下开发。常用的使用方法是创建一个线程对象,并调用它的 start 方法。可以通过检测线程的 isFinished 属性判断线程是否结束。

let helloThread = Thread {
print("Hello World")
}
helloThread.start()

也可以通过创建一个 Thread 的子类,将需要后台执行的代码写在重写的 main 方法里。

class TestThread: Thread {
override func main() {
// Code goes here
}
}

直接使用线程可能会引发的一个问题是,如果你的代码和所基于的框架代码都创建自己的线程时,那么活动的线程数量有可能以指数级增长。例如,在 8 核 CPU 中,你创建了 8 个线程来完全发挥 CPU 性能。然而在这些线程中你的代码所调用的框架代码也做了同样事情(因为它并不知道你已经创建的这些线程),这样会很快产生成成百上千的线程。代码的每个部分自身都没有问题,然而最后却还是导致了问题。使用线程并不是没有代价的,每个线程都会消耗一些内存和内核资源。

Grand Central Dispatch

GCD 是自 macOS 10.6 和 iOS 4 被引入的一个更方便充分使用多核 CPU 性能的技术,现在也作为 libdispatch 被加入 FreeBSD 等操作系统中。通过 GCD,开发者不用再直接跟线程打交道了,只需要向队列中添加代码块即可,GCD 在后端管理着一个线程池。GCD 不仅决定着你的代码块将在哪个线程被执行,它还根据可用的系统资源对这些线程进行管理。这样可以将开发者从线程管理的工作中解放出来,通过集中的管理线程,来缓解大量线程被创建的问题。GCD 带来的另一个重要改变是作为开发者可以将工作考虑为一个队列,而不是一堆线程,这种并行的抽象模型更容易掌握和使用。

GCD 的队列实际上就是一系列代码块,这些代码可以在主线程或后台线程中以同步或者异步的方式执行。一旦队列创建完成,操作系统就接管了这个队列,并将其分配到任意一个核心中进行处理。不管有多少个队列,它们都能被系统正确地管理,这些都不需要开发者进行手动管理。队列遵循 FIFO 模式(先进先出),这意味着先进队列的任务会先被执行。

Objective-C 和早期 Swift 中的 GCD 仍保留 C 风格 API,在 Swift 3 中 GCD 被进行了较大的改变,更加面向对象。

创建队列,只需要简单的构造一个 DispatchQueue 对象:

let queue = DispatchQueue(label: "queueIdentifier")
queue.async {
// 异步执行代码
}

这里的 async 为异步执行,即任务将(几乎)同时执行。sync 方法则会在串行队列里将任务一个个依次执行。

DispatchQueue 的构造函数里包含了一个 qos 参数,即队列优先级(Quality of Service),是一个名为 QoSClass 的枚举类型:

public enum QoSClass {
case background
case utility
case `default`
case userInitiated
case userInteractive
case unspecified
}

使用不同优先级的若干个队列乍听起来非常直接,不过强烈建议在绝大多数情况下使用默认的优先级队列。如果执行的任务需要访问一些共享的资源,那么在不同优先级的队列中调度这些任务很快就会造成不可预期的行为。这样可能会引起程序的完全挂起,因为低优先级的任务阻塞了高优先级任务,使它不能被执行。

GCD 也不是总需要创建队列,尤其是并不建议改变优先级的时候。常用的方法有 DispatchQueue.global(),即操作系统创建的全局队列,一个后台队列的集合。从别的队列访问主队列也很简单,只需要 DispatchQueue.main。主队列经常用于更新 UI 等操作。一个简单的使用全局队列进行计算并从主队列更新 UI 的栗子如下。

// Global queue
DispatchQueue.global().async {
// Time-comsuming operations
for i in 0 ..< 10 {
total += i
}
// Back to main queue
DispatchQueue.main.async {
// Update UI
print(total)
}
}

GCD 还有一个很重要的概念叫 DispatchGroup。可以把几个相关的任务队列放到一个组中,常用的 DispatchGroup 实例方法有 waitnotify。当一个组中所有队列任务执行完毕后会触发队列的 notify 方法。例如一个程序具有两个异步队列分别从服务器下载文本和图片,文本和图片都下载完成后从主队列更新 UI。栗子如下。

// 创建队列
let textQueue = DispatchQueue(label: "textQueue")
let imageQueue = DispatchQueue(label: "imageQueue")
// 创建组
let group = DispatchGroup()
// 文本队列异步下载
textQueue.async(group: group) {
downloadText()
}
// 图片队列异步下载
imageQueue.async(group: group) {
downloadImage()
}
// 下载完成后触发 DispatchGroup 的 notify,从主队列更新 UI
group.notify(queue: .main) {
updateUI()
}

需要注意的是这里 notify 方法的 queue 参数是组里队列执行完毕后代码块要被提交到的队列而不是组所监听的队列(这个关系由 async 方法的 group 参数确定),因此更新 UI 的主队列作为 notify 方法的 queue 参数传入。

第二个常用的方法 wait 即组可以选择等待的时间,如果在时间内所有队列执行完毕则执行某段代码块,否则超时错误执行另一段代码块。基本用法如下:

// 等待两秒钟
let result = group.wait(timeout: .now() + 2.0)
switch result {
// 成功执行
case .success:
print("Success")
// 超时
case .timedOut:
print("GG")
}

wait 方法会返回一个 DispatchTimeoutResult 枚举,它的构成只有 successtimedOut 两种,因此常用 switch-case 语句作进一步的判断。

DispatchGroup 也可以手动进行计数管理,即 group.enter()group.leave(),在这种情况下 enterleave 必须配对。例如:

let group = DispatchGroup()
// task 1
group.enter()
Task1.someTask {
group.leave()
}
// task 2
group.enter()
Task2.anotherTask {
group.leave()
}
// 所有任务完成后
group.notify(queue: .global()) {
completion()
}

GCD 还有一种常见的用法是做延时操作,比较简单:

DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
// 延迟三秒执行
}

这里时间的用法和上述 wait 方法类似,都是一个 DispatchTime 对象。比较有趣的是这里可以使用 + 号并不是数学运算,而是实现了一个函数 public func +(time: DispatchTime, seconds: Double) -> DispatchTime

Operation Queue

操作队列是由 GCD 提供的一个队列模型的 Cocoa 抽象。GCD 提供了更加底层的控制,而操作队列则在 GCD 之上实现了一些方便的功能。OperationQueue 有两种不同类型的队列:主队列和自定义队列。主队列运行在主线程之上,而自定义队列在后台执行。在两种类型中,这些队列所处理的任务都使用 Operation 的子类来表述。你可以通过重写 main 或者 start 方法来定义自己的 Operation。重写 main 并不需要管理一些状态属性(例如 isExecutingisFinished),当 main 方法返回的时候这个 Operation 就结束了。这种方式使用起来非常简单,但是灵活性相对重写 start 来说要少一些。

class TestOperation: Operation {
override func main() {
// Do something
}
}

类似的,也可以构造 OperationQueue,并将 Operation 添加到队列中。栗子如下:

// 构造操作队列
let operationQueue = OperationQueue()
// 构造 Operation
let testOperation = TestOperation()
// 添加 Operation
operationQueue.addOperation(testOperation)
// 添加代码块
operationQueue.addOperation {
// Do something
}

主队列可以通过 OperationQueue.main 访问到。除了提供基本的调度操作或 block 外,操作队列还提供了在 GCD 中不太容易处理好的特性的功能。例如,可以通过 maxConcurrentOperationCount 属性来控制一个特定队列中可以有多少个操作参与并发执行,将其设置为 1 的话将得到一个串行队列。

另外还有一个方便的功能就是根据队列中 Operation 的优先级对其进行排序,这不同于 GCD 的队列优先级,它只影响当前队列中所有被调度的 Operation 的执行先后。如果需要进一步在除了 5 个标准的优先级以外对 Operation 的执行顺序进行控制的话,还可以通过 addDependency 方法在 Operation 之间指定依赖关系。对于需要明确的执行顺序时,操作依赖是非常强大的一个机制。它可以让你创建一些操作组,并确保这些操作组在依赖它们的操作被执行之前执行,或者在并发队列中以串行的方式执行操作。

RunLoop

RunLoop 就是一组小的循环,在里面不断处理新的事件,比如 RunLoop.main 与主线程相关负责处理 UI 事件、计时器以及其它内核相关事件。每个 RunLoop 都和一个线程相关(一一对应)。

RunLoop 可以运行在不同的模式中,每种模式都定义了一组事件,供 RunLoop 做出响应,比如 RunLoop.main 暂时性的将某个任务优先执行。一个典型的栗子是 iOS 的滚动,在进行滚动时为保证流畅 RunLoop 并不是运行在默认模式中,因此其他任务(计时器、UI 更新)并不会被 RunLoop 响应。如果需要,则要设置 NSRunLoopCommonModes 的模式并添加到 RunLoop 中。