Windows アプリケーションでのハングの防止

影響を受けるプラットフォーム

クライアント - Windows 7
サーバー - Windows Server 2008 R2

説明

ハング - ユーザーパースペクティブ

応答性の高いアプリケーションが好きなユーザー。 メニューをクリックすると、現在作業を印刷している場合でも、アプリケーションがすぐに反応するように求めます。 長い文書をお気に入りのワード プロセッサに保存する場合は、ディスクの回転中に入力を続けます。 アプリケーションが入力にタイムリーに反応しない場合、ユーザーはむしろ迅速にせっかちになります。

プログラマは、アプリケーションがユーザー入力に即座に応答しない正当な理由を多く認識している可能性があります。 アプリケーションが一部のデータの再計算にビジー状態になるか、単にディスク I/O が完了するのを待っている可能性があります。 しかし、ユーザーの調査から、ユーザーはほんの数秒の無応答の後に煩わされ、不満を感じることがわかっています。 5 秒後、ハングしたアプリケーションを終了しようとします。 クラッシュの横にあるアプリケーションハングは、Win32 アプリケーションを操作するときのユーザー中断の最も一般的な原因です。

アプリケーションのハングには多くの異なる根本原因があり、それらのすべてが応答しない UI に現れるわけではありません。 ただし、応答しない UI は最も一般的なハング エクスペリエンスの 1 つであり、このシナリオでは現在、検出と回復の両方で最もオペレーティング システムのサポートを受けます。 Windows は自動的にデバッグ情報を検出、収集し、必要に応じてハングしたアプリケーションを終了または再起動します。 そうしないと、ハングしたアプリケーションを回復するために、ユーザーがマシンを再起動する必要がある場合があります。

ハング - オペレーティング システムのパースペクティブ

アプリケーション (またはより正確には、スレッド) がデスクトップ上にウィンドウを作成すると、デスクトップ ウィンドウ マネージャー (DWM) と暗黙的なコントラクトに入り、ウィンドウ メッセージをタイムリーに処理します。 DWM は、メッセージ (キーボード/マウス入力、および他のウィンドウからのメッセージ、およびそれ自体) をスレッド固有のメッセージ キューにポストします。 スレッドは、メッセージ キューを介してこれらのメッセージを取得してディスパッチします。 スレッドが GetMessage() を呼び出してキューにサービスを提供しない場合、メッセージは処理されず、ウィンドウがハングします。再描画することも、ユーザーからの入力を受け入れることもできます。 オペレーティング システムは、メッセージ キュー内の保留中のメッセージにタイマーをアタッチすることで、この状態を検出します。 メッセージが 5 秒以内に取得されていない場合、DWM はウィンドウがハングすることを宣言します。 この特定のウィンドウの状態は、IsHungAppWindow() API を使用してクエリを実行できます。

検出は最初のステップにすぎません。 この時点で、ユーザーはアプリケーションを終了することもできません。[X (閉じる)] ボタンをクリックすると、WM_CLOSEメッセージが発生し、他のメッセージと同様にメッセージ キューにスタックします。 デスクトップ ウィンドウ マネージャーは、ハングしたウィンドウをシームレスに非表示にし、元のウィンドウの以前のクライアント領域のビットマップを表示する "ゴースト" コピーに置き換えます (タイトル バーに "Not Responding" を追加します)。 元のウィンドウのスレッドがメッセージを取得しない限り、DWM は両方のウィンドウを同時に管理しますが、ユーザーはゴースト コピーのみを操作できます。 このゴースト ウィンドウを使用すると、ユーザーは移動、最小化、および (最も重要なのは) 応答しないアプリケーションを閉じるだけで、内部状態を変更することはできません。

ゴースト エクスペリエンス全体は次のようになります。

[メモ帳が応答していません] ダイアログを示すスクリーンショット。

デスクトップ ウィンドウ マネージャーは最後に 1 つの操作を行います。Windows エラー報告と統合されるため、ユーザーはアプリケーションを閉じて必要に応じて再起動するだけでなく、貴重なデバッグ データを Microsoft に送り返すこともできます。 Winqual Web サイトにサインアップすると、独自のアプリケーションのハング データを取得できます。

Windows 7 では、このエクスペリエンスに 1 つの新機能が追加されました。 オペレーティング システムはハングしたアプリケーションを分析し、特定の状況では、ブロック操作を取り消してアプリケーションを再び応答させるオプションをユーザーに提供します。 現在の実装では、ソケット呼び出しのブロックの取り消しがサポートされています。今後のリリースでは、より多くの操作がユーザーが取り消し可能になります。

