防止 Windows 應用程式中的停止回應

受影響的平臺

用戶端 - Windows 7
伺服器 - Windows Server 2008 R2

Description

停止回應 - 使用者檢視方塊

回應式應用程式等使用者。 當他們按一下功能表時,即使應用程式目前正在列印其工作,也希望應用程式立即回應。 當他們將冗長的檔儲存在慣用的字處理器中時,他們想要在磁片仍在旋轉時繼續輸入。 當使用者無法及時回應其輸入時,使用者就會變得不小心。

程式設計人員可能會辨識許多合法的原因,讓應用程式無法立即回應使用者輸入。 應用程式可能會忙碌地重新計算某些資料,或只是等候其磁片 I/O 完成。 不過,從使用者研究中,我們知道使用者在幾秒鐘沒有回應之後感到挫折和挫折。 在 5 秒之後,他們會嘗試終止無回應的應用程式。 在當機之前,應用程式停止回應是使用 Win32 應用程式時最常見的使用者中斷來源。

應用程式停止回應有許多不同的根本原因,並非所有它們都會在沒有回應的 UI 中自行資訊清單。 不過,沒有回應的 UI 是最常見的停止回應體驗之一,而此案例目前會收到偵測和復原的最作業系統支援。 Windows 會自動偵測、收集偵錯資訊,並選擇性地終止或重新開機無回應的應用程式。 否則,使用者可能必須重新開機機器,才能復原無回應的應用程式。

停止回應 - 作業系統檢視方塊

當應用程式 (或更精確時,執行緒) 在桌面上建立視窗時,它會使用桌面視窗管理員 (DWM) 進入隱含合約,以及時處理視窗訊息。 DWM 會將訊息 (來自其他視窗的鍵盤/滑鼠輸入和訊息,以及本身) 傳送到執行緒特定的訊息佇列中。 執行緒會透過其訊息佇列擷取並分派這些訊息。 如果執行緒未藉由呼叫 GetMessage () 來服務佇列,則不會處理訊息,而且視窗會停止回應:它無法重新繪製,也無法接受使用者的輸入。 作業系統會藉由將計時器附加至訊息佇列中的擱置訊息來偵測此狀態。 如果訊息未在 5 秒內擷取,DWM 會宣告視窗無回應。 您可以透過 IsHungAppWindow () API 查詢此特定視窗狀態。

偵測只是第一個步驟。 此時,使用者仍然無法終止應用程式 - 按一下 [X (關閉) ] 按鈕會導致WM_CLOSE訊息,這會卡在訊息佇列中,就像任何其他訊息一樣。 桌面視窗管理員可協助您順暢地隱藏,然後將無回應視窗取代為「准刪除」複本,其中顯示原始視窗先前工作區的點陣圖 (,並將「未回應」新增至標題列) 。 只要原始視窗的執行緒未擷取訊息,DWM 就會同時管理這兩個視窗,但允許使用者只與准刪除複製互動。 使用此准刪除視窗,使用者只能移動、最小化和 ,最重要的是關閉沒有回應的應用程式,但無法變更其內部狀態。

整個准刪除體驗看起來像這樣:

顯示 [記事本未回應] 對話方塊的螢幕擷取畫面。

桌面視窗管理員會執行最後一件事;它與Windows 錯誤報告整合,讓使用者不只關閉並選擇性地重新開機應用程式,也會將寶貴的偵錯資料傳回 Microsoft。 您可以在 Winqual 網站註冊,以取得您自己的應用程式此停止回應資料。

Windows 7 已將一項新功能新增至此體驗。 作業系統會分析無回應的應用程式,在某些情況下,讓使用者選擇取消封鎖作業,並讓應用程式再次回應。 目前的實作支援取消封鎖通訊端呼叫;未來版本中,更多作業將會是使用者可取消的作業。

若要整合您的應用程式與停止回應復原體驗,並充分利用可用的資料,請遵循下列步驟:

  • 請確定您的應用程式註冊重新開機和復原,讓使用者盡可能停止回應。 正確註冊的應用程式可以自動重新開機,其中大部分未儲存的資料都會保持不變。 這適用于應用程式停止回應和當機。
  • 從 Winqual 網站取得頻率資訊,以及偵錯您無回應和損毀應用程式的資料。 即使在 Beta 版期間,您也可以使用這項資訊來改善程式碼。 See "Introducing Windows Error Reporting" for a brief overview.
  • 您可以透過對 DisableProcessWindowsGhosting () 的呼叫,停用應用程式中的准刪除功能。 不過,這可防止平均使用者關閉並重新啟動無回應的應用程式,而且通常會在重新開機時結束。

