问题背景
今年 7 月份,现场反馈了一个上位机卡死的问题:运行的流程一直没有结束,但日志却没有持续输出,只剩心跳包在打印。
当时我们抓取了两个 dump 文件,打印了所有线程的调用堆栈,试图从中定位卡死的位置。然而分析完所有线程后,始终没有找到业务线程的踪影。
由于情况紧急必须尽快修复,我们只能结合卡住前的日志信息,用了一个晚上逐行分析代码,最终找到了卡死的代码位置。但这也留下了一个疑惑:卡死的地方明明是一个 while 循环,为什么 WinDbg 打印的所有线程调用堆栈中却找不到这个函数?
过了一个多月,最近终于闲下来,可以仔细钻研一下这个问题了。
复现 Demo
以下是我简化后的 demo 代码:
c#
private void TestBtn_Click(object sender, RoutedEventArgs e)
{
Task.Run(async () =>
{
await TestFun();
});
}
bool flag = false;
async Task TestFun()
{
while (true)
{
await Task.Delay(100);
if (flag)
{
Debug.WriteLine("Flag is true, exiting loop.");
break;
}
}
}
代码逻辑很简单:点击 Test 按钮后,通过线程池线程执行 TestFun 函数。需要注意的是,TestFun 并不是一个普通函数,而是一个异步函数 ------在 while 循环中交替执行 await Task.Delay(100) 和业务代码。
现象复现与原因分析
运行程序、点击按钮后,用任务管理器抓取 dump,再用 WinDbg 分析所有线程的调用堆栈------果然,没有找到任何正在执行 TestFun 的线程。
原因就在"异步"二字上:
异步函数在编译时会被编译器转换为一个状态机 。当执行到 await Task.Delay 时,当前线程会被释放 回线程池,此时它要么空闲,要么去执行其他任务。状态机会挂起等待,直到 Task.Delay 完成后,再由某个线程池线程(不一定是原来的线程)继续执行后续代码。
因此,抓取 dump 的那一刻,如果状态机正好处于 Task.Delay 的等待阶段,就没有任何一个线程 的调用堆栈上能看到 TestFun------它根本不在任何线程上运行。这正是当初在 dump 中找不到业务线程的根本原因。
生产环境如何定位
那么,生产环境遇到这类问题该如何定位呢?答案是使用 SOS 扩展的 !dumpasync 命令,它可以枚举出当前所有处于挂起状态的异步状态机:
xml
0:000> !dumpasync
STACK 1
000002550ba48670 00007ffe05e64998 ( ) System.Threading.Tasks.Task+DelayPromise
000002550b9e2480 00007ffe05e67bb0 (0) WhileLoopDemo.MainWindow+<TestFun>d__3 @ 7ffe05d8a200
000002550b9e24d0 00007ffe05e685e0 (0) WhileLoopDemo.MainWindow+<<TestBtn_Click>b__1_0>d @ 7ffe05d89ee0
000002550b9df440 00007ffe05e49cd8 ( ) System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>
从输出可以清晰地看到异步调用链:TestFun 状态机(<TestFun>d__3)正挂起在 Task.DelayPromise 上,其上层是按钮点击事件的 lambda 状态机。即便没有线程正在执行它,异步调用链依然一览无余。
总结
以后再遇到"程序卡死但 dump 中找不到业务线程"的问题时,不妨换个思路:
- 先怀疑业务代码中是否存在 while 死循环 (尤其是配合
await的异步循环); - 除了查看常规的线程调用堆栈,别忘了用
!dumpasync查看异步状态机------真正的"案发现场"可能根本不在任何线程上。