作为一个iOS开发,多线程是一个不可回避的话题。下面是我个人对GCD的浅显理解,写出来供大家一起讨论。以下所有代码都是基于swift3。
前言
什么是线程
线程就是你在代码中执行一段任务的最小单元,他会逐行执行你的代码。如果你的代码中,有一段执行很耗时,比如for循环10亿次,那么在这个循环执行完毕之前,后面的代码都不会执行。我们都知道iOS应用只有一个主线程,如果你的耗时循环是在这个主线程中,那么主线程就会被阻塞,那么你的应用就会被卡死,一直到这个循环执行完毕。
如何解决这个问题呢,很简单,就是开启一个新的线程来执行这个耗时的任务。这就是所谓的多线程。
iOS中使用多线程的几种方式
Thread:
你可以直接创建自己的线程,像这样:
//创建一个线程
let newThread = Thread {
for i in 0...100{
print(i)
}
}
//线程执行
newThread.start()
这是创建线程最基本的方式,但是你需要自己管理线程的生命周期,比如何时销毁等等,所以这种方式在本文中不做过多阐述。
OperationQueue:
用起来比较方便,你可以子类化Operation类或者直接添加block进去。这种方式也提供了一些GCD不能实现的功能,比如取消一个操作,比如设置依赖等等,你可以像下面这样使用:
/** 自定义的operation */
class MyOperation: Operation {
//如果重写start方法,必须自己管理线程
/*override func start() { }*/
override func main() {
print("myOperation main")
}
}
/** 自定义的operation */
class HisOperation: Operation {
override func main() {
print("hisOperation main")
}
}
//创建一个operationQueue
let operationQueue = OperationQueue()
//设置最大并行数,如果是1,就是串行的
//operationQueue.maxConcurrentOperationCount = 1
//block方式添加一个任务
operationQueue.addOperation {
print("执行任务")
}
//子类化Opertion类的方式添加任务
let myOperation = MyOperation()
let hisOperation = HisOperation()
//设置依赖 myOperation依赖于hisOperation
//意思就是hisOperation执行完之后myOperation才会执行
myOperation.addDependency(hisOperation)
operationQueue.addOperation(myOperation)
operationQueue.addOperation(hisOperation)
这种方式很方便,但是可操作性没有GCD那么强大,有需要的同学可以自己去研究一下。正如本文的名字一样,这篇文章主要讨论的是下面的这种方式。
Grand Central Dispatch:
GCD是Apple提供的一套及其容易使用的多线程库。使用这种方式,开发者不用直接和线程打交道,也不需要直接管理线程的生命周期。我们只需要了解队列
的概念,并且把我们需要执行的任务放进队列
里面就好,GCD会自己决定是否创建新的线程,何时销毁线程,甚至他会合理的优化他所管理的线程池来配合CPU发挥更好的性能。
接下来整篇文章都是围绕GCD这个技术来阐述一些我自己的理解和使用心得。
队列
在GCD中,队列的概念就是装我们提交的任务的一个容器。我们要做的只是把任务提交到队列中,线程相关的事情让队列去做。
几种队列
GCD为我们提供了两种队列使用,也可以定义自己的队列:
//主队列
let mainQueue = DispatchQueue.main
//全局并行队列
let globalQueue = DispatchQueue.global()
//自定义串行队列,不设置attributes参数,默认是串行队列
let = DispatchQueue(label: "myQueue")
//自定义并行队列
let myConcurrentQueue = DispatchQueue(label: "myConcurrentQueue", attributes: .concurrent)
- 主队列中的任务只在主线程中执行,并且主队列是串行队列。
- 除主队列以外的其他队列根据CPU使用情况自动优化线程的创建和分配。
- 全局并行队列和自定义并行队列并没有太大区别,可以直接当做并行队列使用。
串行和并行
串行和并行的区别就是:
- 任务放进队列后,串行队列排队执行,前一个任务执行完毕才执行后一个。
- 并行队列是只要
执行任务需要的线程
空闲就马上执行,并不需要等待队列中正在执行的其他任务执行完毕。
异步提交多个任务到并行队列:
let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent)
for i in 0...5{
concurrentQueue.async {
sleep(1)
print("执行任务 \(i) 是否主线程 \(Thread.isMainThread)")
}
}
多执行几次,可以看到每次的输出都不同。
执行任务 3 是否主线程 false
执行任务 1 是否主线程 false
执行任务 4 是否主线程 false
执行任务 0 是否主线程 false
执行任务 2 是否主线程 false
执行任务 5 是否主线程 false
将上面的并行队列换成串行队列,你会发现,任务每次都是按照12345严格的顺序排列执行的。
还有另外一种特殊的情况就是同步提交到并行队列:
同步提交多个任务到并行队列:
for i in 0...5{
concurrentQueue.sync {
sleep(1)
print("执行任务 \(i) 是否主线程 \(Thread.isMainThread)")
}
}
不管执行多少次,可以看到输出都是一样的:
执行任务 0 是否主线程 true
执行任务 1 是否主线程 true
执行任务 2 是否主线程 true
执行任务 3 是否主线程 true
执行任务 4 是否主线程 true
执行任务 5 是否主线程 true
这是因为同步提交到非主队列的所有任务都会被放到提交它的线程去执行(参考后面同步和异步),而我们知道线程是一个接一个的执行任务的,所以在同一个线程同步提交多个任务到并发队列,实质上也是在这个线程串行执行的。
优先级
你也可以给在创建自己的队列的时候设置优先级:
//自定义优先级为userInitiated的串行队列
let myUserInitiatedQueue = DispatchQueue(label: "myUserInitiatedQueue", qos: .userInteractive)
优先级从低到高有以下几种:
case background
case utility
case `default`
case userInitiated
case userInteractive
case unspecified
- 高优先级队列中的任务会抢占低优先级队列的线程资源,优先执行。
- 优先级反转
- 如果有一个低优先级的任务正在执行,并且锁定了他操作的资源。
- 这时候一个同样需要这个资源的高优先级队列中的任务准备执行,那么高优先级的任务就不得不等待低优先级的任务对这个资源解除锁定。
- 这时候出现了一个中优先级队列中的任务准备执行,他并不需要这个被锁定的资源。
- 此时高优先级的任务正在等待,可执行的最高优先级的任务就是这个中优先级的任务。
- 结果可想而知,同样准备执行的情况下,中优先级的任务先于高优先级的任务被执行。
- 上面的叙述只是你给队列设置优先级之后可能出现的一种情况,事实上,滥用队列的优先级会让你的程序变得极其难调试,甚至是灾难。所以如果不是极其必要,建议只使用默认优先级即可。
任务分发和执行
同步和异步
理解了队列的概念,我们把任务放进队列又是如何实现的呢?dispatch
这个单词的意思是派遣、分发。所以GCD的主要功能就是分发任务到指定的队列上。分发有两种方式,同步和异步。
同步和异步的区别就是:
对于线程来讲
- 同步,线程告诉队列同步执行这个任务,直到这个任务执行完毕,同步操作都不会返回,这个线程不会做任何除这个任务之外的事情。
- 异步:线程告诉队列异步执行这个任务,不管这个任务执行的如何,异步操作立即返回,这个线程可以去做其他的事情。
对于队列来讲
- 同步:除主队列外的其他队列,调度提交任务的线程来执行被提交的任务。
- 异步:除主队列外的其他队列,调度其他线程来执行被提交任务。
- 因为主队列只能调度主线程,所以不管什么方式提交到主队列的任务,都会去主线程执行。
像下面这样同步分发一个任务到串行队列:
let serialQueue = DispatchQueue(label: "serialQueue")
serialQueue.sync {
print("同步提交到串行队列的任务开始执行")
print("是否在主线程执行:\(Thread.isMainThread)")
sleep(3)
}
print("提交到队列之后执行的任务")
可以看到输出是这样的:
同步提交到串行队列的任务开始执行
是否在主线程执行:true
提交到队列之后执行的任务
执行过程分析:
- 任务进入串行队列,队列中没有其他任务。
- 因为是同步,当前线程等待同步操作完成,队列调度当前线程执行任务。
- 执行完毕,同步操作返回。当前线程继续向后执行。
像下面这样异步分发一个任务到串行队列:
serialQueue.async {
print("异步提交到串行队列的任务开始执行")
print("是否在主线程执行:\(Thread.isMainThread)")
sleep(3)
}
print("提交到队列之后执行的任务")
输出是这样的:
提交到队列之后执行的任务
异步提交到串行队列的任务开始执行
是否在主线程执行:false
执行过程分析:
- 任务进入串行队列,队列中没有其他任务。
- 因为是异步,异步操作立即返回,当前线程继续向后执行。
- 队列调度其他线程执行提交的任务。
把上面的serialQueue
换成并行队列或者全局并行队列,用串行和并行的区别中所说的原理来分析,可以推断出结果是一样的。何不现在自己动手试一试?
如果你认真的阅读并且实践了上面的两个分析,相信你会对同步和异步有一个深刻的理解。值得注意的是,异步提交的任务并不总是开启新线程,如果当前的线程池中存在没有被销毁也没有正在被使用的线程,队列同样有可能拿来使用。
看了上文我们都知道主队列是只调度主线程的队列,如果把上面的serialQueue
换成主队列,又会发生什么呢?
死锁
你可能会写出类似这样的代码:
let serialQueue = DispatchQueue(label: "serialQueue")
serialQueue.sync {
print("同步到串行队列的任务执行 是否主线程 \(Thread.isMainThread)")
serialQueue.sync {
print("再次同步到串行队列的任务执行 是否主线程 \(Thread.isMainThread)")
}
}
程序在输出一句后崩溃:
同步到串行队列的任务执行 是否主线程 true
分析一下执行过程:
- 第一个任务提交到串行队列。队列中有一个任务。
- 第一个任务在当前线程执行,提交第二个任务到队列。此时队列中有两个任务。
- 因为是同步提交,此时第一个任务(也就是同步操作)要等待第二个任务执行完毕才能返回。
- 因为是串行队列,此时第二个任务要等待第一个任务出队列后才能执行。
- 这时状况变成了两个任务互相等待,这就是死锁。
解决这个问题有三个办法:
- 将串行队列换成并发队列,并发队列中后面的任务不必等待前面的任务执行完成。死锁不会发生。
- 将两次提交的任务提交到不同的队列(可以是并行或者串行),后一个任务不必等待前一个任务执行完成,死锁不会发生。
- 将其中一个或者是两个同步提交的任务换成异步方式。因为异步提交任务会立即返回。死锁不会发生。
上面的例子是比较容易理解的死锁,有一种情况是,你会向主队列中同步提交一个任务:
DispatchQueue.main.sync {
print("同步到主队列的任务执行 是否主线程 \(Thread.isMainThread)")
}
这段代码没有输出,程序直接崩溃。
事实上主队列是比较特殊的队列,主线程中所有的任务默认都是主队列中的任务。所以同步提交任务到主队列这件事本身也是一个任务。这样理解的话,同步提交主队列任务
和被提交任务
就变成了主队列中互相等待完成的两个个任务。和上面的原理一样,死锁发生了。
解决这个问题的办法也有三个:
- 主队列改为并行队列。
- 同步改为异步。
- 从其他线程中向主队列同步提交任务。
个人的建议是,尽量使用异步,因为只要你使用了同步,死锁随时都会发生。
GCD常用API
延时执行
你可以使用延时执行来让你的代码在未来的某个你期望的时间执行,像这样:
//延后的时间
let delayTime = DispatchTime.now() + DispatchTimeInterval.seconds(3)
//延时执行
DispatchQueue.main.asyncAfter(deadline: delayTime) {
print("延时执行 是否在主线程 \(Thread.isMainThread)")
}
输出:
延时执行 是否在主线程 true
你应该让你的代码时序保持清晰。除非及其必要的情况,否则慎用延时执行。当你用了许多延时执行,然后遇到调试起来异常困难的问题的时候,你就会知道,从逻辑上让代码在正确的时间执行的重要性。
迭代执行
如果你有一些需要重复执行的很耗时的任务,你可能需要一个for循环:
for index in 0...5 {
sleep(1)
print("执行\(index) 是否主线程 \(Thread.isMainThread)")
}
输出:
执行0 是否主线程 true
执行1 是否主线程 true
执行2 是否主线程 true
执行3 是否主线程 true
执行4 是否主线程 true
执行5 是否主线程 true
在循环执行完毕之前,应用界面会卡死,因为这一切是在主线程做的。
换成下面这样呢?
DispatchQueue.concurrentPerform(iterations: 5) { index in
sleep(1)
print("执行\(index) 是否主线程 \(Thread.isMainThread)")
}
print("迭代完毕")
输出:
执行0 是否主线程 true
执行3 是否主线程 false
执行1 是否主线程 false
执行2 是否主线程 false
执行4 是否主线程 true
迭代完毕
性能提升了不少,至少不是每次循环都在主线程执行了。
但是只要有在主线程执行的耗时任务,界面依然会卡死。
其实我们可以把这个迭代任务异步提交到并发队列:
DispatchQueue.global().async {
DispatchQueue.concurrentPerform(iterations: 5) { index in
sleep(1)
print("执行\(index) 是否主线程 \(Thread.isMainThread)")
}
print("迭代完毕")
}
输出:
执行3 是否主线程 false
执行1 是否主线程 false
执行0 是否主线程 false
执行2 是否主线程 false
执行4 是否主线程 false
迭代完毕
这样主线程不会被阻塞,同样我们迭代的性能也提高了,两全其美。
另外一点,细心的你可能注意到了,就是我们的迭代完毕
输出总是在最后面。这是因为DispatchQueue.concurrentPerform
是模拟同步执行的,虽然内部开启了不同的线程,但是要等到所有线程中的任务执行完毕后才继续往下执行。
这里面其实用到了我们下面要说的组
的方式。
组
实际开发中经常会遇到这样的需求:有几个很耗时的任务需要放到子线程去执行,哪个先执行完并不确定。后面的任务需要这几个任务的返回结果才能继续执行。这种情况该怎么办呢。
你可能想这样做:
DispatchQueue.global().async {
print("任务1")
sleep(1)
print("任务2")
sleep(2)
print("任务3")
sleep(3)
//回到主线程
DispatchQueue.main.async {
print("依赖前三个任务的任务")
}
}
这样做的确能完成我们的需求,并且不会阻塞主线程。但是这样我们就需要三个耗时任务的时间之和这么久之后才能执行依赖他们的返回结果的任务。这显然不是我们想要的性能。
可不可以把这三个任务放到三个不同的线程中去,并且等最后一个任务执行完毕再通知我执行依赖他们返回结果的任务呢?答案是当然可以:
//创建一个组
let group = DispatchGroup()
//在组中执行任务
DispatchQueue.global().async(group: group) {
print("任务1")
sleep(1)
}
DispatchQueue.global().async(group: group) {
print("任务2")
sleep(2)
}
DispatchQueue.global().async(group: group) {
print("任务3")
sleep(3)
}
//执行完毕,回到主线程
group.notify(queue: DispatchQueue.main) {
print("依赖前三个任务的任务")
}
输出为:
任务1 {number = 3, name = (null)}
任务3 {number = 4, name = (null)}
任务2 {number = 5, name = (null)}
依赖前三个任务的任务
可以看到,三个任务在不同的线程执行,我们只需要在三个任务中耗时最长的一个的时间之后执行依赖它们的任务。性能是不是提高了很多呢?
信号量
信号量相比较上面的组
方式用起来更加灵活。我理解的信号量是这样一种存在:
- 初始化一个信号量,并且给他一个初始值。
- 调用信号量的
wait
方法:- 如果此时信号量为零,那么当前线程阻塞,直到信号量不为零,或者消耗完你设置的等待时间,线程才会继续执行。
- 如果此时信号量不为零,线程继续执行,并且信号量减一。
- 调用
signal
方法信号量加一。
举一个简单的例子,看过了前面,我们都知道像这样去其他线程执行任务:
DispatchQueue.global().async {
print("第一个任务执行")
sleep(1)
}
print("其他任务执行")
输出是这样的:
其他任务执行
第一个任务执行
我们如何用信号量来控制,第一个任务执行完毕后再执行其他的任务呢?
//初始化初始值为0的信号量
let semaphore = DispatchSemaphore(value: 0)
//异步方式去子线程执行任务
DispatchQueue.global().async {
print("第一个任务执行")
sleep(1)
//任务执行完毕,信号量加一
//此时主线程观察到信号量大于零,后面的代码继续执行
semaphore.signal()
}
//当前信号量为0,线程阻塞,wait不传timeout参数代表永远等待
semaphore.wait()
print("其他任务执行")
输出:
第一个任务执行
其他任务执行
这下明白信号量的工作机制了吧?可能你会说,然并卵,我可以直接在主线程顺序执行,何必多此一举呢。当然这只是为了简单起见,举的一个容易理解的例子。信号量可以做的事情当然远不止于此。
再举一个例子,大家都知道OperationQueue
可以通过设置属性maxConcurrentOperationCount
来控制最大允许并发数量。那么如果使用GCD如何做到这一点呢?
像这样并发执行12个任务:
for index in 1...12 {
DispatchQueue.global().async {
sleep(1)
print("执行任务\(index)")
}
}
可以看到每次的输出的顺序都不一样:
执行任务10
执行任务12
执行任务1
执行任务9
执行任务2
执行任务8
执行任务7
执行任务6
执行任务3
执行任务5
执行任务11
执行任务4
这是因为,最大的并发数量是由GCD根据当前的资源利用情况优化配置的,如果我们想自己控制,就需要使用信号量,上面的程序稍加改动:
//初始化初始值为3的信号量, 即允许最大并发数为3
let semaphore = DispatchSemaphore(value: 3)
for index in 1...12 {
DispatchQueue.global().async {
//判断当前的信号量,为0就阻塞,否则信号量减1
semaphore.wait()
//执行任务
sleep(1)
print("执行任务\(index)")
//任务执行完毕信号量加1
semaphore.signal()
}
}
可以看到输出是三个三个一组的:
执行任务2
执行任务1
执行任务3
执行任务5
执行任务4
执行任务6
执行任务7
执行任务8
执行任务9
执行任务12
执行任务10
执行任务11
每次执行的时候信号量都减1,直到信号量为0,这时候后面的任务就必须等待前面三个任务中某一个执行完毕信号量大于0时才能继续执行。这样就实现了保证同一时刻最多只有三个任务并行执行的目的。
理解了信号量的机制,发挥你的想象力,真的可以解决实际开发中的很多问题。另外有一点需要注意,调用了semaphore.wait()
之后必须调用semaphore.signal()
让信号量回到初始值。否则在信号量的生命周期结束时,信号量不是初始状态会影响它的释放,这会导致一个运行时错误。
多个线程改变同一个资源
访问并改变一个资源的过程是这样的:
- 从内存中copy一份资源
- 改变它的值。
- 写入并覆盖内存。
想象一下,如果我们有多个线程访问同一个资源,并且都要改变这个值。假设这个资源是一个Int。
- A线程copy了这个资源,准备改变,给资源加1。
- 这时候B线程也来访问这个资源,注意:此时A还没来得及将改变后的值覆盖原有内存。
- 这时候B线程copy的资源时还没被A改变过的资源。B也给资源加1。
- A改变后的资源覆盖内存。
- B改变后的资源覆盖内存。
- 此时我们期望的资源应该是比原来的值多2,但是按照上面的逻辑,资源只被加1了一次。
这显然不是我们希望看到的。
不用锁的情况下,你可以用GCD的barrier
机制来改善这个状况。
像这样:
let barrierTask = DispatchWorkItem(flags: .barrier) {
//修改资源
}
DispatchQueue.global().async(execute: barrierTask)
被barrier
标记的任务进入队列后:
- 会等待队列中之前所有的任务执行完成才会执行。
-
barrier
任务之后进入队列的任务会等待barrier
任务执行完毕后执行。
后记
仓促写成,难免会有理解不到位的地方。希望看到这篇文章的人能够不吝指出,不胜感激。