停止回應 - 開發人員觀點

作業系統會將應用程式定義為未處理訊息至少 5 秒的 UI 執行緒。 明顯的錯誤會造成一些停止回應,例如,等候從未發出訊號的事件執行緒,而兩個執行緒分別持有鎖定並嘗試取得其他執行緒。 您可以修正這些錯誤,而不需要太多心力。 不過,許多停止回應並不明確。 是,UI 執行緒不會擷取訊息,但同樣忙碌地執行其他「重要」工作,最後會回到處理訊息。

不過,使用者會將此視為 Bug。 設計應該符合使用者的期望。 如果應用程式的設計導致沒有回應的應用程式,則設計必須變更。 最後,這很重要,無法像程式碼錯誤一樣修正無回應;它需要在設計階段進行前置工作。 嘗試讓應用程式現有的程式碼基底更能讓 UI 更具回應性,通常太昂貴。 下列設計指導方針可能會有所説明。

  • 讓 UI 回應性成為最上層需求;使用者應該一律覺得控制您的應用程式
  • 確定使用者可以取消超過一秒才能完成的作業,以及/或該作業可以在背景中完成;視需要提供適當的進度 UI

顯示 [複製專案] 對話方塊的螢幕擷取畫面。

  • 將長時間執行或封鎖作業排入佇列作為背景工作 (這需要妥善思考的傳訊機制,以在工作完成時通知 UI 執行緒)
  • 讓 UI 執行緒的程式碼保持簡單;盡可能移除許多封鎖 API 呼叫
  • 只有在視窗和對話方塊已就緒且可完全運作時,才會顯示它們。 如果對話方塊需要顯示資源密集而無法計算的資訊,請先顯示一些泛型資訊,並在更多資料可供使用時立即更新。 良好的範例是 Windows 檔案總管中的資料夾屬性對話方塊。 它必須顯示資料夾的大小總計、檔案系統無法立即取得的資訊。 對話方塊會立即顯示,而背景工作執行緒會更新 「size」 欄位:

此螢幕擷取畫面顯示 Windows 屬性的 [一般] 頁面,其中包含 [大小]、[磁片上的大小] 和 [包含] 文字圓圈。

可惜的是,沒有簡單的方法來設計和撰寫回應式應用程式。 Windows 不提供簡單的非同步架構,可讓您輕鬆排程封鎖或長時間執行的作業。 下列各節介紹一些防止停止回應的最佳做法,並強調一些常見的陷阱。

最佳做法

讓 UI 執行緒保持簡單

UI 執行緒的主要責任是擷取和分派訊息。 任何其他工作類型都會導致此執行緒擁有的視窗掛斷的風險。

建議:

  • 將耗用大量資源或未系結的演算法移至背景工作執行緒長時間執行的作業
  • 盡可能識別多個封鎖函式呼叫,並嘗試將它們移至背景工作執行緒;呼叫另一個 DLL 的任何函式都應該是可疑的
  • 請特別努力從背景工作執行緒移除所有檔案 I/O 和網路 API 呼叫。 如果不是分鐘,這些函式可能會封鎖數秒。 如果您需要在 UI 執行緒中執行任何類型的 I/O,請考慮使用非同步 I/O
  • 請注意,您的 UI 執行緒也會維護進程所裝載的所有單一執行緒 Apartment (STA) COM 伺服器;如果您進行封鎖呼叫,這些 COM 伺服器將會沒有回應,直到您再次服務訊息佇列為止

