GCD导致死锁的原因和解决方案
所谓死锁,通常指有两个线程A和B都卡住了,并等待对方完成某些操作。A不能完成是因为它在等待B完成。但B也不能完成,因为它在等待A完成。于是大家都完不成,就导致了死锁(DeadLock)。
在使用GCD的时候,我们会把需要处理的任务放到Block中,然后将任务追加到相应的队列里面,这个队列,叫做Dispatch Queue。然而,存在于两种Dispatch
Queue,一种是要等待上一个执行完,再执行下一个的Serial Dispatch Queue,这叫做串行队列;另一种,则是不需要上一个执行完,就能执行下一个的Concurrent Dispatch Queue,叫做并行队列。这两种,均遵循FIFO原则。
串行与并行针对的是队列,而同步与异步,针对的则是线程。最大的区别在于,同步线程要阻塞当前线程,必须要等待同步线程中的任务执行完,返回以后,才能继续执行下一任务;而异步线程则是不用等待。
案例一:
Objective-C
1 2 3 4 5 6 |
NSLog(@"1");// dispatch_sync(dispatch_get_main_queue(),^{ NSLog(@"2");// }); NSLog(@"3");// |
结果,控制台输出:
Objective-C
1 2 |
1 |
分析:
- dispatch_sync表示是一个同步线程;
- dispatch_get_main_queue表示运行在主线程中的主队列;
- 任务2是同步线程的任务。
首先执行任务1,这是肯定没问题的,只是接下来,程序遇到了同步线程,那么它会进入等待,等待任务2执行完,然后执行任务3。但这是队列,有任务来,当然会将任务加到队尾,然后遵循FIFO原则执行任务。那么,现在任务2就会被加到最后,任务3排在了任务2前面,问题来了:
任务3要等任务2执行完才能执行,任务2由排在任务3后面,意味着任务2要在任务3执行完才能执行,所以他们进入了互相等待的局面。【既然这样,那干脆就卡在这里吧】这就是死锁。
案例二:
Objective-C
1 2 3 4 5 6 |
NSLog(@"1");// dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH,0),^{ NSLog(@"2");// }); NSLog(@"3");// |
结果,控制台输出:
Objective-C
1 2 3 4 |
1 2 3 |
分析:
首先执行任务1,接下来会遇到一个同步线程,程序会进入等待。等待任务2执行完成以后,才能继续执行任务3。从dispatch_get_global_queue可以看出,任务2被加入到了全局的并行队列中,当并行队列执行完任务2以后,返回到主队列,继续执行任务3。
案例三:
Objective-C
1 2 3 4 5 6 7 8 9 10 11 |
dispatch_queue_tqueue=dispatch_queue_create("com.demo.serialQueue",DISPATCH_QUEUE_SERIAL); NSLog(@"1");// dispatch_async(queue,^{ NSLog(@"2");// dispatch_sync(queue,^{ NSLog(@"3");// }); NSLog(@"4");// }); NSLog(@"5");// |
结果,控制台输出:
Objective-C
1 2 3 4 5 |
1 5 2 // |
分析:
这个案例没有使用系统提供的串行或并行队列,而是自己通过dispatch_queue_create函数创建了一个DISPATCH_QUEUE_SERIAL的串行队列。
- 执行任务1;
- 遇到异步线程,将【任务2、同步线程、任务4】加入串行队列中。因为是异步线程,所以在主线程中的任务5不必等待异步线程中的所有任务完成;
- 因为任务5不必等待,所以2和5的输出顺序不能确定;
- 任务2执行完以后,遇到同步线程,这时,将任务3加入串行队列;
- 又因为任务4比任务3早加入串行队列,所以,任务3要等待任务4完成以后,才能执行。但是任务3所在的同步线程会阻塞,所以任务4必须等任务3执行完以后再执行。这就又陷入了无限的等待中,造成死锁。
案例四:
Objective-C
1 2 3 4 5 6 7 8 9 10 |
NSLog(@"1");// dispatch_async(dispatch_get_global_queue(0,0),^{ NSLog(@"2");// dispatch_sync(dispatch_get_main_queue(),^{ NSLog(@"3");// }); NSLog(@"4");// }); NSLog(@"5");// |
结果,控制台输出:
Objective-C
1 2 3 4 5 6 7 |
1 2 5 3 4 // |
分析:
首先,将【任务1、异步线程、任务5】加入Main Queue中,异步线程中的任务是:【任务2、同步线程、任务4】。
所以,先执行任务1,然后将异步线程中的任务加入到Global Queue中,因为异步线程,所以任务5不用等待,结果就是2和5的输出顺序不一定。
然后再看异步线程中的任务执行顺序。任务2执行完以后,遇到同步线程。将同步线程中的任务加入到Main Queue中,这时加入的任务3在任务5的后面。
当任务3执行完以后,没有了阻塞,程序继续执行任务4。
从以上的分析来看,得到的几个结果:1最先执行;2和5顺序不一定;4一定在3后面。
案例五:
Objective-C
1 2 3 4 5 6 7 8 9 10 11 12 |
dispatch_async(dispatch_get_global_queue(0,0),^{ NSLog(@"1");// dispatch_sync(dispatch_get_main_queue(),^{ NSLog(@"2");// }); NSLog(@"3");// }); NSLog(@"4");// while(1){ } NSLog(@"5");// |
Objective-C
1 |
结果,控制台输出: |
Objective-C
1 2 3 4 |
1 4 // |
分析:
和上面几个案例的分析类似,先来看看都有哪些任务加入了Main Queue:【异步线程、任务4、死循环、任务5】。
在加入到Global Queue异步线程中的任务有:【任务1、同步线程、任务3】。
第一个就是异步线程,任务4不用等待,所以结果任务1和任务4顺序不一定。
任务4完成后,程序进入死循环,Main Queue阻塞。但是加入到Global Queue的异步线程不受影响,继续执行任务1后面的同步线程。
同步线程中,将任务2加入到了主线程,并且,任务3等待任务2完成以后才能执行。这时的主线程,已经被死循环阻塞了。所以任务2无法执行,当然任务3也无法执行,在死循环后的任务5也不会执行。
最终,只能得到1和4顺序不定的结果。
有一定GCD使用经验的新手通常认为,死锁是很高端的操作系统层面的问题,离我很远,一般不会遇上。其实这种想法是非常错误的,因为只要简单三行代码(如果愿意,甚至写在一行就可以)就可以人为创造出死锁的情况。
int main(int argc, const char * argv[]) {
@autoreleasepool {
dispatch_sync(dispatch_get_main_queue(), ^(void){
NSLog(@"这里死锁了");
});
}
return 0;
}
比如这个最简单的OC命令行程序就会导致死锁,运行后不会看到任何结果。
在解释为什么会死锁之前,首先明确一下“同步&异步”“串行&并发”这两组基本概念:
同步执行:比如这里的dispatch_sync,这个函数会把一个block加入到指定的队列中,而且会一直等到执行完blcok,这个函数才返回。因此在block执行完之前,调用dispatch_sync方法的线程是阻塞的。
与之对应的就有“异步执行”的概念:
异步执行:一般使用dispatch_async,这个函数也会把一个block加入到指定的队列中,但是和同步执行不同的是,这个函数把block加入队列后不等block的执行就立刻返回了。
接下来看一看另一组相对的概念:“串行&并发”
串行队列:比如这里的dispatch_get_main_queue。这个队列中所有任务,一定按照先来后到的顺序执行。不仅如此,还可以保证在执行某个任务时,在它前面进入队列的所有任务肯定执行完了。对于每一个不同的串行队列,系统会为这个队列建立唯一的线程来执行代码。
与之相对的是并发队列:
并发队列:比如使用dispatch_get_global_queue。这个队列中的任务也是按照先来后到的顺序开始执行,注意是开始,但是它们的执行结束时间是不确定的,取决于每个任务的耗时。对于n个并发队列,GCD不会创建对应的n个线程而是进行适当的优化
我们把整个dispatch_sync看作是一个任务,比如说是非常关键、需要高度集中注意力的运钞过程。这个过程非常重要,一旦开始执行就必须一气呵成,任何事情都不能干扰这个过程(阻塞线程)。
现在主线程开始执行这个运钞任务,任务执行到一半时,突然运钞员说我好累啊,辛苦了好久了,我现在需要休息(向主线程添加了block)。运钞员天真的认为,我知道运钞这个事很重要,本来应该等到运钞结束后再休息(这样是串行)。但是在这之前,我的身体条件不允许工作。
但是之前已经说了,运钞这件事很重要,它一旦开始就不能结束(阻塞线程)。怎么能允许有人中途休息呢,因此要休息可以(block是可以执行的),先把钞票运到安全地方再休息。
对应到代码里面来,当我们想要同步执行这个block的时候,其实是告诉主线程,你把事情处理完了,就过来处理我这个blcok,在此之前我一直等你。而主线程呢,刚处理dispatch_sync函数到一半呢,这个函数还没返回,哪里有空去执行block。因此这段代码运行后,并非卡在block中无法返回,而是根本无法执行到这个block。
好了,总结一下,到底什么是死锁。首先,虽然刚刚我们提到了队列和线程,以及它们之间的对应关系,但是死锁一定是针对线程而言的,队列只是GCD给出的抽象数据结构。所谓的死锁,一定是发生在一个或多个线程之间的。那么死锁和线程阻塞的关系呢,可以这么理解,双向的阻塞导致了死锁。因为阻塞是线程中经常发生的事情,最多就是主线程的阻塞影响了用户体验。而一旦出现了双向的阻塞,就导致了死锁。我们可以看到,主线程是串行的,在执行某一个任务的时候线程被阻塞了,而这个任务(dispatch_sync)在执行时,又要求阻塞主线程,从而导致了互相的阻塞,也就是死锁。
接下来我们思考一下,什么情况下会导致死锁。这个问题可能一下子难以得出准确的回答,为了解决这个问题,我打算使用排除法。即先看看什么情况下不会发生死锁。比如说,异步执行block肯定不会发生死锁。比如刚刚的代码改成这样:
dispatch_async(dispatch_get_global_queue(0,0), ^(void){
NSLog(@"这就不死锁了");
});
甚至可以总结出来:异步执行一定不会导致死锁。因为回顾一下之前导致的死锁的原因,很重要的一点是主线程在执行dispatch_sync,这是个同步方法,block执行完之前都不会返回。而既然是异步的执行,那么是立刻返回的,因此不会阻塞主线程。双向的阻塞不成立了,只是主线程处理blcok时阻塞,但这不会引起死锁。
根据之前我们的分析和总结,GCD中我们需要关心的就是同步还是异步执行,以及把block添加到哪个队列中(串行还是并发)。
所以接下来就只需要重点思考一下,在同步执行时,什么时候会导致死锁。可以再得出一个结论,向并发队列中添加block不会导致死锁。再次回顾一下之前导致的死锁的原因,由于在串行队列中添加了block,block一直等到前面的任务处理完才会执行,从而导致了死锁。现在即使是同步的向并发队列中添加block,GCD会自动为我们管理线程,主线程目前阻塞着(处理这个同步方法),那就新建一个新的线程,但无论如何这个被添加block迟早都会被执行。而所有添加的block被执行完后,同步方法也就返回了。因此不会导致死锁。
最后再来讨论一下用同步方法向串行队列添加block的情况,这种情况下会不会造成死锁呢,答案是不一定。事实上,导致死锁的原因一定是:
在某一个串行队列中,同步的向这个队列添加block。
比如文章开头的例子就属于这种情况。如果同步的向另外一个串行队列添加方法,并不一定导致死锁。比如:
dispatch_queue_t queue = dispatch_queue_create("serial", nil);
dispatch_sync(queue, ^(void){
NSLog(@"这个也不会死锁");
});
分析一下代码,向名为serial的串行队列添加任务后,GCD自动创建了一个新的线程,在这个线程中执行block方法。在这个过程中,主线程和新的线程都是阻塞的,但是并不会导致死锁。
为什么说向另一个串行队列添加任务不一定导致死锁呢,因为队列是可以嵌套的,比如在A队列(串行)添加一个任务a,在a这个任务中向B队列(串行)添加任务b,在b这个任务中又向A队列添加任务,这就间接满足了“在某一个串行队列中,同步的向这个队列添加block”。但是我们好像每一次都没有直接向相同的队列中添加block。
所以判断是否发生死锁的最好方法就是看有没有在串行队列(当然也包括主队列)中向这个队列添加任务。又因为我们知道每个串行队列对应一个线程,所以只要不在某个线程中调用会阻塞这个线程的方法即可。
事实上,我们使用同步的方法编程,往往是要求保证任务之间的执行顺序是完全确定的。且不说GCD提供了很多强大的功能来满足这个需求,向串行队列中同步的添加任务本身就是不合理的,毕竟队列已经是串行的了,直接异步添加就可以了啊。所以,解决文章开头那个死锁例子的最简单的方法就是在合适的位置添加一个字母a。