记一次crash排查—dispatch_once引起的死锁

该 bug 表现为:测试环境下,启动过程中 App 卡死,不响应任何操作;真机环境下,启动后直接crash。重现 bug 后暂停程序并打印堆栈信息,符号化后的堆栈信息涉及到公司内部代码 这里就不全贴出来了,栈顶的三条记录如下:

1
2
3
4
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
frame #0: 0x000000010db481b6 libsystem_kernel.dylib`semaphore_wait_trap + 10
frame #1: 0x000000010d80367f libdispatch.dylib`_dispatch_thread_event_wait_slow + 16
frame #2: 0x000000010d7e98cc libdispatch.dylib`dispatch_once_f + 290

程序最终停留在 semaphore_wait_trap,应该是信号量等待导致了死锁,并且调用栈的信息中频繁出现 dispatch_once_f_dispatch_once [inlined]等关键字,猜测应该是 dispatch_once 的并发执行过程中出现了循环等待进而导致死锁。

有关 dispatch_once 导致的死锁问题,网上大多数文章介绍的情形是递归调用,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@implementation TestObjectA
+ (TestObjectA *)sharedInstance
{
static TestObjectA *manager = nil;
static dispatch_once_t token;
dispatch_once(&token, ^{
manager = [[TestObjectA alloc] init];
});
return manager;
}

- (instancetype)init
{
self = [super init];
if (self) {
[TestObjectB sharedInstance];
}
return self;
}
@end

@implementation TestObjectB
+ (TestObjectB *)sharedInstance
{
static TestObjectB *manager = nil;
static dispatch_once_t token;
dispatch_once(&token, ^{
manager = [[TestObjectB alloc] init];
});
return manager;
}

- (instancetype)init
{
self = [super init];
if (self) {
[TestObjectA sharedInstance];
}
return self;
}
@end

上述程序运行后必然会出现死锁,而且堆栈信息与该bug的堆栈类似:

1
2
3
4
5
6
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)
frame #0: libdispatch.dylib`_dispatch_once_wait + 101
* frame #1: Test`+[TestObjectA sharedInstance] [inlined]
frame #2: Test`+[TestObjectA sharedInstance](self=TestObjectA, _cmd="sharedInstance") at ViewController.m:104
frame #3: Test`-[TestObjectB init](self=0x000060000146ca20, _cmd="init") at ViewController.m:140
...

但是,我翻遍了与crash堆栈信息有关的所有代码,没有发现递归调用的情形,那么问题出在哪里呢?

死锁分析

前文对 dispatch_once 的原理进行过分析,dispatch_once 的执行中可能遇到以下三种情形:

  1. 第一次执行,执行block,执行完成后置predicate标记
  2. 非第一次执行,而步骤1尚未执行完毕,此时线程需要等待步骤1完成,步骤1完成后依次唤醒等待的线程
  3. 非第一次执行,且步骤1已经执行完成,线程跳过block继续执行后续任务

从这三种情形来看,只有情形2出现了线程等待,也只有这里才有可能导致死锁。
继续分析代码,发现了如下调用逻辑:

1
2
3
子线程(负责网络请求):sharedInstance -> dispatch_once -> init -> dispatch_sync(dispatch_get_main_queue(), ... )

Main Thread: sharedInstance -> dispatch_once

看到这里,死锁的原因已经很清晰了:
首先,子线程先调用 sharedInstance 首先进入 dispatch_once ,然后调用某个类的 init 方法,而在该类的 init 方法中,由于业务需要,某些初始化操作必须放到主线程中同步执行(init代码中会判断当前线程是否主线程,如果不是,就使用 dispatch_sync 将操作同步到主线程中执行)。
如果子线程 dispatch_once 的block任务未执行完毕时,主线程进入了 dispatch_once ,由于子线程阻塞地向主线程中提交任务,而此时主线程又在等待子线程的 dispatch_once 任务完成,如此一来就出现了循环等待情形,两个线程的任务都无法结束,信号量无法释放,出现死锁。

死锁原因找到了,那么解决办法也很简单:

  1. dispatch_sync 改为 dispatch_async
  2. 将网络请求也放到主线程中执行(根据代码逻辑,只有在子线程中才会调用 dispatch_sync
  3. 将 Main Thread 的 sharedInstance 调用时机提前,放到子线程相应 sharedInstance 的调用之前,确保在主线程中调用 init,避免调用 dispatch_sync

由于 init 方法属于大众点评定位框架 Meridian,我们使用pod引入,直接修改为 dispatch_async 并不是一个好办法;而且,如果把网络请求和数据处理都放到主线程中执行,势必会加重主线程的负担,可能导致卡顿等问题;因此,我们最终选择了方法3,修改之后经过测试验证,死锁情况没有再出现。

总结

引起死锁的原因有很多,但是只要围绕死锁产生的四个条件:互斥、请求与保持、不剥夺、循环等待 进行分析,找出根本原因就很简单了,而且,在写代码的时候也要牢记上述四个条件,最大可能地避免、预防和解除死锁。