不要:

  • 等候任何核心物件 (,例如 Event 或 Mutex) 超過非常短的時間;如果您完全必須等候,請考慮使用 MsgWaitForMultipleObjects () ,這會在新訊息送達時解除封鎖
  • 使用 AttachThreadInput () 函式,與另一個執行緒共用執行緒的視窗訊息佇列。 它不僅難以正確同步處理佇列的存取權,也可以防止 Windows 作業系統正確偵測無回應視窗
  • 在任何背景工作執行緒上使用 TerminateThread () 。 以這種方式終止執行緒不會允許它釋放鎖定或訊號事件,而且可以輕鬆地造成孤立的同步處理物件
  • 從 UI 執行緒呼叫任何「未知」程式碼。 如果您的應用程式具有擴充性模型,這特別適用;不保證協力廠商程式碼遵循您的回應性指導方針
  • 進行任何類型的封鎖廣播呼叫;SendMessage (HWND_BROADCAST) 讓您不受目前執行之每一個寫入不正確的應用程式

實作非同步模式

從 UI 執行緒移除長時間執行或封鎖作業需要實作非同步架構,以允許將這些作業卸載至背景工作執行緒。

建議:

  • 在 UI 執行緒中使用非同步視窗訊息 API,特別是將 SendMessage 取代為其中一個非封鎖的對等:PostMessage、SendNotifyMessage 或 SendMessageCallback
  • 使用背景執行緒來執行長時間執行或封鎖工作。 使用新的執行緒集區 API 來實作您的背景工作執行緒
  • 提供長時間執行背景工作的取消支援。 針對封鎖 I/O 作業,請使用 I/O 取消,但只做為最後一個方式;取消「正確」作業並不容易
  • 使用 IAsyncResult 模式或使用事件實作 Managed 程式碼的非同步設計

以明智方式使用鎖定

您的應用程式或 DLL 需要鎖定,才能同步存取其內部資料結構。 使用多個鎖定會增加平行處理原則,並讓您的應用程式更具回應性。 不過,使用多個鎖定也會增加以不同順序取得這些鎖定的機會,並造成執行緒死結。 如果兩個執行緒各自持有鎖定,然後嘗試取得另一個執行緒的鎖定,其作業會形成迴圈等候,以封鎖這些執行緒的任何向前進度。 您只能藉由確保應用程式中的所有線程一律以相同順序取得所有鎖定,以避免這種死結。 不過,以「正確」順序取得鎖定並不一定容易。 軟體元件可以組成,但無法取得鎖定。 如果您的程式碼呼叫其他元件,該元件的鎖定現在會成為隱含鎖定順序的一部分,即使您無法看到這些鎖定也一樣。

因為鎖定作業比重要區段、Mutex 和其他傳統鎖定的一般函式還多,所以會更困難。 任何跨執行緒界限的封鎖呼叫都有可能導致死結的同步處理屬性。 呼叫執行緒會執行具有 'acquire' 語意的作業,而且在呼叫的目標執行緒 'releases' 之前無法解除封鎖。 許多 User32 函式 (例如 SendMessage) ,以及許多封鎖 COM 呼叫都屬於此類別。

更糟的是,作業系統有自己的內部進程特定鎖定,有時會在程式碼執行時保留。 當 DLL 載入進程時,會取得此鎖定,因此稱為「載入器鎖定」。 DllMain 函式一律會在載入器鎖定下執行;如果您在 DllMain (中取得任何鎖定,且不應該) ,則必須將載入器鎖定部分設為鎖定順序。 呼叫特定 WIN32 API 也可能代表您取得載入器鎖定 - LoadLibraryEx、GetModuleHandle 等函式,特別是 CoCreateInstance。

若要將所有專案系結在一起,請查看下列範例程式碼。 此函式會取得多個同步處理物件,並隱含定義鎖定順序,這在游標檢查上不一定明顯。 在函式專案上,程式碼會取得「重大區段」,而且在函式結束之前不會釋出它,藉此讓它成為鎖定階層中的最上層節點。 然後,程式碼會呼叫 Win32 函式 LoadIcon () ,其底下可能會呼叫作業系統載入器以載入此二進位檔。 這項作業會取得載入器鎖定,現在也會成為此鎖定階層的一部分, (確定 DllMain 函式不會取得g_cs鎖定) 。 接下來,程式碼會呼叫 SendMessage () ,這是封鎖的跨執行緒作業,除非 UI 執行緒回應,否則不會傳回。 同樣地,請確定 UI 執行緒永遠不會取得g_cs。

