问题背景
近期我们项目在自动化测试中发生一起奇怪的冻屏现象,它的直接原因是启动某个应用程序时,system_server 进程的 ActivityManager:procStart 线程卡在函数 attemptUsapSendArgsAndGetResult 上等待 socket 数据返回,然而一直没有数据返回。
1 | php复制代码"ActivityManager:procStart" prio=5 tid=33 Native |
对此问题,我们需要知道此时 system_server 等待的 socket 服务端进程是谁,此时服务端进程又在干吗,熟悉此部分代码,从堆栈上看可以知道等待的服务端进程是 usap64,但实际上后台有多个 usap64 进程,一个个排查当然也是可行的,这里我还是采用内存分析的方式来锁定。将 system_server 内存转储成 Coredump 文件。
客户端解析
1 | less复制代码art-parser> bt 1882 |
方法一 | 从寄存器上看很容易知道 sock fd = 0x2c4 = 708 |
方法二 | cat /proc/1745/task/1882/syscall系统调用ID 参数0 ~ 参数7212 0x2c4 0x7ca6d66650 0x40004028 0x7ca6d66580 0x7ca6d66600 0x4 0x7ca6d66450 0x7dec54b8e8 |
从调用栈上看,LocalSocket 创建的 socket 使用的是 AF_UNIX 协议族,因此,我们可以通过 ino 号查询该 socket 状态。
1 | arduino复制代码typedef enum { |
1 | shell复制代码# ls -lh /proc/1745/fd | grep 708 |
从 socket 状态上看,已经连上服务端,等待服务端返回数据,我们先查看这个 attemptUsapSendArgsAndGetResult 函数的参数内容。
1 | ini复制代码art-parser> disassemble 0x70981df0 -i 0x7d64a53850 |
1 | sql复制代码art-parser> p 0x13a81aa8 |
1 | ini复制代码art-parser> p 0x13a81a80 |
1 | bash复制代码# netstat -a -p | grep usap_pool_primary |
至此,我们可以得出结论是,system_server 在启动应用程序 com.android.statementservice 时发生,并且等待服务端进程 3690/usap64 完成并返回结果集。
服务端解析
1 | less复制代码# debuggerd -b 3690 |
1 | yaml复制代码# ps -eT 3690 |
从现场通过 debug 手段,确定进程 3690 有且仅有一个线程,并且处于一个等锁的状态,这个锁永远也不可能主动释放了,因此卡死了客户端出现冻屏,我们分析下锁的 owner 是谁,仍然是将进程 3690 内存转储成 Coredump 文件来进行内存分析。
1 | rust复制代码(gdb) bt |
1 | ini复制代码(gdb) p *('art::Mutex' *)0xb400007d67e22c90 |
奇怪的事情发生了,线程 3657 到底是谁,即使 ps -eT | grep 3657 搜遍了所有线程号,终究无法得到,因此只有一个原因是该线程已经退出了。
疑问一
为什么3657已退出,锁却没有被释放?\color{red}{为什么 3657 已退出,锁却没有被释放?}为什么3657已退出,锁却没有被释放?
其实这个多线程 Fork 后,子进程发生死锁现象,在 Linux 编写程序发生这种太常见了,由于 Fork 的写时复制机制,因此存在父进程的一个锁变量状态处于上锁状态,此时发生了 Fork,父子进程共享了同一个虚拟内存地址空间,此时子进程在使用该锁,则会出现死锁现象。
数据恢复
基于前面的疑问,我们可以知道线程 3657 获得锁之后,发生了 Fork,因此 3657 的线程栈也应该在进程 3690 的地址空间下。
1 | shell复制代码# cat /proc/3690/maps | grep 3657 |
前面我们保存了进程 3690 的 Coredump 文件,并且它是 Zygote 进程 Fork 而来,共享了 Zygote 复制前的内存,因此我们查看此时被虚拟机管理的线程还有谁。
3690 数据解析
1 | ini复制代码art-parser> thread |
父进程 952 与子进程 3690,在写分离之前,它们是共享地址空间,也共享同一个栈地址,因此我们可以修改一些假数据,让 3690 添加到虚拟机管理下,这样就可以使用 art-parser 来解析 3690 堆栈的 Java 数据。
1 | ini复制代码art-parser> bt 952 |
堆栈中的 self 则是 art::Thread 的地址,其中 0x3b8 则是该线程的 ID,我们只需要将 0x3b8 修改成 0xe6a 即可。
1 | makefile复制代码art-parser> rd 0xb400007d67e42c00 -e 0xb400007d67e42c10 |
1 | sql复制代码art-parser> wd 0xb400007d67e42c08 0x00000e6a00000001 |
1 | less复制代码art-parser> bt 3690 |
我们此时也知道解析下从客户端传入的参数,核对下前面的分析是否一致。
1 | ini复制代码art-parser> disassemble 0x708328b8 -i 0x7d64410398 |
1 | csharp复制代码 0x71ccf280 <+96>: mov x2, x23 |
1 | ini复制代码art-parser> p 0x12d7e5f8 |
从参数上我们可以核实该 usap64 此时确实是用来启动应用程序 com.android.statementservice,与客户端解析部分吻合。
3657 栈恢复
1 | css复制代码7d5016b000-7d50272000 rw-p 00000000 00:00 0 [anon:stack_and_tls:3657] |
1 | bash复制代码(gdb) x /i 0x0000007dec56126c-0x4 |
1 | bash复制代码Dump of assembler code for function _ZN5NonPIL20MutexLockWithTimeoutEP24pthread_mutex_internal_tbPK8timespec: |
于是我们可以得到一组 gdb、lldb 以及 art-parser 进行栈回溯所需要的寄存器值。
即:
寄存器 | 值 |
---|---|
PC | 0x7dec4f3040 |
LR | 0x7dec56126c |
SP | 0x7d5026e550 |
FP | 0x7d5026e570 |
对原本进程 3690 的 Coredump 文件进行修改。
1 | erlang复制代码0001B710 05 00 00 00 88 01 00 00 01 00 00 00 43 4F 52 45 ............CORE |
对 Coredump 的寄存器以及线程号进行修改后
1 | erlang复制代码0001B710 05 00 00 00 88 01 00 00 01 00 00 00 43 4F 52 45 ............CORE |
1 | less复制代码art-parser> bt 3657 |
将修改后的 coredump 文件,重载 art-parser 上解析,即可清楚的看到 3657 线程在函数 RevokeThreadLocalBuffers上获得了锁 Region lock,然后发生了 Fork 行为,因此造成子进程死锁了。
疑问二
事实上 Google 也考虑到 Linux 多线程 Fork 易死锁问题,因此在 Zygote Fork 子进程之前会停止所有 Daemon 线程,那为什么 3657 还未完成退出,就发生了 Fork。
细节 1
可能有同学注意到线程都已经 join 了,为什么还有 Daemon 线程没有退出,函数 Daemons.stop() 却已经完成了?
这里涉及到 ART 虚拟机线程设计,在 ART 中 join 线程,会在 art::Thread::Destroy 中释放锁资源,因此线程就不会在等待就结束了,对应代码如下:
然而 3657 线程已经运行到 RevokeThreadLocalBuffers 此处,因此 Java 层最后一个 Daemon.stop() 已经结束了。
细节 2
即使 Daemons.stop() 函数结束了,ART 虚拟机仍可以在做一次保护,只需考虑 ThreadList 列表仅剩主线程即可。我们可以看 nativePreFork 在 ART 虚拟机后续如何处理的,有无等待 ThreadList 清理且等待。
然而我们的机器 unregistering_count_ 并未减到 0,nativePreFork 就已经结束了。
对比我们项目 PreZygoteFork 代码,好家伙并没这一段代码。
目前项目的处理方式,通过 Java API 访问 /proc/self/task 目录下的文件数来判断进程是否仅剩一个。
从 3690 进程的内存中,查询父进程历史线程本地变量
可以从中找到最后一次从 tasks.list() 产生的 java.lang.String[] 对象
1 | less复制代码art-parser> p 0x12d7e370 |
结论
至于为什么通过 Java API 访问 /proc/self/task 目录下的文件数会出现不可靠情况仍需进一步调查。
Revert^2 “Wait for thread termination in PreZygoteFork()” | android-review.googlesource.com/c/platform/… |
Revert^2 “Remove waitUntilAllThreadsStopped()” | android-review.googlesource.com/c/platform/… |
Google 给出的新的 waitUntilAllThreadsStopped 处理,依旧是存在瑕疵,首先循环条件有限 1000 次采样,不排除一种情况在最后一次线程退出时,结束了 Unregister 函数后,ThreadList 判断是否为 1 条件通过,但线程也可能在内核态中 do_exit 上发生等待,可能就造成 waitUntilAllThreadsStopped 判断 1000 次不可达,从而发生 FATAL 错误。
补充
1 | ini复制代码/* |
验证实验
实验原理: |
---|
创建 Daemon 线程前,保存 /proc/self/task 的所有线程号到样本一 (alltask) |
启动 5 个 Daemon 线程,分别叫 Daemon-0、Daemon-1、……、Daemon-4 |
终止 4 个 Daemon 线程,保留 Daemon-4 |
保存 /proc/self/task 的所有线程号到样本二 (savetask),并循环判断校验 savetask 长度 > alltask 长度,1000次后终止 Daemon-4 重新测试。 |
当 savetask 长度 <= alltask 长度时,保存现场 Coredump。 |
使用 art-parser 解析内存,核实 savetask 是否存在 Daemon-4,并校验前后数据。若不存在 Daemon-4, 则 proc_task_readdir 发生了截断。 |
1 | ini复制代码int length = 0; |
1 | ini复制代码art-parser> p 0x16d40e20 |
根本原因
Java API ‘ZygoteHooks.waitUntilAllThreadsStopped’ 已经运行了很多年,但为什么最近才发生此类问题,突然变得不可靠,其原因是最新的内核修复了一个小问题导致。Commit 0658a0961b0ac (“procfs: do not list TID 0 in /proc/<pid>
/task”)。
1 | diff复制代码diff --git a/fs/proc/base.c b/fs/proc/base.c |
像本文遇到的案例,如果函数 proc_task_readdir 发生了截断。
版本 | |
---|---|
0658a0961b0ac 之前 | 952、3655 或者 952、0 |
0658a0961b0ac 之后 | 952 |
因此在之前的 Android 版本上,这个函数 while (tasks.list().length > 1) 此处即便内核截断了返回,也至少返回 2 个目录,因此会继续等待,直到正常退出。同样的实验进行调整也在内核 5.15 上测试是否发生截断会得到 0 这个目录。
1 | ini复制代码art-parser> p 0x13d81178 |
正如一句老话,这个功能是基于一个 Bug 之上实现的,如果你把这个 Bug 修复了,那我变成了 Bug。
本文转载自: 掘金