饭粒 發表於 2025-8-6 08:55:35

.NET线程异常退出引发程序崩溃的问题分析及解决方案

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">一:背景</a></li><ul class="second_class_ul"><li><a href="#_lab2_0_0">1. 讲故事</a></li></ul><li><a href="#_label1">二:故障重现</a></li><ul class="second_class_ul"><li><a href="#_lab2_1_1">1. 问题代码</a></li></ul><li><a href="#_label2">三:如何寻找第一现场</a></li><ul class="second_class_ul"><li><a href="#_lab2_2_2">1. process monitor</a></li><li><a href="#_lab2_2_3">2. MinHook 注入</a></li></ul><li><a href="#_label3">四:总结</a></li><ul class="second_class_ul"></ul></ul></div><p class="maodian"><a name="_label0"></a></p><h2>一:背景</h2>
<p class="maodian"><a name="_lab2_0_0"></a></p><h3>1. 讲故事</h3>
<p>前天收到了一个.NET程序崩溃的dump,经过一顿分析之后,发现祸根是因为一个.NET托管线程(DBG=XXXX)的异常退出所致,参考如下:</p>
<div class="jb51code"><pre class="brush:csharp;">0:011&gt; !t
ThreadCount:      17
UnstartedThread:0
BackgroundThread: 16
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                                                            Lock
DBG   ID   OSID ThreadOBJ         State GC Mode   GC Alloc Context                  Domain         Count Apt Exception
   0    1   84d8 000001C0801EAC20    26020 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 STA
   3    2   9d78 000001C0801F8210    2b220 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 MTA (Finalizer)
   4    4   8760 000001C08466C800102b220 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 MTA (Threadpool Worker)
   ...
