字节跳动在 如何系统性治理 iOS 稳定性问题 中关于如何归因 Watchdog 问题时提到了死锁检测能力,当时覆盖了部分锁:
具体实现,可以参考文章 iOS 写一个死锁检测。
字节跳动的方案原理可以简单概括为:通过寄存器拿到持有锁的线程信息,获取完整的线程锁等待关系,构建锁等待关系图,进而识别出死锁线程。但对于 semaphore 并不适用,因为 semaphore 没有记录持有锁的线程信息。
本文探讨如何检测出 semaphore 的死锁问题。
Semaphore Demo
以 GCD 为例,编写以下示例代码:
- (void)viewDidLoad
{
[super viewDidLoad];
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
NSLog(@"%p", semaphore);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(5);
dispatch_semaphore_signal(semaphore);
});
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"end");
// Do any additional setup after loading the view.
}
主线程的 end 输出需要等待子线程执行 5s。如果子线程在执行耗时任务,但得不到充分的 CPU 资源执行任务,则主线程很容易卡死。
那么如何检测出导致主线程卡死的线程?
原理
虽然 semaphore 并没有记录持有锁线程的信息,但是 wait 线程以及 signal 线程都会持有 semaphore 实例,所以只需要获取 wait 的 semaphore 实例,同时遍历其它线程判断该线程是否也持有相同 semaphore 实例,持有相同 semaphore 实例的其中一个或多个线程即是导致卡死的线程。(可能 signal 线程还未执行,那么暂时不存在导致卡死的线程)
当子线程处于 sleep 时,通过 LLDB 断点调试可以发现,主线程正在执行 semaphore_wait_trap 函数,这个函数会触发一个软件中断,将程序从用户模式切换到内核模式,执行 0x80 系统调用,使主线程 Block。
通过 register read 读取寄存器信息,可以发现 x20 寄存器中存储的值与 semaphore 实例地址相同。
x20 寄存器是 Callee-saved Registers,Callee-saved Registers 含义是子函数需要使用 Callee-saved Registers 时,需要先将它们的值保存到栈上,在返回前将它们的值从栈上恢复。
可以通过主线程 x20 寄存器的值获取 semaphore 实例地址。
接着切换到子线程:
但是子线程的寄存器中并没有发现与 semaphore 实例地址相同的值。
Block
在 Block 内部使用的对象都会被 Block 持有,所以从查找 semaphore 实例可以转换成查找 Block 实例。 查看函数的调用堆栈可以发现,在 _dispatch_call_block_and_release 函数中,最终会调用 _Block_release 函数将 Block 释放掉:
也就是说在函数调用过程中,Block 会一直存在。在函数调用过程中,Callee-saved Registers 以及 Caller-saved Registers 可能会被修改,在修改之前通常都会将旧值保存到栈上。所以如果寄存器中获取不到,可以从栈空间中查找存储的内容。
通过 SP 寄存器可以获取栈顶地址,因为栈向下生长。所以可以通过每个函数头的 sub 指令获取栈生长的深度。
sub 指令
这里介绍下如何获取 sub 指令的立即数,以 sub sp, sp, #0x40 为例,0x40 就是立即数,表示 SP 值减 0x40 ,也就是栈空间向下生长 64 个字节,这条指令对应的内容为:
因为 iOS 为小端序,所以对应 16 进制为 0xd10103ff,转换成二进制表示为:
1101 0001 0000 0001 0000 0011 1111 1111
查看 sub 指令官方文档说明:
imm12 表示 12位立即数。
Rd 或 Rn 为 11111 则代表 SP 寄存器。
64 位下 sf = 1,所以高 8 位为 1101 0001,紧接着是 0 sh,然后是 imm12,所以立即数为 00 0001 0000 00,即为 16 进制的 0x40。
总结:高 8 位 1101 0001 表示 64 位下 sub 指令,低 10 位 11 1111 1111 表示源寄存器和目标寄存器是 SP 寄存器,11 - 22 位表示立即数。
获取到栈空间后,可以按 8 字节大小(指针大小)遍历,Block 的数据结构大致如下:
struct BlockLiteral {
void *isa;
int flags;
int reserved;
void (*invoke)(void *, ...);
struct BlockDescriptor *descriptor;
// imported variables
};
Block 前 8 字节为 isa 指针,当读取前 8 字节内容为 __NSMallocBlock__(可以预先获取 Block isa 地址,通过地址判断是否是 Block),则表示这是一个 Block 结构体:
Block 捕获的对象存储在距离首地址 32 个字节位置(当捕获多个对象时,会依次排列,Strong 引用排在 Weak 引用之前),这里可以看到 $1[4] 就是需要查找的 semaphore 对象:
如果未能查找到 smephore 对象,当遍历内容为 0x0 时,则可以跳出本次栈空间地址查找,继续遍历下一个地址直到栈空间查找结束: