该 bug 表现为:测试环境下,启动过程中 App 卡死,不响应任何操作;真机环境下,启动后直接crash。重现 bug 后暂停程序并打印堆栈信息,符号化后的堆栈信息涉及到公司内部代码 这里就不全贴出来了,栈顶的三条记录如下:
1 | * thread |
程序最终停留在 semaphore_wait_trap
,应该是信号量等待导致了死锁,并且调用栈的信息中频繁出现 dispatch_once_f
、 _dispatch_once [inlined]
等关键字,猜测应该是 dispatch_once
的并发执行过程中出现了循环等待进而导致死锁。
有关 dispatch_once
导致的死锁问题,网上大多数文章介绍的情形是递归调用,比如:
1 | @implementation TestObjectA |
上述程序运行后必然会出现死锁,而且堆栈信息与该bug的堆栈类似:
1 | * thread |
但是,我翻遍了与crash堆栈信息有关的所有代码,没有发现递归调用的情形,那么问题出在哪里呢?
死锁分析
前文对 dispatch_once
的原理进行过分析,dispatch_once
的执行中可能遇到以下三种情形:
- 第一次执行,执行block,执行完成后置predicate标记
- 非第一次执行,而步骤1尚未执行完毕,此时线程需要等待步骤1完成,步骤1完成后依次唤醒等待的线程
- 非第一次执行,且步骤1已经执行完成,线程跳过block继续执行后续任务
从这三种情形来看,只有情形2出现了线程等待,也只有这里才有可能导致死锁。
继续分析代码,发现了如下调用逻辑:
1 | 子线程(负责网络请求):sharedInstance -> dispatch_once -> init -> dispatch_sync(dispatch_get_main_queue(), ... ) |
看到这里,死锁的原因已经很清晰了:
首先,子线程先调用 sharedInstance
首先进入 dispatch_once
,然后调用某个类的 init
方法,而在该类的 init
方法中,由于业务需要,某些初始化操作必须放到主线程中同步执行(init
代码中会判断当前线程是否主线程,如果不是,就使用 dispatch_sync
将操作同步到主线程中执行)。
如果子线程 dispatch_once
的block任务未执行完毕时,主线程进入了 dispatch_once
,由于子线程阻塞地向主线程中提交任务,而此时主线程又在等待子线程的 dispatch_once
任务完成,如此一来就出现了循环等待情形,两个线程的任务都无法结束,信号量无法释放,出现死锁。
死锁原因找到了,那么解决办法也很简单:
- 将
dispatch_sync
改为dispatch_async
- 将网络请求也放到主线程中执行(根据代码逻辑,只有在子线程中才会调用
dispatch_sync
) - 将 Main Thread 的
sharedInstance
调用时机提前,放到子线程相应sharedInstance
的调用之前,确保在主线程中调用init
,避免调用dispatch_sync
。
由于 init
方法属于大众点评定位框架 Meridian
,我们使用pod引入,直接修改为 dispatch_async
并不是一个好办法;而且,如果把网络请求和数据处理都放到主线程中执行,势必会加重主线程的负担,可能导致卡顿等问题;因此,我们最终选择了方法3,修改之后经过测试验证,死锁情况没有再出现。
总结
引起死锁的原因有很多,但是只要围绕死锁产生的四个条件:互斥、请求与保持、不剥夺、循环等待 进行分析,找出根本原因就很简单了,而且,在写代码的时候也要牢记上述四个条件,最大可能地避免、预防和解除死锁。