アプリケーションをハング回復エクスペリエンスと統合し、使用可能なデータを最大限に活用するには、次の手順に従います。

  • アプリケーションが再起動と回復のために登録されていることを確認し、ユーザーにできるだけ痛みがないことを確認します。 適切に登録されたアプリケーションは、保存されていないほとんどのデータをそのまま使用して自動的に再起動できます。 これは、アプリケーションのハングとクラッシュの両方に対して機能します。
  • Winqual Web サイトから、頻度情報と、ハングしたアプリケーションやクラッシュしたアプリケーションのデバッグ データを取得します。 この情報は、ベータ版中でもコードを改善するために使用できます。 簡単な概要については、「Windows エラー報告の概要」を参照してください。
  • DisableProcessWindowsGhosting () の呼び出しを使用して、アプリケーションでゴースト機能を無効にすることができます。 ただし、これにより、平均的なユーザーがハングしたアプリケーションを閉じて再起動するのを防ぎ、多くの場合、再起動で終了します。

ハング - 開発者パースペクティブ

オペレーティング システムは、5 秒以上メッセージを処理していない UI スレッドとしてアプリケーションハングを定義します。 明らかなバグにより、一部のハングが発生します。たとえば、通知されないイベントを待機しているスレッドと、2 つのスレッドがそれぞれロックを保持し、他のスレッドを取得しようとしています。 これらのバグは、あまり労力をかけずに修正できます。 しかし、多くのハングはあまり明確ではありません。 はい、UI スレッドはメッセージを取得していませんが、他の "重要な" 作業を行うのも同様にビジー状態であり、最終的にはメッセージの処理に戻ります。

ただし、ユーザーはこれをバグと認識します。 設計は、ユーザーの期待と一致する必要があります。 アプリケーションの設計が応答しないアプリケーションになる場合は、デザインを変更する必要があります。 最後に、これは重要です。無応答はコードのバグのように修正できません。設計フェーズ中に事前に作業する必要があります。 アプリケーションの既存のコード ベースを改良して UI の応答性を高めようとすると、多くの場合、コストが高すぎます。 次の設計ガイドラインが役立つ場合があります。

  • UI の応答性を最上位の要件にします。ユーザーは常にアプリケーションを制御する必要がある
  • ユーザーが完了するまでに 1 秒以上かかる操作を取り消すことができるか、バックグラウンドで操作を完了できることを確認します。必要に応じて適切な進行状況 UI を提供する

[アイテムのコピー] ダイアログを示すスクリーンショット。

  • 実行時間の長い操作またはブロック操作をバックグラウンド タスクとしてキューに入れます (これには、作業が完了したときに UI スレッドに通知するための十分に考慮されたメッセージング メカニズムが必要です)
  • UI スレッドのコードをシンプルに保ちます。ブロックしている API 呼び出しをできるだけ多く削除する
  • ウィンドウとダイアログは、準備が整い、完全に動作する場合にのみ表示します。 ダイアログにリソースを大量に消費して計算できない情報を表示する必要がある場合は、最初に一般的な情報を表示し、より多くのデータが使用可能になったときにその場で更新します。 適切な例として、Windows エクスプローラーのフォルダーのプロパティ ダイアログがあります。 フォルダーの合計サイズ、ファイル システムからすぐに使用できない情報を表示する必要があります。 ダイアログがすぐに表示され、ワーカー スレッドから "size" フィールドが更新されます。

Windows プロパティの [全般] ページを示すスクリーンショット。[サイズ]、[ディスク上のサイズ]、[含む] の各テキストが丸で囲まれています。

残念ながら、応答性の高いアプリケーションを設計して記述する簡単な方法はありません。 Windows には、ブロック操作または実行時間の長い操作を簡単にスケジュールできる単純な非同期フレームワークは用意されていません。 次のセクションでは、ハングを防ぐためのベスト プラクティスをいくつか紹介し、一般的な落とし穴の一部を強調します。

推奨する運用方法

UI スレッドをシンプルに保つ

UI スレッドの主な役割は、メッセージを取得してディスパッチすることです。 他の種類の作業では、このスレッドが所有するウィンドウをぶら下げるリスクが生じます。

