「沒有回應」的真面目 ── Windows 如何判定應用程式當住,以及不卡住的設計

· · Windows, Windows 開發, 故障調查, 多執行緒, WinForms, WPF, Win32 API, UI 設計

「操作到一半應用程式變白,顯示(沒有回應)。」「我們接到偶爾卡住的單據,但開發機上從來重現不了。」── 對 Windows 業務應用程式來說,這個「沒有回應」是最常見的抱怨之一。而且出奇少人知道:把「沒有回應」顯示放上去的不是卡住的應用程式本身──是作業系統

Windows 怎麼知道應用程式「卡住了」?那個結霜白的視窗是什麼?本文以在 Windows 上撰寫業務應用程式的開發者、以及承接應用程式卡住單據的 IT 人員為對象,從訊息迴圈的基礎走完「沒有回應」判定,並依一次資訊整理無回應的經典原因、不卡住的設計,以及調查卡住當下的程序。

1. 先講結論

  • 「沒有回應」是作業系統的判定。當視窗(以及擁有它的 GUI 執行緒)沒有在等輸入、不在啟動序列中,且 5 秒沒有取出訊息(PeekMessage)時,作業系統把它當成沒有回應。判定不是每個處理程序一次。1
  • 變白的視窗是「幽靈視窗」。作業系統隱藏了原本的視窗,換進相同位置、大小與外觀的假貨。你能做的只有移動、最小化或關閉;內容並沒有在跑。附加了偵錯工具時不會建立幽靈視窗。2
  • 無回應的原因幾乎總是收斂成一件事。本該幫浦訊息迴圈的 UI 執行緒被重工作或等待擋住。同步 I/O、網路呼叫、鎖等待,以及跨執行緒的 SendMessage 是經典。3
  • 設計原則是「不要在 UI 執行緒上等待或計算」。把重工作移到工作者執行緒(C# 用 async/await + Task.Run),讓 UI 執行緒專心繪製、進度與接受取消。4
  • DoEvents 與手動幫浦訊息迴圈是重入錯誤的溫床。「沒有回應」顯示消失了,但結構現在允許任意事件在工作中途插入。正確做法是分離,不是躲避。
  • 調查從捕捉卡住當下的狀態開始。取傾印並看 UI 執行緒的堆疊,幾乎總能找出它在等什麼。

2. 前提:Windows 應用程式由訊息驅動

要理解「沒有回應」,先要接受 Windows GUI 應用程式是事件驅動的。GUI 應用程式不是自己去抓輸入;它接收作業系統送來的訊息(滑鼠、鍵盤、重繪要求、計時器等)並據此行動。3

每個建立視窗的執行緒都有訊息佇列,並跑像這樣的訊息迴圈

MSG msg;
while (GetMessage(&msg, NULL, 0, 0) > 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);   // the window procedure is called
}

GetMessage 從佇列取出訊息,DispatchMessage 呼叫該視窗的視窗程序(訊息處理函式)。按鈕點擊處理、重繪,以及 WinForms 或 WPF 事件處理常式,追到底都在這個迴圈的一次迭代裡跑。5

訊息迴圈的基本結構作業系統把滑鼠、鍵盤和其他輸入放進執行緒的訊息佇列;UI 執行緒的迴圈用 GetMessage 取出,用 DispatchMessage 呼叫視窗程序,處理結束後回到迴圈頂端OS(輸入、重繪要求、計時器)執行緒訊息佇列用 GetMessage 取出DispatchMessage在視窗程序裡處理

圖 1: GUI 應用程式的心臟是訊息迴圈;每個事件處理常式都作為這個迴圈的一次迭代執行。

這個結構有一個重要後果。若你在視窗程序(事件處理常式)裡做耗時工作,迴圈在此期間無法取出下一則訊息。它既不能回應點擊,也不能回應重繪要求──這才是「卡住」真正的意思。

