一:背景
1. 講故事
寫(xiě)這篇文章起源于訓(xùn)練營(yíng)里一位朋友最近在微信聊到他對(duì)這個(gè)問(wèn)題使用了一種非常切實(shí)可行,簡(jiǎn)單粗暴的方式,并且也成功解決了公司里幾個(gè)這樣的卡死dump,如今在公司已是靈魂級(jí)人物,讓我也嘗到了什么叫反哺!對(duì),這個(gè)東西叫 Harmony
, github網(wǎng)址: https://github.com/pardeike/Harmony
,一個(gè)非常牛逼的C#程序函數(shù)修改器。
二:卡死問(wèn)題的回顧
1. 故障成因
為了方便講述,先把 WinForm/WPF 程序故障的調(diào)用堆棧給大家呈現(xiàn)一下。
0:000:x86> !clrstack
OS Thread Id: 0x4eb688 (0)
Child SP IP Call Site
002fed38 0000002b [HelperMethodFrame_1OBJ: 002fed38] System.Threading.WaitHandle.WaitOneNative(System.Runtime.InteropServices.SafeHandle, UInt32, Boolean, Boolean)
002fee1c 5cddad21 System.Threading.WaitHandle.InternalWaitOne(System.Runtime.InteropServices.SafeHandle, Int64, Boolean, Boolean)
002fee34 5cddace8 System.Threading.WaitHandle.WaitOne(Int32, Boolean)
002fee48 538d876c System.Windows.Forms.Control.WaitForWaitHandle(System.Threading.WaitHandle)
002fee88 53c5214a System.Windows.Forms.Control.MarshaledInvoke(System.Windows.Forms.Control, System.Delegate, System.Object[], Boolean)
002fee8c 538dab4b [InlinedCallFrame: 002fee8c]
002fef14 538dab4b System.Windows.Forms.Control.Invoke(System.Delegate, System.Object[])
002fef48 53b03bc6 System.Windows.Forms.WindowsFormsSynchronizationContext.Send(System.Threading.SendOrPostCallback, System.Object)
002fef60 5c774708 Microsoft.Win32.SystemEvents+SystemEventInvokeInfo.Invoke(Boolean, System.Object[])
002fef94 5c6616ec Microsoft.Win32.SystemEvents.RaiseEvent(Boolean, System.Object, System.Object[])
002fefe8 5c660cd4 Microsoft.Win32.SystemEvents.OnUserPreferenceChanged(Int32, IntPtr, IntPtr)
002ff008 5c882c98 Microsoft.Win32.SystemEvents.WindowProc(IntPtr, Int32, IntPtr, IntPtr)
...
這個(gè)程序之所以被卡死,底層原因到底大概是這樣的。
- 程序在t1時(shí)間,有非主線程創(chuàng)建了控件。
- 程序在t2時(shí)間,用戶主動(dòng)或被動(dòng)做了 遠(yuǎn)程連接,Windows主題色刷新 等操作,這種系統(tǒng)級(jí)操作Windows需要同步刷新給所有UI控件。
- 那些非主線程控件由于沒(méi)有 MessageLoop 機(jī)制,導(dǎo)致主線程給這些UI發(fā)消息時(shí)得不到響應(yīng),最終引發(fā)悲劇。
t2時(shí)間的卡死是由于t1時(shí)間的錯(cuò)誤創(chuàng)建導(dǎo)致,要想在dump中反向追溯目前是無(wú)法做到的,所以要想找到禍根需要監(jiān)控t1,即MarshalingControl
到底是誰(shuí)創(chuàng)建的,為此我也寫(xiě)過(guò)兩篇文章來(lái)仔細(xì)分析此事。
第一種方式是啟動(dòng) windbg 對(duì) System_Windows_Forms_ni System.Windows.Forms.Application+MarshalingControl..ctor
進(jìn)行攔截,說(shuō)實(shí)話這種方式很多程序員搞不定,原因在于windbg的使用門(mén)檻較高,現(xiàn)實(shí)中很多程序員連CURD都沒(méi)摸明白,所以可想而知了。。。
第二種方式是啟動(dòng) perfview 對(duì) winform/wpf 程序進(jìn)行監(jiān)控,直到程序出現(xiàn)卡死停止收集。最后在錄播中尋找 MarshalingControl..ctor
的調(diào)用棧,這種方式也有不可行的時(shí)候,如果說(shuō)卡死發(fā)生在程序啟動(dòng)的10天后,那這個(gè)錄播文件將會(huì)超級(jí)超級(jí)大,或者有更極端的情況發(fā)生。
所以這兩種方案都有各自的優(yōu)缺點(diǎn),現(xiàn)實(shí)可行性雖然有,但不高。。。今天作為終結(jié)篇,必須把這個(gè)問(wèn)題安排掉,繼續(xù)提供兩種切實(shí)可行的方案。
三:兩種修改方案
1. 使用 Harmony 注入
Harmony作為一款運(yùn)行時(shí)C#方法修改器,借助它我完全可以將一些邏輯注入到 MarshalingControl..ctor
中,比如記錄下初始化該方法的 堆棧信息
,是不是就可以輕松找到這個(gè)非主線程控件到底是誰(shuí)?對(duì)不對(duì),有了思路,我們?cè)?nuget 上引用 Lib.Harmony
,上代碼說(shuō)話。
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
var harmony = new Harmony("一線碼農(nóng)聊技術(shù)");
Type applicationType = typeof(Application);
Type marshalingControlType = applicationType.GetNestedType("MarshalingControl", BindingFlags.NonPublic);
ConstructorInfo constructor = marshalingControlType.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
var prefix = typeof(HookMarshalingControl).GetMethod("OnActionExecuting");
harmony.Patch(constructor, new HarmonyMethod(prefix));
}
private void Form1_Load(object sender, EventArgs e)
{
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
Button btn = new Button();
var query = btn.Handle;
}
private void button1_Click(object sender, EventArgs e)
{
backgroundWorker1.RunWorkerAsync();
}
}
public class HookMarshalingControl
{
public static void OnActionExecuting()
{
Console.WriteLine("----------------------------");
Console.WriteLine($"控件創(chuàng)建線程:{Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine(Environment.StackTrace);
Console.WriteLine("----------------------------");
}
}
卦中的代碼邏輯我就不詳述了,核心就是將 OnActionExecuting
方法注入到 MarshalingControl..ctor
構(gòu)造函數(shù)里,把程序運(yùn)行起來(lái)后觀察 output 窗口,截圖如下:
![](/files/attmgn/2025/1/freeflydom20250115090127067_0.jpg)
終于是一個(gè)臥槽,禍根居然是一個(gè) tid=3
的線程初始化了 new Button()
控件。。。
2. 使用 DnSpy
Harmony 作為一款修改器,它對(duì)程序的侵入性是非常高的,目前還是有一些bug,比如對(duì) .NET7 的支持還不是很好,但相對(duì) perfview
和 windbg
的方式已經(jīng)非常輕量級(jí)了,極大的降低了使用門(mén)檻。
問(wèn)題來(lái)了,那有沒(méi)有一種對(duì)程序無(wú)侵入,可行性超高的方式呢?當(dāng)然是有的,dnspy 此時(shí)可以閃亮登場(chǎng),用過(guò) dnspy 的朋友應(yīng)該知道它是一款輕量級(jí),免安裝綠色的調(diào)試器,當(dāng)然除了調(diào)試器功能,它還是一款程序集修改器,可以實(shí)現(xiàn) Harmony 的所有功能,在實(shí)踐中我們可以將 dnspy copy 到客戶機(jī)使用 啟動(dòng)調(diào)試
或者 附加進(jìn)程
的方式對(duì)程序進(jìn)行干預(yù)。
如何使用 dnspy 對(duì) MarshalingControl..ctor
進(jìn)行干預(yù)呢?可以使用 斷點(diǎn)日志
的功能,日志信息如下:
控件創(chuàng)建線程:{Environment.CurrentManagedThreadId} \n $CALLSTACK
![](/files/attmgn/2025/1/freeflydom20250115090127203_1.jpg)
有些人可能要問(wèn)了 $CALLSTACK
是什么東西?很顯然是堆棧信息,除了這個(gè)關(guān)鍵詞還有很多,具體可以看后面的 問(wèn)號(hào)面板
。
![](/files/attmgn/2025/1/freeflydom20250115090127234_2.jpg)
接下來(lái)把程序跑起來(lái),觀察 output面板。
![](/files/attmgn/2025/1/freeflydom20250115090127257_3.jpg)
從面板中可以清楚的看到,原來(lái)有個(gè) tid=3 的線程創(chuàng)建了一個(gè) Button
控件,這就是我們要找的禍根。
到這里,可能有些人要說(shuō),dnspy 啟動(dòng) exe 的方式因?yàn)楦鞣N原因在我們這邊行不通,有沒(méi)有其他的方式呢? 當(dāng)然是有的,我們還可以在程序啟動(dòng)之后以 進(jìn)程附加
的方式注入,同樣也是一種非常可行且低侵入的方式。
為了能夠更早的介入,可以在 Form1 初始化之前彈一個(gè)MessageBox,有更好的方式大家也可以說(shuō)一下,感謝。參考代碼如下:
public partial class Form1 : Form
{
public Form1()
{
MessageBox.Show("開(kāi)啟你的注入吧...");
InitializeComponent();
}
}
彈框之后,使用 dnspy 的進(jìn)程附加。
![](/files/attmgn/2025/1/freeflydom20250115090127290_4.jpg)
附加好了之后關(guān)閉彈框讓程序繼續(xù)運(yùn)行,點(diǎn)擊 buttton 按鈕,可以看到 output 上的輸出。
11:20:01.548 控件創(chuàng)建線程:<<<當(dāng)線程位于不安全狀態(tài)時(shí)無(wú)法對(duì)表達(dá)式進(jìn)行求值。按步調(diào)試或運(yùn)行直到觸發(fā)斷點(diǎn)。>>>
11:20:01.550 System.Windows.Forms.Application.MarshalingControl.MarshalingControl
11:20:01.551 System.Windows.Forms.Application.ThreadContext.MarshalingControl.get
11:20:01.552 System.Windows.Forms.WindowsFormsSynchronizationContext.WindowsFormsSynchronizationContext
11:20:01.553 System.Windows.Forms.WindowsFormsSynchronizationContext.InstallIfNeeded
11:20:01.553 System.Windows.Forms.Control.Control
11:20:01.554 System.Windows.Forms.ButtonBase.ButtonBase
11:20:01.554 System.Windows.Forms.Button.Button
11:20:01.554 WindowsFormsApp1.Form1.backgroundWorker1_DoWork
11:20:01.555 System.ComponentModel.BackgroundWorker.OnDoWork
11:20:01.555 System.ComponentModel.BackgroundWorker.WorkerThreadStart
11:20:01.556 System.Runtime.Remoting.Messaging.StackBuilderSink.AsyncProcessMessage
11:20:01.556 System.Threading.ExecutionContext.RunInternal
11:20:01.557 System.Threading.ExecutionContext.Run
11:20:01.557 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem
11:20:01.557 System.Threading.ThreadPoolWorkQueue.Dispatch
11:20:01.558 [本機(jī)到托管的轉(zhuǎn)換]
11:20:01.558
這里稍微提醒一下,tid 在這里沒(méi)有顯示出來(lái),大家可以換成問(wèn)號(hào)面板
上的關(guān)鍵詞 $TID
即可,不過(guò)TID不是最重要的,最重要的是調(diào)用棧給弄出來(lái)了。
四:總結(jié)
作為一名專業(yè)的 .NET高級(jí)調(diào)試師
,在這個(gè)經(jīng)典卡死的問(wèn)題溯源上一直沒(méi)有提供非常好的解決方案,還是有些內(nèi)疚的,在我的高級(jí)調(diào)試之旅中還是會(huì)不間斷的收到類似dump,相信這篇文章之后,不再有人被它所困擾!
?轉(zhuǎn)自https://www.cnblogs.com/huangxincheng/p/18668388
該文章在 2025/1/15 9:01:42 編輯過(guò)