推奨事項:

  • 実行時間の長い操作が発生するリソース集中型または無制限のアルゴリズムをワーカー スレッドに移動する
  • できるだけ多くのブロック関数呼び出しを識別し、ワーカー スレッドに移動してみてください。別の DLL を呼び出す関数は疑わしい
  • ワーカー スレッドからすべてのファイル I/O 呼び出しとネットワーク API 呼び出しを削除する作業を追加で行います。 これらの関数は、分でない場合、何秒間もブロックできます。 UI スレッドで任意の種類の I/O を実行する必要がある場合は、非同期 I/O の使用を検討してください
  • UI スレッドは、プロセスによってホストされているすべてのシングル スレッド アパートメント (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 キャンセルを使用しますが、最後の手段としてのみ使用します。'right' 操作を取り消すのは簡単ではありません
  • IAsyncResult パターンを使用するか、イベントを使用してマネージ コードの非同期設計を実装する

ロックを賢明に使用する

アプリケーションまたは DLL には、内部データ構造へのアクセスを同期するためにロックが必要です。 複数のロックを使用すると並列処理が向上し、アプリケーションの応答性が向上します。 ただし、複数のロックを使用すると、これらのロックを異なる順序で取得し、スレッドがデッドロックする可能性も高くなります。 2 つのスレッドがそれぞれロックを保持し、もう一方のスレッドのロックを取得しようとすると、それらの操作は循環待機を形成し、これらのスレッドの進行をブロックします。 このデッドロックを回避するには、アプリケーション内のすべてのスレッドが常に同じ順序ですべてのロックを取得します。 ただし、"右" の順序でロックを取得することは常に簡単ではありません。 ソフトウェア コンポーネントは構成できますが、ロックの取得はできません。 コードが他のコンポーネントを呼び出す場合、そのコンポーネントのロックは、それらのロックを表示できない場合でも、暗黙的なロック順序の一部になります。

ロック操作には、クリティカル セクション、ミューテックス、その他の従来のロックの通常の関数よりもはるかに多くの機能が含まれているため、さらに困難になります。 スレッド境界を越えるブロック呼び出しには、デッドロックが発生する可能性がある同期プロパティがあります。 呼び出し元のスレッドは、'acquire' セマンティクスを持つ操作を実行し、その呼び出しをターゲット スレッド '解放' するまでブロックを解除できません。 非常に多くの User32 関数 (SendMessage など) と、多くのブロック COM 呼び出しがこのカテゴリに分類されます。

さらに悪いことに、オペレーティング システムには独自の内部プロセス固有のロックがあり、コードの実行中に保持されることがあります。 このロックは、DLL がプロセスに読み込まれるときに取得されるため、"ローダー ロック" と呼ばれます。 DllMain 関数は常にローダー ロックの下で実行されます。DllMain でロックを取得した場合 (ただし、ロックしない場合)、ローダー ロックをロック順序の一部にする必要があります。 特定の Win32 API を呼び出すと、LoadLibraryEx、GetModuleHandle、特に CoCreateInstance などの関数に代わってローダー ロックが取得される場合もあります。

これらすべてを結び付けるためには、以下のサンプル コードを参照してください。 この関数は、複数の同期オブジェクトを取得し、カーソル検査で必ずしも明らかではないロック順序を暗黙的に定義します。 関数の入力時に、コードは Critical セクションを取得し、関数が終了するまで解放しないため、ロック階層内の最上位ノードになります。 次に、このコードは 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 を使用して正常に終了します)
  • 独自のデッドロックを診断する場合は、Wait Chain Traversal API を使用します

次の行為は禁止とします。

  • DllMain() 関数で非常に単純な初期化作業以外の操作を行います。 詳細については、「DllMain コールバック関数」を参照してください。 特に LoadLibraryEx または CoCreateInstance を呼び出さないでください
  • 独自のロック プリミティブを記述します。 カスタム同期コードは、コード ベースに微妙なバグを簡単に導入できます。 オペレーティング システム同期オブジェクトの豊富な選択を代わりに使用する
  • グローバル変数のコンストラクターとデストラクターで作業を行うと、ローダー ロックの下で実行されます

例外に注意する

例外を使用すると、通常のプログラム フローとエラー処理を分離できます。 この分離のため、例外の前にプログラムの正確な状態を把握することは困難であり、例外ハンドラーは有効な状態を復元する際に重要な手順を見逃す可能性があります。 これは、今後のデッドロックを防ぐためにハンドラーで解放する必要があるロックの取得に特に当てはまります。

次のサンプル コードは、この問題を示しています。 "buffer" 変数への無制限のアクセスでは、アクセス違反 (AV) が発生することがあります。 この AV はネイティブの例外ハンドラーによってキャッチされますが、例外の時点でクリティカル セクションが既に取得されているかどうかを判断する簡単な方法はありません (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 (user32) コードで C++ 例外を使用します。コールバックでスローされた例外は、オペレーティング システムによって提供される C コードのレイヤーを通過します。 そのコードは、C++ のアンロール セマンティクスについて知りません