也值得接受:訊息有兩條傳遞路徑。PostMessage 把訊息放上佇列並立刻返回,迴圈依序取出並處理訊息。SendMessage直接呼叫視窗程序,處理完成前不會回到呼叫端36 這個差異直接進入第 4 章的死結討論。

兩條訊息傳遞路徑PostMessage 把訊息放上佇列並立刻返回;訊息迴圈依序取出並處理。SendMessage 直接呼叫視窗程序,處理完成前不回到呼叫端PostMessage放上佇列(立刻返回)迴圈依序取出並處理SendMessage直接呼叫程序處理完成前不返回

圖 2: 即使兩者都是「送訊息」,排入佇列的 Post 與等待完成的 Send 本質完全不同。

3. 「沒有回應」怎麼判定 ── 5 秒規則與幽靈視窗

那麼作業系統怎麼知道「這個應用程式卡住了」?準則有官方記載。當視窗沒有在等輸入、不在啟動序列中,且 5 秒沒有呼叫 PeekMessage(取出訊息)時,作業系統把它當成沒有回應。1 換句話說,作業系統像把脈一樣看「訊息迴圈是否真的在轉」,5 秒沒有脈搏就判定視窗沒有回應(文件寫明這個 5 秒值未來可能改變)。判定單位是視窗與擁有它的 GUI 執行緒;有數條 UI 執行緒的應用程式裡,一條卡住不代表另一條上的視窗死了。傾印裡該看的執行緒是卡住視窗的擁有者。

被判定的頂層視窗接下來發生什麼,也有記載。作業系統隱藏原本的視窗,換成具有相同 Z 順序、位置、大小與外觀的「幽靈視窗」。使用者能做的只有移動、調整大小或(強制)關閉。裡面的應用程式其實沒有在回應,因此其他操作都不行。2

無回應視窗判定與幽靈視窗替換當 UI 執行緒被重工作擋住、訊息取出停了 5 秒,作業系統判定視窗沒有回應,隱藏原本的,換進相同外觀的幽靈視窗,只提供使用者移動、最小化與關閉UI 執行緒被重工作擋住訊息取出停止過了 5 秒?換進幽靈視窗標題顯示(沒有回應)結霜白;只能移動與關閉

圖 3: 「沒有回應」文字與白畫面都屬於作業系統換上去的幽靈視窗,不屬於卡住的應用程式。

標題列上出現的「(沒有回應)」字串,以及 Aero 佈景下結霜白的外觀,都屬於這個幽靈視窗。由此引出兩個實務後果。

  • 等到「沒有回應」顯示出來時,擁有該視窗的執行緒至少已 5 秒沒有在處理訊息。不是「顯示來得太早」── UI 執行緒確定被擋住了。
  • 附加了偵錯工具時不會建立幽靈視窗。2 當看起來像「偵錯工具下從來不會沒有回應,發行版卻會」時,卡住本身可以相同,只有顯示不同。

還有一個 API DisableProcessWindowsGhosting,對整個處理程序停用這次替換。7 它是為資訊站終端這類特殊情況準備的,那種情況你不希望作業系統自己把視窗弄得像還能操作。呼叫它會讓「沒有回應」顯示不再出現,但應用程式卡住這個事實不變。要理解這不是一般應用程式拿來當「沒有回應」對策的東西。

4. 為什麼應用程式會卡住 ── 擋住 UI 執行緒的經典模式

原因煮乾了是單一一點──「UI 執行緒回不到訊息迴圈」──但實務上遇到的形狀落在幾個經典裡。

擋住 UI 執行緒的經典原因分類四個經典家族──同步 I/O 與網路呼叫、鎖等待、跨執行緒 SendMessage,以及 COM STA 牽連──都收斂成同一點:UI 執行緒無法回到訊息迴圈哪一種經典原因?I/O 還是鎖?SendMessage 還是 COM?同步 I/O 與網路鎖等待SendMessage跨執行緒COM STA 牽連UI 回不去沒有回應檢查

圖 4: 看得見的症狀相同,但擋住執行緒的兇手落在四個家族,對策各不相同。

同步 I/O 與網路呼叫。這是最常見的。在按鈕點擊處理常式裡同步做大檔讀寫、資料庫查詢、Web API 呼叫,或存取網路磁碟機上的檔案。開發機上幾分之一秒就結束,所以你從不注意;正式環境的網路延遲或檔案伺服器打嗝,把它變成數十秒的等待,於是接到「偶爾卡住」的單據。網路磁碟機在連線斷掉時逾時很長,會把症狀戲劇性地加重。

鎖等待。UI 執行緒試圖取得與工作者執行緒共享資料上的鎖,結果等著長時間握著那把鎖的工作者。鎖的紀律在多執行緒實務系列有詳細說明。

跨執行緒的 SendMessageSendMessage 在目的地視窗的程序處理完之前不會返回6 當你把它送到另一條執行緒上的視窗,傳送端會被弄成等到那條執行緒處於能處理訊息的狀態。若目的地執行緒自己也在等什麼,就變成雙方互相等待的訊息死結3 特別是送到 HWND_BROADCAST,只要有一個視窗沒有回應就會把你拖進去。等不起的時候,考慮 SendMessageTimeout 或不等人回覆的 PostMessage8

跨執行緒 SendMessage 造成的死結若工作者執行緒向 UI 執行緒視窗送出 SendMessage,而 UI 執行緒正被擋住等工作者的結果,雙方就互相等對方完成而死結工作者執行緒UI 執行緒工作者執行緒UI 執行緒無法處理訊息(被擋住)無法從 SendMessage 返回互相等待──死結等工作者結束(被擋住)SendMessage(處理完才返回)

圖 5: 「UI 執行緒等工作者,工作者經由 SendMessage 等 UI 執行緒」是經典死結。

COM 套間牽連。對 STA 物件的呼叫以視窗訊息傳遞,因此 UI 執行緒(STA)被擋住時,其他執行緒的 COM 呼叫也會連帶被擋住。那個結構在 COM STA/MTA 文章裡說明。

「只是一瞬間」的堆積。即使是 50 ms 的同步呼叫,在迴圈裡叫 100 次就是 5 秒。沒有回應的門檻是 5 秒,但體感「遲鈍」大約從 100 ms 開始。設計經驗法則是「UI 執行緒只能被擋住毫秒級」。

5. 不卡住的設計 ── 把重工作移出 UI 執行緒

設計原則只有一件:把耗時工作移出 UI 執行緒。在 C#(WinForms/WPF)裡,async/await 是最短的正確做法。

private async void RunButton_Click(object sender, EventArgs e)
{
    runButton.Enabled = false;
    try
    {
        // CPU-heavy work, or APIs that are only synchronous, go to a worker via Task.Run
        var result = await Task.Run(() => HeavyCalculation(input));

        // For I/O, use APIs that are natively async (they do not consume a thread either)
        var data = await httpClient.GetStringAsync(url);

        // After await you are back on the UI thread, so you can touch controls directly
        resultLabel.Text = result + data.Length.ToString();
    }
    catch (Exception ex)
    {
        // An exception leaking from an async void handler will take the app down. Catch it here
        MessageBox.Show($"The operation failed: {ex.Message}");
    }
    finally
    {
        runButton.Enabled = true;
    }
}

有三點。第一,await 在等的時候,UI 執行緒已回到訊息迴圈,因此不會變成沒有回應。第二,await 之後的接續回到 UI 執行緒,因此之後可以正常碰控制項(從工作者執行緒直接碰控制項是禁止的;需要時用 Control.Invoke / Dispatcher.InvokeAsync)。4 第三,工作執行期間停用按鈕,並以其他方式從設計上殺死重入

