在 iOS 当中,苹果提供了两种方式进行多任务编程:Grand Central Dispatch (GCD) 和 NSOperationQueue。当我们需要把任务分配到不同的线程中,或者是非主队列的其它队列中时,这两种方法都可以很好地满足需求。选择哪一种方法是很主观的行为,但是该文章关注前一种,即 GCD。不管使用哪一种方法,有一条规则必须要牢记: 任何操作都不能堵塞主线程,必须使其用于界面响应以及用户交互。所有的耗时操作或者对 CPU 需求大的任务都要在并发或者后台队列中执行。对于新手来说,理解和实践可能都会比较难。
我们需要先了解一些更具体的概念。首先,GCD 中的核心词是 dispatch queue。一个队列实际上就是一系列的代码块,这些代码可以在主线程或后台线程中以同步或者异步的方式执行。一旦队列创建完成,操作系统就接管了这个队列,并将其分配到任意一个核心中进行处理。不管有多少个队列,它们都能被系统正确地管理,这些都不需要开发者进行手动管理。队列遵循 FIFO 模式(先进先出),这意味着先进队列的任务会先被执行(想像在柜台前排队的队伍,排在第一个的会首先被服务,排在最后的就会最后被服务)。我们会在后面的第一个例子中更清楚地理解这个概念。
接下来,另一个重要的概念就是 WorkItem(任务项)。一个任务项就是一个代码块,它可以随同队列的创建一起被创建,也可以被封装起来,然后在之后的代码中进行复用。正如你所想,任务项的代码就是 dispatch queue 将会执行的代码。队列中的任务项也是遵循 FIFO 模式。这些执行可以是同步的,也可以是异步的。对于同步的情况下,应用会一直堵塞当前线程,直到这段代码执行完成。而当异步执行的时候,应用先执行任务项,不等待执行结束,立即返回。我们会在后面的实例里看到它们的区别。
1. 认识 Dispatch Queue
在 Swift 3 当中,创建一个 dispatch queue 的最简单方式如下:
let queue = DispatchQueue(label: "com.appcoda.myqueue")
你唯一要做的事就是为你的队列提供一个独一无二的标签(label)。使用一个反向的 DNS 符号(”com.appcoda.myqueue”)就很好,因为用它很容易创造一个独一无二的标签,甚至连苹果公司都是这样建议的。尽管如此,这并不是强制性的,你可以使用你喜欢的任何字符串,只要这个字符串是唯一的。除此之外,上面的构造方法并不是创建队列的唯一方式。在初始化队列的时候可以提供更多的参数,我们会在后面的篇幅中谈论到它。
一旦队列被创建后,我们就可以使用它来执行代码了,可以使用 sync 方法来进行同步执行,或者使用 async 方法来进行异步执行。因为我们刚开始,所以先使用代码块(一个闭包)来作为被执行的代码。在后面的篇幅中,我们会初始化并使用 dispatch 任务项(DispatchWorkItem)来取代代码块(需要注意的是,对于队列来说代码块也算是一个任务项)。我们先从同步执行开始,下面要做的就是打印出数字 0~9 :
程序的运行会在队列的 block 中止,并且直到队列的任务结束前,它都不会执行主线程,也不会打印数字 100 ~ 109。程序会有这样的行为,是因为我们使用了同步执行。你也可以在控制台中看到输出结果
但是如果我们使用 async 方法运行代码块会发生什么事呢?在这种情况下,程序不需要等待队列任务完成才往下执行,它会立马返回主线程,然后第二个 for 循环会与队列里的循环同时运行。在我们看到会发生什么事之前,将队列的执行改用 async 方法
尽管上面的示例很简单,但已经清楚地展示了一个程序在同步队列与异步队列中行为的差异。我们将在接下来的示例中继续使用这种彩色的控制台输出,请记住,特定颜色代码特定队列的运行结果,不同的颜色代表不同的队列。
2. Quality Of Service(QoS)和优先级
在使用 GCD 与 dispatch queue 时,我们经常需要告诉系统,应用程序中的哪些任务比较重要,需要更高的优先级去执行。当然,由于主队列总是用来处理 UI 以及界面的响应,所以在主线程执行的任务永远都有最高的优先级。不管在哪种情况下,只要告诉系统必要的信息,iOS 就会根据你的需求安排好队列的优先级以及它们所需要的资源(比如说所需的 CPU 执行时间)。虽然所有的任务最终都会完成,但是,重要的区别在于哪些任务更快完成,哪些任务完成得更晚。
用于指定任务重要程度以及优先级的信息,在 GCD 中被称为 Quality of Service(QoS)。事实上,QoS 是有几个特定值的枚举类型,我们可以根据需要的优先级,使用合适的 QoS 值来初始化队列。如果没有指定 QoS,则队列会使用默认优先级进行初始化。要详细了解 QoS 可用的值,可以参考这个文档,请确保你仔细看过这个文档。下面的列表总结了 Qos 可用的值,它们也被称为 QoS classes。第一个 class 代码了最高的优先级,最后一个代表了最低的优先级:
userInteractive
userInitiated
default
utility
background
unspecified
let queue1 = DispatchQueue(label: "com.appcoda.queue1", qos: DispatchQoS.userInitiated)
let queue2 = DispatchQueue(label: "com.appcoda.queue2", qos: DispatchQoS.userInitiated)
从上面的截图当中可以轻易看出这两个任务被“均匀”地执行,而这也是我们预期的结果。现在让我们把 queue2 的 QoS class 设置为 utility(低优先级),如下所示
现在看看会发生什么:
毫无疑问地,第一个 dispatch queue(queue1)比第二个执行得更快,因为它的优先级比较高。即使 queue2 在第一个队列执行的时候也获得了执行的机会,但由于第一个队列的优先级比较高,所以系统把多数的资源都分配给了它,只有当它结束后,系统才会去关心第二个队列。
现在让我们再做另外一个试验,这次将第一个 queue 的 QoS class 设置为 background:
3. 并行队列
在上面的初始化当中,有一个新的参数:attributes。当这个参数被指定为 concurrent 时,该特定队列中的所有任务都会被同时执行。如果没有指定这个参数,则队列会被设置为串行队列。事实上,QoS 参数也不是必须的,在上面的初始化中,即使我们将这些参数去掉也不会有任何问题。
现在重新运行代码,可以看到任务都被并行地执行了:
注意,改变 QoS class 也会影响程序的运行。但是,只要在初始化队列的时候指定了 concurrent,这些任务就会以并行的方式运行,并且它们各自都会拥有运行时间。
这个 attributes 参数也可以接受另一个名为 initiallyInactive 的值。如果使用这个值,任务不会被自动执行,而是需要开发者手动去触发。我们接下来会进行说明,但是在这之前,需要对代码进行一些改动。首先,声明一个名为 inactiveQueue 的成员属性,如下所示
if let queue = inactiveQueue {
queue.activate()
}
现在的问题是,我们如何在指定 initiallyInactive 的同时将队列指定为并行队列?其实很简单,我们可以将两个值放入一个数组当中,作为 attributes 的参数,替代原本指定的单一数值:
4. 延迟执行
有时候,程序需要对代码块里面的任务项进行延时操作。GCD 允许开发者通过调用一个方法来指定某个任务在延迟特定的时间后再执行。
5. 访问主队列和全局队列
在前面的所有例子当中,我们都手动创建了要使用的 dispatch queue。实际上,我们并不总是需要自己手动创建,特别是当我们不需要改变队列的优先级的时候。就像我在文章一开头讲过的,操作系统会创建一个后台队列的集合,也被称为全局队列(global queue)。你可以像使用自己创建的队列一样来使用它们,只是要注意不能滥用。
访问全局队列十分简单:
let globalQueue = DispatchQueue.global()
可以像我们之前使用过的队列一样来使用它:
当使用全局队列的时候,并没有太多的属性可供我们进行修改。但是,你仍然可以指定你想要使用队列的 Quality of Service:
let globalQueue = DispatchQueue.global(qos: .userInitiated)
如果没有指定 QoS class(就像本节的第一个示例),就会默认以 default 作为默认值。
无论你使不使用全局队列,你都不可避免地要经常访问主队列,大多数情况下是作为更新 UI 而使用。在其它队列中访问主队列的方法也非常简单,就如下面的代码片段所示,并且需要在调用的同时指定同步还是异步执行:
DispatchQueue.main.async {
// Do something
}
事实上,你可以输入 DispatchQueue.main. 来查看主队列的所有可用选项,Xcode 会通过自动补全来显示主队列所有可用的方法,不过上面代码展示的就是我们绝大多数时间会用到的(事实上,这个方法是通用的,对于所有队列,都可以通过输入 . 之后让 Xcode 来进行自动补全)。就像上一节所做的一样,你也可以为代码的执行增加延时。
现在让我们来看一个真实的案例,演示如何通过主队列来更新 UI。在初始工程的 Main.storyboard 文件中有一个 ViewController 场景(sence),这个 ViewController 场景包含了一个 imageView,并且这个 imageView 已经通过 IBOutlet 连接到对应的 ViewController 类文件中。在这里,我们通过 fetchImage() 方法(目前是空的)来下载一个 Appcoda 的 logo 并将其展示到 imageView 当中。下面的代码完成了上述动作(我不会在这里针对 URLSession 做相关的讨论,以及介绍它如何使用):
func fetchImage() {
let imageURL: URL = URL(string: "http://www.appcoda.com/wp-content/uploads/2015/12/blog-logo-dark-400.png")!
(URLSession(configuration: URLSessionConfiguration.default)).dataTask(with: imageURL, completionHandler: { (imageData, response, error) in
if let data = imageData {
print("Did download image data")
self.imageView.image = UIImage(data: data)
}
}).resume()
}
意,我们并没有在主队列更新 UI 界面,而是试图在 dataTask(...) 方法的 completion handler 里运行的后台线程来更新界面。编译、运行程序,看看会发生什么(不要忘记调用 fetchImage() 方法):
即使我们得到了图片下载完成的信息,但是没有看到图片被显示到 imageView 上面,这是因为 UI 并没有更新。大多数情况下,这个图片会在信息出现的一小会后显示出来(但是如果其他任务也在应用程序中执行,上述情况不保证会发生),不仅如此,你还会在控制台看到关于在后台线程更新 UI 的一大串出错信息。
现在,让我们改正这段有问题的行为,使用主队列来更新用户界面。在编辑上述方法的时候,只需要改动底下所示部分,并注意我们是如何使用主队列的:
if let data = imageData {
print("Did download image data")
DispatchQueue.main.async {
self.imageView.image = UIImage(data: data)
}
}
再次运行程序,会看到图片在下载完成后被正确地显示出来。主队列确实被调用并更新了 UI。
6. 使用 DispatchWorkItem 对象
DispatchWorkItem 是一个代码块,它可以在任意一个队列上被调用,因此它里面的代码可以在后台运行,也可以在主线程运行。它的使用真的很简单,就是一堆可以直接调用的代码,而不用像之前一样每次都写一个代码块。
下面展示了使用任务项最简单的方法:
let workItem = DispatchWorkItem {
// Do something
}
现在让我们通过一个小例子来看看 DispatchWorkItem 如何使用。前往 useWorkItem() 方法,并添加如下代码:
func useWorkItem() {
var value = 10
let workItem = DispatchWorkItem {
value += 5
}
}
这个任务项的目的是将变量 value 的值增加 5。我们使用任务项对象去调用 perform() 方法,如下所示:
workItem.perform()
这行代码会在主线程上面调用任务项,但是你也可以使用其它队列来执行它。参考下面的示例:
let queue = DispatchQueue.global()
queue.async {
workItem.perform()
}
这段代码也可以正常运行。但是,有一个更快地方法可以达到同样的效果。DispatchQueue 类为此目的提供了一个便利的方法:
queue.async(execute: workItem)
当一个任务项被调用后,你可以通知主队列(或者任何其它你想要的队列),如下所示:
workItem.notify(queue: DispatchQueue.main) {
print("value = ", value)
}
上面的代码会在控制台打印出 value 变量的值,并且它是在任务项被执行的时候打印的。现在将所有代码放到一起,userWorkItem() 方法内的代码如下所示
func useWorkItem() {
var value = 10
let workItem = DispatchWorkItem {
value += 5
}
workItem.perform()
let queue = DispatchQueue.global(qos: .utility)
queue.async(execute: workItem)
workItem.notify(queue: DispatchQueue.main) {
print("value = ", value)
}
}
7. 创建线程群组
func group() {
// 获得全局队列
let globalQueue = DispatchQueue.global()
// 创建一个队列组
let group = DispatchGroup()
globalQueue.async(group: group, execute: {
print("任务一 \(Thread.current)")
})
globalQueue.async(group: group, execute: {
print("任务二 \(Thread.current)")
})
// group内的任务完成后,执行此方法
group.notify(queue: globalQueue, execute: {
print("终极任务 \(Thread.current)")
})
globalQueue.async(group: group, execute: {
print("任务三 \(Thread.current)")
})
globalQueue.async(group: group, execute: {
print("任务四 \(Thread.current)")
})
}
开启多条线程,去执行群组中的任务,当群组内的四个任务执行完毕后,再去执行notify里面的任务
8. GCD 信号量控制并发
当我们在处理一系列线程的时候,当数量达到一定量,在以前我们可能会选择使用NSOperationQueue来处理并发控制,但如何在GCD中快速的控制并发呢?答案就是dispatch_semaphore。
信号量是一个整形值并且具有一个初始计数值,并且支持两个操作:信号通知和等待。当一个信号量被信号通知,其计数会被增加。当一个线程在一个信号量上等待时,线程会被阻塞(如果有必要的话),直至计数器大于零,然后线程会减少这个计数。
在GCD中有三个函数是semaphore的操作,分别是:
1、dispatch_semaphore_create 创建一个semaphore
2、dispatch_semaphore_signal 发送一个信号
3、dispatch_semaphore_wait 等待信号
下面我们逐一介绍三个函数:
(1) dispatch_semaphore_create
dispatch_semaphore_t dispatch_semaphore_create(long value);
传入的参数为long,输出一个dispatch_semaphore_t类型且值为value的信号量。值得注意的是,这里的传入的参数value必须大于或等于0,否则dispatch_semaphore_create会返回NULL。
(2) dispatch_semaphore_signal
long dispatch_semaphore_signal(dispatch_semaphore_t dsema)这个函数会使传入的信号量dsema的值加1
(3) dispatch_semaphore_wait
long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)
这个函数会使传入的信号量dsema的值减1。这个函数的作用是这样的,如果dsema信号量的值大于0,该函数所处线程就继续执行下面的语句,并且将信号量的值减1;如果desema的值为0,那么这个函数就阻塞当前线程等待timeout(注意timeout的类型为dispatch_time_t,不能直接传入整形或float型数),如果等待的期间desema的值被dispatch_semaphore_signal函数加1了,且该函数(即dispatch_semaphore_wait)所处线程获得了信号量,那么就继续向下执行并将信号量减1。如果等待期间没有获取到信号量或者信号量的值一直为0,那么等到timeout时,其所处线程自动执行其后语句
(4)dispatch_semaphore_signal的返回值为long类型,当返回值为0时表示当前并没有线程等待其处理的信号量,其处理的信号量的值加1即可。当返回值不为0时,表示其当前有(一个或多个)线程等待其处理的信号量,并且该函数唤醒了一个等待的线程(当线程有优先级时,唤醒优先级最高的线程;否则随机唤醒)。
dispatch_semaphore_wait的返回值也为long型。当其返回0时表示在timeout之前,该函数所处的线程被成功唤醒。当其返回不为0时,表示timeout发生。
(5)停车场剩余4个车位,那么即使同时来了四辆车也能停的下。如果此时来了五辆车,那么就有一辆需要等待。信号量的值就相当于剩余车位的数目,dispatch_semaphore_wait函数就相当于来了一辆车,dispatch_semaphore_signal就相当于走了一辆车。停车位的剩余数目在初始化的时候就已经指明了(dispatch_semaphore_create(long value)),调用一次dispatch_semaphore_signal,剩余的车位就增加一个;调用一次dispatch_semaphore_wait剩余车位就减少一个;当剩余车位为0时,再来车(即调用dispatch_semaphore_wait)就只能等待。有可能同时有几辆车等待一个停车位。有些车主没有耐心,给自己设定了一段等待时间,这段时间内等不到停车位就走了,如果等到了就开进去停车。而有些车主就像把车停在这,所以就一直等下去。