44   16   b2fc 000001C08F949450102b220 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 MTA (GC) (Threadpool Worker)
46   15   9904 000001C08F9487B0102b220 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 MTA (Threadpool Worker)
XXXX    3   a23c 000001C08F948E00102b220 Preemptive0000000000000000:0000000000000000 000001c080266300 -00001 Ukn (Threadpool Worker) </pre></div>
<p>由于线程异常退出,CLR此时完全不知情,当 GC 触发时会在这个<code>XXXX线程</code>上寻找引用根,由于是一个不存在的线程,所以访问它的空间自然就是<code>访问违例</code>,从&nbsp;<code>ScanStackRoots</code>&nbsp;函数调用栈上可以清晰的看到,参考如下:</p>
<div class="jb51code"><pre class="brush:csharp;">0:011&gt; .ecxr
rax=00007ffdbefcc8a0 rbx=000000a42007f5f0 rcx=000000a42187f688
rdx=0000000000000000 rsi=000000a42007ee60 rdi=000000a42007f100
rip=00007ffdbec36cbb rsp=000000a42007f828 rbp=000001c08f948e00
r8=000000a42007f910r9=000001c08f948e00 r10=00000fffb7da5860
r11=0555501544555545 r12=ffffffffffffffff r13=0000000000000000
r14=0000000000000000 r15=00007ffdbec14fb0
iopl=0         nv up ei pl nz ac pe cy
cs=0033ss=002bds=002bes=002bfs=0053gs=002b             efl=00010211
coreclr!InlinedCallFrame::FrameHasActiveCall+0x13:
00007ffd`bec36cbb 483b01          cmp   rax,qword ptr ds:000000a4`2187f688=????????????????
0:011&gt; k
*** Stack trace for last set context - .thread/.cxr resets it
# Child-SP          RetAddr               Call Site
00 000000a4`2007f828 00007ffd`bec36c2e   coreclr!InlinedCallFrame::FrameHasActiveCall+0x13
01 000000a4`2007f830 00007ffd`bec36aef   coreclr!ScanStackRoots+0x3a
02 000000a4`2007f8a0 00007ffd`bec29627   coreclr!GCToEEInterface::GcScanRoots+0x8f
03 (Inline Function) --------`--------   coreclr!GCScan::GcScanRoots+0x73
04 000000a4`2007f8e0 00007ffd`bec14865   coreclr!WKS::gc_heap::background_mark_phase+0xdf
05 000000a4`2007f990 00007ffd`bed286a0   coreclr!WKS::gc_heap::gc1+0x511
06 000000a4`2007f9f0 00007ffd`bed391c1   coreclr!WKS::gc_heap::bgc_thread_function+0x68
07 000000a4`2007fa20 00007ffe`3533e8d7   coreclr!&lt;lambda_7303b2ca2c5f80d5f81ddddfcd2de660&gt;::operator()+0xa1
08 000000a4`2007fa50 00007ffe`363f14fc   kernel32!BaseThreadInitThunk+0x17
09 000000a4`2007fa80 00000000`00000000   ntdll!RtlUserThreadStart+0x2c</pre></div>
<p>说实话这种崩溃我见过很多例,但更多的都是&nbsp;<code>new Thread</code>&nbsp;创建出来的,所以用 harmony 对它的&nbsp;<code>Thread.StartCore</code>&nbsp;进行拦截就能轻松找出,但这次崩溃有一些特殊,它并不是来自于&nbsp;<code>new Thread</code>&nbsp;而是线程池散养的线程(ThreadPool),这对问题分析增加了不少难度,既然是反思,那就好好的总结此类问题的解决思路吧。</p>
<p class="maodian"><a name="_label1"></a></p><h2>二:故障重现</h2>
<p class="maodian"><a name="_lab2_1_1"></a></p><h3>1. 问题代码</h3>
<p>为了方便演示,我们用 C# 调用 C,然后在 C 中通过&nbsp;<code>TerminateThread</code>&nbsp;让程序异常退出,首先看下 C 代码:</p>
<div class="jb51code"><pre class="brush:csharp;">extern "C"
{
        _declspec(dllexport) void dowork();
}
#include "iostream"
#include &lt;Windows.h&gt;
using namespace std;
void dowork()
{
        DWORD threadId = GetCurrentThreadId();
        printf("C++:当前线程ID(十进制):%lu,十六进制:0x%X\n", threadId, threadId);
        printf("C++:我准备退出了哦。。。\n");
        TerminateThread(GetCurrentThread(), 1);
}</pre></div>
<p>接下来在 C# 中调用导出的 dowork 方法,参考代码如下:</p>
<div class="jb51code"><pre class="brush:csharp;">namespace Example_1_1
{
    internal class Program
    {
      static void Main(string[] args)
      {
            DoRequest();
            Console.ReadLine();
      }
      static void DoRequest()
      {
            Task.Run(() =&gt;
            {
                Console.WriteLine("1. 调用 C++ 代码...");
                try
                {
                  dowork();
                  Console.WriteLine("2. C++ 代码执行完毕...");
                }
                catch (Exception ex)
                {
                  Console.WriteLine($"2. C++ 代码执行异常: {ex.Message}");
                }
            });
      }
      
      public extern static void dowork();
    }
}</pre></div>
<p>最后将程序运行起来,用windbg附加,可以看到果然有一个 XXXX 线程,截图如下:</p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202508/202508060857331.png" /></p>
<p>故障已经复现,接下来就是寻找到底是谁让 ThreadPool 线程异常退出了。。。</p>
<p class="maodian"><a name="_label2"></a></p><h2>三:如何寻找第一现场</h2>
<p class="maodian"><a name="_lab2_2_2"></a></p><h3>1. process monitor</h3>
<p>要想找到这个问题的祸根,需要找到调用&nbsp;<code>TerminateThread</code>&nbsp;函数的调用栈,一种简单粗暴的方法就是用&nbsp;<code>process monitor</code>,根据 Windows 的ETW 规则,一个线程退出时会发出一个 Event 事件,这种事件可以被 process monitor 捕获,并且还能记录到调用栈,有了想法之后说干就干,配置界面如下:</p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202508/202508060857332.png" /></p>
<p>接下来运行程序,使用 windbg 附加进程,寻找问题线程ID,参考如下:</p>
<div class="jb51code"><pre class="brush:csharp;">0:005&gt; !t
ThreadCount:      5
UnstartedThread:0
BackgroundThread: 3
PendingThread:    0
DeadThread:       1
Hosted Runtime:   no
                                                                                                            Lock
DBG   ID   OSID ThreadOBJ         State GC Mode   GC Alloc Context                  Domain         Count Apt Exception
   0    1   153c 00000202C603C240    2a020 Preemptive00000202CA819060:00000202CA81B020 00000202c6088980 -00001 MTA
   3    2      afc 00000202C60F0DB0    2b220 Preemptive0000000000000000:0000000000000000 00000202c6088980 -00001 MTA (Finalizer)
XXXX    4   4718 00000202C6057D10102b220 Preemptive00000202CA80CF70:00000202CA80E740 00000202c6088980 -00001 Ukn (Threadpool Worker)
   4    5   4420 00000202C605D510302b220 Preemptive00000202CA80EB40:00000202CA810760 00000202c6088980 -00001 MTA (Threadpool Worker)
0:005&gt; ? 4718
Evaluate expression: 18200 = 00000000`00004718</pre></div>
<p>从卦中可以看到是一个叫&nbsp;<code>osid=18200</code>&nbsp;的线程异常退出,接下来从 process monitor 界面上果然看到了一个<code>Thread ID:18200</code>&nbsp;的&nbsp;<code>Thread Exit</code>&nbsp;事件,完美,截图如下:</p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202508/202508060857333.png" /></p>
<p>接下来就是双击,打开 Stack 选项卡,可以清晰的看到是有人调用了&nbsp;<code>Example_1_2!dowork</code>&nbsp;导致的退出,截图如下:</p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202508/202508060857334.png" /></p>
<p>在真实项目中,我相信你看到 dowork 函数应该知道发生了什么,排查范围是不是一下子就小了很多。。。相信这个问题你能轻松搞定。</p>
<p class="maodian"><a name="_lab2_2_3"></a></p><h3>2. MinHook 注入</h3>
<p>上面的 process monitor 虽好,但也有一个让人不如意的地方,那就是不能显示托管栈,这个确实没办法,那有没有办法让我看到托管栈呢?如果能看到就完美了,做法非常简单,对&nbsp;<code>kernel32!TerminateThread</code>&nbsp;进行注入即可,一旦有人执行了这个方法,记录 Terminate 线程的线程ID以及调用栈即可,完整代码如下:</p>
<div class="jb51code"><pre class="brush:csharp;">namespace Example_1_1
{
    internal class Program
    {
      static void Main(string[] args)
      {
            // Install the hook before any TerminateThread calls can occur
            TerminateThreadHook.InstallHook();
            Console.WriteLine("Hook installed. Starting test...");
            DoRequest();
            // Uninstall hook when done
            TerminateThreadHook.UninstallHook();
            Console.ReadLine();
      }
      static void DoRequest()
      {
            Task.Run(() =&gt;
            {
                Console.WriteLine("1. 调用 C++ 代码...");
                try
                {
                  dowork();
                  Console.WriteLine("2. C++ 代码执行完毕...");
                }
                catch (Exception ex)
                {
                  Console.WriteLine($"2. C++ 代码执行异常: {ex.Message}");
                }
            });
      }
      
      public extern static void dowork();
    }
    public static class TerminateThreadHook
    {
      // TerminateThread function signature
      
      private delegate bool TerminateThreadDelegate(IntPtr hThread, uint dwExitCode);
      private static TerminateThreadDelegate _originalTerminateThread;
      private static IntPtr _terminateThreadPtr = IntPtr.Zero;
      public static void InstallHook()
      {
            // 1. Get TerminateThread address from kernel32.dll
            _terminateThreadPtr = MinHook.GetProcAddress(
                MinHook.GetModuleHandle("kernel32.dll"), "TerminateThread");
            if (_terminateThreadPtr == IntPtr.Zero)
            {
                Console.WriteLine("Failed to find TerminateThread address.");
                return;
            }
            // 2. Initialize MinHook
            var status = MinHook.MH_Initialize();
            if (status != MinHook.MH_STATUS.MH_OK)
            {
                Console.WriteLine($"MH_Initialize failed: {status}");
                return;
            }
            // 3. Create Hook
            var detourPtr = Marshal.GetFunctionPointerForDelegate(
                new TerminateThreadDelegate(HookedTerminateThread));
            status = MinHook.MH_CreateHook(_terminateThreadPtr, detourPtr, out var originalPtr);
            if (status != MinHook.MH_STATUS.MH_OK)
            {
                Console.WriteLine($"MH_CreateHook failed: {status}");
                return;
            }
            _originalTerminateThread = Marshal.GetDelegateForFunctionPointer&lt;TerminateThreadDelegate&gt;(originalPtr);
            // 4. Enable Hook
            status = MinHook.MH_EnableHook(_terminateThreadPtr);
            if (status != MinHook.MH_STATUS.MH_OK)
            {
                Console.WriteLine($"MH_EnableHook failed: {status}");
                return;
            }
            Console.WriteLine("TerminateThread hook installed successfully!");
      }
      public static void UninstallHook()
      {
            if (_terminateThreadPtr == IntPtr.Zero)
                return;
            // 1. Disable Hook
            var status = MinHook.MH_DisableHook(_terminateThreadPtr);
            if (status != MinHook.MH_STATUS.MH_OK)
                Console.WriteLine($"MH_DisableHook failed: {status}");
            // 2. Uninitialize MinHook
            status = MinHook.MH_Uninitialize();
            if (status != MinHook.MH_STATUS.MH_OK)
                Console.WriteLine($"MH_Uninitialize failed: {status}");
            _terminateThreadPtr = IntPtr.Zero;
            Console.WriteLine("Hook uninstalled.");
      }
      private static bool HookedTerminateThread(IntPtr hThread, uint dwExitCode)
      {
            // Get current thread ID
            uint currentThreadId = GetCurrentThreadId();
            uint targetThreadId = GetThreadId(hThread);
            Console.WriteLine($" TerminateThread intercepted!");
            Console.WriteLine($"Attempting to terminate thread: 0x{targetThreadId.ToString("X")} (ID: {targetThreadId})");
            Console.WriteLine($"Called from thread ID: {currentThreadId}");
            // Print managed call stack
            Console.WriteLine("\n:");
            Console.WriteLine(Environment.StackTrace);
            return _originalTerminateThread(hThread, dwExitCode);
      }
      
      private static extern uint GetCurrentThreadId();
      
      private static extern uint GetThreadId(IntPtr hThread);
    }
    public static class MinHook
    {
      public enum MH_STATUS
      {
            MH_OK = 0,
            MH_ERROR_ALREADY_INITIALIZED,
            MH_ERROR_NOT_INITIALIZED,
            // ... other status codes
      }
      
      public static extern MH_STATUS MH_Initialize();
      
      public static extern MH_STATUS MH_Uninitialize();
      
      public static extern MH_STATUS MH_CreateHook(IntPtr pTarget, IntPtr pDetour, out IntPtr ppOriginal);
      
      public static extern MH_STATUS MH_EnableHook(IntPtr pTarget);
      
      public static extern MH_STATUS MH_DisableHook(IntPtr pTarget);
      
      public static extern IntPtr GetModuleHandle(string lpModuleName);
      
      public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
    }
}</pre></div>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202508/202508060857335.png" /></p>
<p>从卦中信息看果然拦截到了,通过&nbsp;<code>Environment.StackTrace</code>&nbsp;属性将托管栈完美的展示出来,但这里也有一个小遗憾就是没看到<code>非托管部分</code>,如果真想要的话可以借助 dbghelp.dll,这个就不细说了,总之根据这些调用栈日志 再比对 dump 中的异常退出线程,最终就会真相大白。。。</p>
<p class="maodian"><a name="_label3"></a></p><h2>四:总结</h2>
<p>如今.NET的主战场在工控,而工控中有大量的C#和C++交互的场景,C++处理不慎就会导致C#灾难性后果,这篇文章所输出的经验希望给后来者少踩坑吧!</p>
頁: [1]
查看完整版本: .NET线程异常退出引发程序崩溃的问题分析及解决方案