原生 Win32 的畫面相同:把工作交給工作者執行緒,用 PostMessage 以自訂訊息通知 UI 執行緒完成,在視窗程序裡更新 UI。PostMessage 只把訊息放上佇列並立刻返回,因此工作者側也不被擋住。6 等待工作者執行緒本身,用條件變數文章所談的紀律來寫。

不卡住應用程式裡的角色分工UI 執行緒只負責接受輸入、顯示進度與接受取消;工作者執行緒跑重工作,並經由 PostMessage 或 await 接續把完成交回 UI 執行緒交出工作PostMessage / await 接續UI 執行緒:輸入、進度、取消工作者執行緒:重工作UI 執行緒上沒有同步 I/O 或長計算

圖 6: 把 UI 執行緒當「櫃檯」,重工作一律交給工作者,只收下完成通知。

要避開的技巧,是在重工作區塊之間插入 Application.DoEvents()PeekMessage 迴圈,只為了讓顯示活著。你躲過了沒有回應,但任意事件處理常式會在工作中途重入。第二次按按鈕、處理期間關閉表單、計時器觸發──任何一個都可能弄壞仍在處理的資料,而且錯誤依賴時機、難以重現。把手動幫浦訊息迴圈留在強制回應進度對話方塊這類有限結構裡,原則上用分離來解決。

DoEvents 造成重入錯誤的時間軸在重工作中途呼叫 DoEvents,讓已排入佇列的點擊之事件處理常式插入並執行、改寫仍在處理的資料,然後原本的工作再繼續,造成依賴時機的資料損壞UI 執行緒UI 執行緒按鈕再按的處理常式插入重工作開始(資料正在處理)DoEvents(處理已排入的訊息)插入的工作改寫資料原本的工作繼續(資料已不一致)

圖 7: DoEvents 抹掉「沒有回應」,代價是邀請任意事件進入工作中途。

對長時間工作,設計裡也要包含進度顯示與取消。用 IProgress<T> 把進度送給 UI,用 CancellationToken 傳達中斷,使用者就能看見「它在做事」,不會伸手去強制結束(那常常是資料損壞的原因)。

長時間工作的進度與取消流程工作者執行緒經由 IProgress 把進度送給 UI 執行緒;UI 上的取消動作經由 CancellationToken 到達工作者;工作者在方便的邊界停下並清理經由 IProgress 的進度CancellationToken工作者:長時間工作UI:進度與停止按鈕在邊界停下並清理

圖 8: 進度是「工作者 → UI」;取消是「UI → 工作者」。從一開始就把這條細的雙向通道寫進設計。

6. 調查卡住的當下

調查「偶爾卡住」時,最有價值的是正好卡住那一刻的執行緒狀態。重開,證據就沒了。

取傾印。在工作管理員的詳細資料索引標籤,對目標處理程序按右鍵 →「建立傾印檔」。光這樣就得到帶有每條執行緒堆疊的完整傾印。只要告訴承接單據的 IT 人員「卡住時,關掉之前先做這個」,調查成功率就差很多。建立收集機制見當機傾印收集文章

看 UI 執行緒的堆疊。在 WinDbg 打開傾印,看正在幫浦訊息迴圈的執行緒(通常是執行緒 0)的堆疊。同步 I/O 顯示為 ReadFile 或網路 API,鎖等待顯示為 WaitFor… 家族呼叫,跨執行緒 SendMessage 顯示為在 SendMessage 裡等待──原樣如此。怎麼讀在 WinDbg 入門文章裡說明。

即時看。用 Process Explorer 可以當場檢查執行緒清單與堆疊。持續遲鈍時,取 WPR 追蹤並分析 UI 執行緒隨時間的等待(WPR/WPA 實戰)。

調查沒有回應的基本程序在卡住當下取傾印,看 UI 執行緒的堆疊,辨識它是停在同步 I/O、鎖等待,還是跨執行緒 SendMessage,並連到對應的設計修正卡住的當下取傾印(關掉之前)看 UI 執行緒的堆疊同步 I/O 或網路等待鎖等待跨執行緒 SendMessage把該處分離到工作者

