一個超經典 WinForm 卡死問題的最後一次反思

2023-08-24 18:02:09

一:背景

1. 講故事

在我分析的 200+ dump 中,同樣會遵循著 28原則,總有那些經典問題總是反覆的出現,有很多的朋友就是看了這篇 一個超經典 WinForm 卡死問題的再反思 找到我,說 WinDbg 攔截 System_Windows_Forms_ni System.Windows.Forms.Application+MarshalingControl..ctor 總會有各種各樣的問題,而且 windbg 也具有強侵入性,它的附加程序方式讓很多朋友望而生畏!

這一篇我們再做一次反思,就是如何不通過 WinDbg 找到那個 非主執行緒建立的控制元件,那到底用什麼工具的? 對,就是用 Perfview 的牆鐘模式。

二:Perview 的牆鐘調查

1. 測試案例

我還是用上一篇提到的案例,用 backgroundWorker1 的工作執行緒去建立一個 Button 控制元件來模擬這種現象,參考程式碼如下:


namespace WindowsFormsApp2
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {

        }

        private void button1_Click_1(object sender, EventArgs e)
        {
            backgroundWorker1.RunWorkerAsync();
        }

        private void backgroundWorker1_DoWork_1(object sender, DoWorkEventArgs e)
        {
            Button btn = new Button();
            var query = btn.Handle;
        }
    }
}

一旦控制元件在工作執行緒上被建立,程式碼內部就會範例化 MarshalingControlWindowsFormsSynchronizationContext,這裡就用前者來探究。

2. 尋找 MarshalingControl 呼叫棧

那怎麼去尋找這個呼叫棧呢?在 perfview 中有一個 Thread Time 核取方塊,它可以記錄到 Thread 的活動軌跡,在活動軌跡中尋找我們的目標類 MarshalingControl 即可,有了思路之後說幹就幹,命令列下的參考程式碼:


PerfView.exe  "/DataFile:PerfViewData.etl" /BufferSizeMB:256 /StackCompression /CircularMB:500 /KernelEvents:ThreadTime /NoGui /NoNGenRundown collect

當然也可以在 Focus process 中輸入你的程序名來減少 Size,啟動 prefview 監控之後,我們開啟程式,點選 Button 按鈕之後,停止 Prefview 監控,稍等片刻之後我們開啟 Thread Time Stacks,檢索我們要的 MarshalingControl 類, 截圖如下:

從卦中可以看到如下三點資訊:

  • 當前 prefview 錄製了 34.7s
  • MarshalingControl.ctor 有 2 個範例
  • 二次範例化分別在 22.84s 和 24.12s

接下來可以右鍵選擇 Goto -> Goto Item in Callers 看一下它的 Callers 到底都是誰?截圖如下:

從卦中可以清晰的看到如下資訊:

  • 第一個範例是由 System.Windows.Forms.ScrollableControl..ctor() 觸發的。

  • 第二個範例是由 System.Windows.Forms.ButtonBase..ctor() 觸發的。

大家可以逐一的去探究,第一個範例是表單自身的 System.Windows.Forms.Form ,後者就是那個罪魁禍首,卦中資訊非常清楚指示了來自於 WindowsFormsApp2.Form1.backgroundWorker1_DoWork_1,是不是非常的有意思?

3. 如何讓表單儘可能早的卡死

所謂的儘早卡死就是儘可能早的讓主執行緒出現如下呼叫棧。


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)
...

如果不能儘早的讓程式卡死,那你就非常被動,因為在真實的案例實踐中,這個 t1 時間的 new button,可能在 t10 時間因為某些操作才會出現程式卡死,所以你會被迫用 prefview 一直監視,而一直監視就會導致生成太多的 etw 事件,總之很搞的。

先感謝下上海的包老師 提供的一段很棒的指令碼,也經過了老師實測
讓這個問題解決起來更加完美 ❤

這裡我用 ILSpy 反編譯一下這個執行程式,完整程式碼如下:


// Freezer.FreezerForm
using System;
using System.ComponentModel;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Freezer;

public class FreezerForm : Form
{
	private Button btnFreezeEm;

	private Container components = null;

	private const uint WM_SETTINGCHANGE = 26u;

	private const uint HWND_BROADCAST = 65535u;

	private const uint SMTO_ABORTIFHUNG = 2u;

	public FreezerForm()
	{
		InitializeComponent();
	}

	protected override void Dispose(bool disposing)
	{
		if (disposing && components != null)
		{
			components.Dispose();
		}
		base.Dispose(disposing);
	}

	private void InitializeComponent()
	{
		btnFreezeEm = new System.Windows.Forms.Button();
		SuspendLayout();
		btnFreezeEm.Location = new System.Drawing.Point(89, 122);
		btnFreezeEm.Name = "btnFreezeEm";
		btnFreezeEm.Size = new System.Drawing.Size(115, 23);
		btnFreezeEm.TabIndex = 0;
		btnFreezeEm.Text = "Freeze 'em!";
		btnFreezeEm.Click += new System.EventHandler(btnFreezeEm_Click);
		AutoScaleBaseSize = new System.Drawing.Size(6, 15);
		base.ClientSize = new System.Drawing.Size(292, 267);
		base.Controls.Add(btnFreezeEm);
		base.Name = "FreezerForm";
		Text = "Freezer";
		ResumeLayout(false);
	}

	[DllImport("user32.dll")]
	private static extern uint SendMessageTimeout(uint hWnd, uint msg, uint wParam, string lParam, uint flags, uint timeout, out uint result);

	[STAThread]
	private static void Main()
	{
		Application.Run(new FreezerForm());
	}

	private void btnFreezeEm_Click(object sender, EventArgs e)
	{
		try
		{
			Cursor = Cursors.WaitCursor;
			SendMessageTimeout(65535u, 26u, 0u, "Whatever", 2u, 5000u, out var _);
		}
		finally
		{
			Cursor = Cursors.Arrow;
		}
	}
}

這個指令碼供大家參考吧,這裡要提醒一下,我實測了下需要在執行時需要反覆點以及最小最大話可能會遇到一次,不管怎麼說還是非常好的寶貴資料。

三:總結

關於對 非主執行緒建立控制元件 的問題,這已經是第三篇思考了,希望後續不要再寫這個主題了。

圖片名稱