bool foo::bar (char* buffer)  
{  
      EnterCriticalSection(&g_cs);  
      // Get 'new data' icon  
      this.m_Icon = LoadIcon(hInst, MAKEINTRESOURCE(5));  
      // Let UI thread know to update icon SendMessage(hWnd,WM_COMMAND,IDM_ICON,NULL);  
      this.m_Params = GetParams(buffer);  
      LeaveCriticalSection(&g_cs);
      return true;  
}  

查看此程式碼似乎很明顯,即使我們只想要同步處理類別成員變數的存取權,我們還是會隱含地g_cs鎖定階層中的最上層鎖定。

建議:

  • 設計鎖定階層並遵守它。 新增所有必要的鎖定。 比起 Mutex 和 CriticalSections,有更多同步處理基本類型;它們全都需要包含。 如果您在 DllMain () 中取得任何鎖定,請在階層中包含載入器鎖定
  • 同意使用您的相依性鎖定通訊協定。 應用程式呼叫的任何程式碼,或可能呼叫應用程式的程式碼都必須共用相同的鎖定階層
  • 鎖定資料結構不是函式。 將鎖定擷取從函式進入點移開,並只保護具有鎖定的資料存取。 如果程式碼在鎖定下運作較少,則死結的機會較少
  • 分析錯誤處理常式代碼中的鎖定取得和發行。 嘗試從錯誤狀況復原時忘記鎖定階層通常
  • 以參考計數器取代巢狀鎖定 - 它們無法死結。 清單和資料表中的個別鎖定元素是很好的候選項目
  • 等候 DLL 中的執行緒控制碼時,請小心。 一律假設您的程式碼可以在載入器鎖定下呼叫。 最好是參考計算資源計數,並讓背景工作執行緒自行清除 (,然後使用 FreeLibraryAndExitThread 完全終止)
  • 如果您想要診斷自己的死結,請使用等候鏈結周遊 API

不要:

  • 在 DllMain () 函式中,執行非常簡單的初始化工作以外的任何動作。 如需詳細資訊,請參閱 DllMain 回呼函式。 特別是不要呼叫 LoadLibraryEx 或 CoCreateInstance
  • 撰寫您自己的鎖定基本類型。 自訂同步處理常式代碼可以輕鬆地將細微錯誤引入您的程式碼基底。 請改用作業系統同步處理物件的豐富選擇
  • 對全域變數執行建構函式和解構函式中的任何工作,這些變數會在載入器鎖定下執行

請小心使用例外狀況

例外狀況允許區隔一般程式流程和錯誤處理。 由於這種分隔,在例外狀況之前,很難知道程式的精確狀態,而例外狀況處理常式可能會遺漏還原有效狀態的重要步驟。 這特別適用于需要在處理常式中釋放的鎖定擷取,以避免未來的死結。

下列範例程式碼說明此問題。 「緩衝區」變數的未系結存取偶爾會導致存取違規, (AV) 。 原生例外狀況處理常式會攔截此防毒軟體,但無法輕易地判斷在例外狀況 (防毒軟體是否已在 EnterCriticalSection 程式碼) 的某處取得重大區段。

 BOOL bar (char* buffer)  
{  
   BOOL rc = FALSE;  
   __try {  
      EnterCriticalSection(&cs);  
      while (*buffer++ != '&') ;  
      rc = GetParams(buffer);  
      LeaveCriticalSection(&cs);  
   } __except (EXCEPTION_EXECUTE_HANDLER)  
   {  
      return FALSE;  
   } 
   return rc;  
}  

建議:

  • 盡可能移除__try/__except;請勿使用 SetUnhandledExceptionFilter
  • 如果您使用 C++ 例外狀況,請將您的鎖定包裝在類似自訂auto_ptr範本中。 解構函式中應該釋放鎖定。 針對原生例外狀況,請釋放 __finally 語句中的鎖定
  • 請小心在原生例外狀況處理常式中執行的程式碼;例外狀況可能會流失許多鎖定,因此您的處理常式不應該取得任何鎖定

不要:

  • 如果 WIN32 API 不需要或必要,請處理原生例外狀況。 如果您在發生重大失敗後使用原生例外狀況處理常式來報告或資料復原,請考慮改用預設的作業系統機制Windows 錯誤報告
  • 搭配任何一種 UI 使用 C++ 例外狀況, (user32) 程式碼;回呼中擲回的例外狀況會通過作業系統所提供的 C 程式碼層。 該程式碼不知道 C++ 取消註冊語意