圖 9: 調查的主角是「卡住當下的傾印」;UI 執行緒的堆疊本身就是原因分類。

也可以把從症狀做第一次切割的方式標準化。若某個操作一定卡住,先懷疑該處理常式裡的同步 I/O。若很少卡住且與操作無關,懷疑鎖順序或跨執行緒的 SendMessage 死結,並在傾印裡對上兩條執行緒的等待目標。若只有某個環境卡住,懷疑網路磁碟機、Proxy 或防毒軟體這類環境因素造成的逾時。

7. 總結

  • 「沒有回應」是作業系統判定應用程式 5 秒沒有取出訊息後換進幽靈視窗的機制。把顯示放上去的是作業系統,不是應用程式。
  • 卡住的原因是單一一點:「UI 執行緒無法回到訊息迴圈」。同步 I/O、網路、鎖等待,以及跨執行緒 SendMessage 是經典。
  • 對策是把重工作移出 UI 執行緒。C# 用 async/await + Task.Run;Win32 用工作者執行緒 + PostMessage。執行期間用停用按鈕等方式從設計上防止重入。
  • DoEvents 躲避,換來的是重入錯誤。DisableProcessWindowsGhosting 只拿掉顯示。兩者都不是根本修正。
  • 調查時,「卡住當下」的傾印最重要。原因幾乎總是原樣寫在 UI 執行緒的堆疊上。

從使用者角度看「沒有回應」是「壞掉了」,但一旦知道機制,就能把它翻譯成精確的句子「UI 執行緒 5 秒沒回來」。從那一句往回推,候選原因、修正與調查程序都會自然掉出來。

相關文章

相關諮詢領域

小村軟體有限公司承接「偶爾卡住」或變成「沒有回應」的業務應用程式根本原因調查(傾印分析與追蹤分析)、把充滿同步工作的舊 UI 程式碼重構成 async/await 與工作者執行緒分離,以及不凍結的 UI 設計審查。即使還沒有重現程序,也可以從如何收集證據的設計開始幫忙。

參考連結

  1. Microsoft Learn, IsHungAppWindow function (winuser.h). 關於判定準則:應用程式「沒有在等輸入、不在啟動序列中,且內部逾時 5 秒沒有呼叫 PeekMessage」時被當成沒有回應;關於這個 5 秒準則可能改變;以及此函式對幽靈視窗一律傳回 TRUE。  2

  2. Microsoft Learn, GetMessage function (winuser.h). 關於頂層視窗數秒不回應訊息時,系統把它當成沒有回應,並換成相同 Z 順序、位置、大小與外觀的幽靈視窗;關於使用者只能移動、調整大小或關閉;以及附加偵錯工具時不建立幽靈視窗。  2 3

  3. Microsoft Learn, About Messages and Message Queues. 關於 Windows 應用程式是事件驅動、視窗程序處理訊息;關於排入佇列的訊息與直接送出的訊息之區別;關於沒有回應的視窗換成幽靈視窗;以及涵蓋執行緒互相送訊息造成死結的章節。  2 3 4

  4. Microsoft Learn, How to make thread-safe calls to controls (Windows Forms). 關於 WinForms 控制項從建立它們以外的任何執行緒碰都不安全;關於從另一條執行緒更新時使用 Invoke/BeginInvoke;以及使用 async/await 或 BackgroundWorker 的安全非同步模式。  2

  5. Microsoft Learn, Using Messages and Message Queues. 關於以 GetMessage、TranslateMessage 與 DispatchMessage 實作典型訊息迴圈,以及如何檢查訊息佇列。 

  6. Microsoft Learn, SendMessage function (winuser.h). 關於 SendMessage 呼叫指定視窗的視窗程序,處理完成前不返回;關於送到另一條執行緒上的視窗會讓傳送端等到該執行緒處理訊息;以及與不等人回覆、把訊息放上佇列的 PostMessage 之差異。  2 3

  7. Microsoft Learn, DisableProcessWindowsGhosting function (winuser.h). 關於能對呼叫端 GUI 處理程序停用幽靈視窗功能(讓沒有回應的視窗可最小化、移動與關閉);以及停用持續該處理程序的生命週期。 

  8. Microsoft Learn, SendMessageTimeout function (winuser.h). 關於能以逾時送出訊息;以及視窗沒有回應(已被判定卡住)時不等待就返回的旗標(SMTO_ABORTIFHUNG)。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

什麼條件下會出現「沒有回應」?
當有視窗的應用程式沒有在等輸入、不在啟動序列中,且 5 秒沒有取出訊息(PeekMessage)時,作業系統判定該視窗無回應。被判定無回應的頂層視窗會被隱藏,換成相同位置、大小與外觀的「幽靈視窗」。「(沒有回應)」標題列文字與結霜白的外觀屬於這個幽靈視窗,它只讓你移動、最小化或關閉。換句話說,「沒有回應」不是應用程式自己顯示的──是作業系統替應用程式放上去的畫面。
有沒有設定能在工作進行中不讓「沒有回應」出現?
呼叫 DisableProcessWindowsGhosting 會對該處理程序停用換成幽靈視窗。不過那只是讓無回應比較不被使用者看見──視窗仍不回應輸入,從使用者角度看是完全凍結、無法移動或關閉。真正的修正不是抑制顯示,而是把重工作移到工作者執行緒,讓 UI 執行緒連十分之一秒都不要被擋,更別說五秒。另外注意,附加了偵錯工具時作業系統不會建立幽靈視窗,因此偵錯期間看起來可能「從來不會沒有回應」。
用 DoEvents(手動幫浦訊息迴圈)來避開「沒有回應」可以嗎?
不建議。在重工作中間轉動 DoEvents 或 PeekMessage 迴圈會躲過無回應視窗判定,但任何事件處理常式都可能重入──第二次按按鈕、關閉視窗、計時器等等。另一個處理常式改寫仍在處理的資料,或碰到本該關閉的表單而丟出例外,會產生依賴時機、難以重現的重入錯誤──比「沒有回應」本身更糟。正確做法是用 Task.Run 等把工作本身移到工作者執行緒,讓 UI 執行緒只負責進度顯示與接受取消。
要怎麼從工作者執行緒更新 UI(控制項)?
WinForms 控制項與 WPF 元素只能從建立它們的執行緒(通常是 UI 執行緒)碰觸。從工作者執行緒直接碰會造成例外或未定義行為。在 C# 裡,async/await 是最輕鬆的路:await 之後的接續回到呼叫端 UI 執行緒,因此 await 之後可以正常更新控制項。要明確切換時,WinForms 用 Control.Invoke/BeginInvoke,WPF 用 Dispatcher.InvokeAsync。原生 Win32 的既定做法是工作者執行緒用 PostMessage 把自訂完成訊息貼到 UI 執行緒,由視窗程序更新 UI。
要怎麼調查應用程式為什麼顯示「沒有回應」?
重要的是在卡住的「當下本身」捕捉狀態。先從工作管理員的詳細資料索引標籤用「建立傾印檔」取完整傾印,再在 WinDbg 看 UI 執行緒(跑訊息迴圈的那條)的堆疊。它是卡在同步 I/O、網路等待、鎖等待,還是經由 SendMessage 等另一條執行緒,堆疊上原樣可見。要看即時處理程序,Process Explorer 的執行緒清單與堆疊檢視有用;要長期追蹤,擷取 WPR 追蹤有效。也請見本站的 WinDbg 入門文章、Process Explorer 實戰,以及 WPR/WPA 實戰。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