次の方法で共有


ハングは無用

.NET アプリケーションでのデッドロックの回避と検出の拡張技法

Joe Duffy


この記事で取り上げる話題:

  • デッドロックが発生する経緯の理解
  • ロックのレベル付けを使用したデッドロックの回避
  • デッドロックの検出および打破
  • デッドロックの検出のためのカスタム CLR ホストの検査

この記事で使用する技術:

  • .NET Framework、C#、C++

サンプルコードのダウンロード: Deadlocks.exe (188KB)
翻訳元: Advanced Techniques To Avoid And Detect Deadlocks In .NET Apps (英語)


目次

  1. デッドロックの概要
  2. その他の微妙なデッドロックの例
  3. ロックのレベル付けを使用したデッドロックの回避
  4. デッドロックの検出および打破
  5. アルゴリズム
  6. ホストする側の API を通した考察
  7. 待機グラフの作成および全探索
  8. カスタム デッドロック ホストの稼働
  9. まとめ

アプリケーションのハングは、ユーザーにとって最も煩わしい出来事の 1 つです。これを出荷前に見つけるのはきわめて困難であり、アプリケーションの配布後にデバッグするのはそれよりはるかに困難です。アプリケーション ハングは、クラッシュとは違って、クラッシュ ダンプを生成することも、カスタムの障害論理をトリガすることもありません。そのような情報が提示される前に、凍結したアプリケーションをユーザーがシャットダウンすることはよくあります。それは、問題の原因を見つけ出して真相を暴くのに使用できるスタック トレースはないことを意味します。

ハングの範囲は、一時的なものから永続的なものまで多岐にわたります。その原因には、遅い入出力、計算の面倒なアルゴリズム、あるいはリソースに対する相互に排他的なアクセスなどがありますが、どの場合も、アプリケーションの全体的な応答性能が低下します。GUI スレッドの実行を妨害するコードは、行われたユーザー入力およびシステム生成メッセージをウィンドウが処理するのを妨げることがあります。その結果、アプリケーションは「応答していません」ということになります。GUI 以外のプログラムでも、共用リソースの使用時やスレッド間、プロセス間、あるいはマシン間の通信の実行時に、応答性に関する問題が起きる可能性があります。  明らかに、最悪のタイプのハングは、回復しないハング、すなわち、デッドロックです。

スレッドおよびロックの標準 Windows 共用メモリ ツールキットを使用した、大量の同時アプリケーションのエンジニアリングは、一見しただけの印象よりはるかに複雑な作業になります。稼働中のメモリをロックし忘れると、良くてクラッシュ、最悪のケースではデータの破壊につながる競合状況を引き起こすことがあります。正確さと、順次パフォーマンス (きめの粗いロック)、および平行スケーラビリティ (きめの細かいロック) の相互の間には、トレードオフがあります。それぞれが多様なきめの細かさでロックの獲得と解放を行うそれぞれ異なるセグメントどうしは、予見できないやり方で互いに対話する傾向があります。システムがデッドロックを軽減する措置を何もとっていないと想定した場合、わずかな手違いがあっても、プログラムの複雑な仕組みが阻害される可能性があります。

今日の共用メモリ プログラミング モデルでは、プログラマは、プログラムの実行全体を通して各種の命令が錯綜する可能性を考慮に入れる必要があります。想定外の事態が起きた場合、その事態を招いたステップを再現するのが非常に困難なこともあります。デッドロックは、競合よりはっきり目に付きます (一番単純なケースでは、プログラムが途中で完全に停止するだけです) が、やはり見つけ出して排除するのは困難です。通常の一連の機能テストで、たまたまそのような箇所がほんの少数見つかることはありますが、たいていは、製品の出荷時には気付かれないまま、疑ってもいない顧客のところで出現する場合のほうが多いようです。その原因が見つかった場合でも、既に窮地に立たされていることがあります。

規律化されたさまざまなロック処置の組み合わせと、積極的なストレス テストを特定の範囲のハードウェア プラットフォームを対象に使用することで、デッドロックに対処することができます。前者の規律は、この記事の基本のトピックです。その前に、デッドロックの基本事項をおさらいしましょう。


1. デッドロックの概要

デッドロックが発生するのは、並行作業中のワーカーたちが、いずれかのワーカーが先に進めるようになるまで、互いに相手が先に進むのを待っている場合です。逆説のように聞こえるかもしれませんが、そのとおりです。デッドロックを生じさせるためには、次のような、4 つの一般的な特性がなければなりません。

相互排他

1 つのスレッドがある特定のリソースを所有していると、他のスレッドはそのリソースを獲得できません。これは、最もクリティカルなセクションに当てはまりますが、Windows の GUI にも当てはまります。どのウィンドウも 1 つのスレッドによって所有されますが、このスレッドは、着信メッセージの処理のみを担当します。処理できなかった場合、良くて応答性の喪失につながり、極端な場合はデッドロックにつながります。

リソースを保有するスレッドは無期限の待機が可能

たとえば、スレッドがクリティカル セクションに入ると、コードは通常は、さらに別のクリティカル セクションを自由に獲得します。この場合、ターゲットのクリティカル セクションがすでに別のスレッドによって保有されていると、通常はブロッキングが発生します。

リソースをその現在の所有者から強制的に取り上げられない

複雑なデータベース管理システム (DBMS) の場合のように、状況によっては、競合が検出されるとリソースが奪取されることもあり得ます。これは一般的に、Windows プラットフォーム上のマネージ コードが使用できるロック プリミティブには当てはまりません。

循環待ち状態

循環待機が発生するのは、複数のスレッドのチェーンが、チェーン内の次のメンバによって保持されているリソースを待機する場合です。再入不能のロックの場合、1 つのスレッドが自身との間のデッドロックの原因になることがあります。大半のロックは再入可能なので、その可能性はありません。

悲観主義的アルゴリズムを使用して作業したことのあるプログラマはすべて、デッドロックがどのように起きるかを既に理解しているはずです。悲観主義的アルゴリズムは、共用リソースの獲得を試みて、競合を検出した場合、通常はそのリソースが使用可能になるまで待機する処置をとります (たとえば、ブロッキングします)。これを楽観主義的アルゴリズムと比較してみます。こちらのアルゴリズムは、後になって、たとえばトランザクションがコミットを試みたときに、競合が検出されるリスクを犯してでも先に進もうとします。一般的に、悲観主義のほうが簡単にプログラムでき、言い訳ももっともらしく聞こえます。しかも、プラットフォーム上では、楽観主義的技法と比較して、悲観主義のほうがはるかにありふれています。これは、通常は、モニター (C# ロック ブロックあるいは Visual Basic の SyncLock ブロック)、mutex、あるいは Win32 CRITICAL_SECTION の形態をとります。

競合を検出して対処できるロックフリーあるいはインターロックベースのアルゴリズムは、システムレベル ソフトウェアでは比較的よく使用されます。多くの場合、このアルゴリズムは、ファースト パス内のクリティカル セクションにはまったく入らずに、デッドロックではなくライブロックの処理のほうを選択します。ただし、ライブロックの場合、平行コードにも対処する必要があります。ライブロックは、きめの細かい競合に起因するからです。その結果は、デッドロックとほぼ同じように、作業の進行の停止になります。ライブロックの効果の説明として、廊下で互いに相手を追い越そうとしている 2 人の人物を想像してください。2 人とも、相手の進路の邪魔にならないように端によって進もうとしますが、そうすることで、どちらも前に進めなくなります。

デッドロックがどのように起きるかをさらに詳しく解説するために、次のような一連のイベントを想定してください。

  1. スレッド 1 はロック A を獲得します。
  2. スレッド 2 はロック B を獲得します。
  3. スレッド 1 はロック B の獲得を試みますが、そのロックは既にスレッド 2 によって保持されているので、スレッド 1 は、B が解放されるまでブロックします。
  4. スレッド 2 はロック A の獲得を試みますが、そのロックは既にスレッド 1 によって保持されているので、スレッド 2 は、A が解放されるまでブロックします。

この時点で、どちらのスレッドもブロック化されて、ウェイク アップしなくなります。図 1 の C# コードはそのような状況を表しています。

図 1 古典的デッドロック

object lockA = new object();
object lockB = new object();

// スレッド 1
void t1() {
  lock (lockA) {
    lock (lockB) {
      /* ... */
    }
  }
}

// スレッド 2
void t2() {
  lock (lockB) {
    lock (lockA) {
      /* ... */
    }
  }
}

図 2 は、t1 および t2 が相互に競合しあった状態になった場合に起こりうる 6 通りの結果を概略しています。 2 つのケース (1 および 4) を除くすべてのケースで、デッドロックが起きます。意に反して、3 回のうち 2 回はデッドロックが起きる (6 通りの結果のうちの 2 つはデッドロックに結びつくため) と結論付けることはできません。なぜなら、2、3、5、あるいは 6 を生成するのに要する短期の時間枠は、統計的に見て、1 および 4 ほど頻繁に起きないからです。

図 2

ケース 結果
1 t1 が A をロックする
t1 が B をロックする
t2 が B をロックする
t2 が A をロックする
2
t1 が A をロックする
t2 が B をロックする
t2 が A をロックする
t1 が B をロックする
3
t1 が A をロックする
t2 が B をロックする
t1 が B をロックする
t2 が A をロックする
4 t2 が B をロックする
t2 が A をロックする
t1 が A をロックする
t1 が B をロックする
5
t2 が B をロックする
t1 が A をロックする
t2 が A をロックする
t1 が B をロックする
6
t2 が B をロックする
t1 が A をロックする
t1 が B をロックする
t2 が A をロックする

統計がどうであっても、このようなコードを書く場合は、明らかにナイフの刃の上でプログラムのバランスをとることになります。遅かれ早かれ、プログラムは、この 4 種類のケースのいずれかのデッドロックに突き当たります。その原因は、ソフトウェアをテストするのに使用したことのない新しいハードウェアが顧客側で購入されたためか、あるいは優先順位の高いスレッドが稼働可能になったために、コンテキストが 1 つの命令から別の命令に切り替えられたためと考えられます。それによって、プログラムは突然停止します。

上記で示した例は簡単に確認でき、それ相応に簡単に修正できます。すなわち、一貫した順序でロックの取得と解放が行われるように t1 と t2 を書き直せばよいだけです。つまり、t1 と t2 は両方ともまず A を獲得した後で B を獲得する (あるいはこの逆) ようにコードを書き直して、いわゆる致命的な絡み合いが起きないようにします。他方、次のような、デッドロックに結びつきやすいロック プロトコルのもっと微妙なケースを考察してみます。

void Transfer(Account a, Account b, decimal amount) {
  lock (a) {
    lock (b) {
      if (a.Balance < amount)
        throw new InsufficientFundsException();
      a.Balance -= amount;
      b.Balance += amount;
    }
  }
}

ある人が、口座 #1234 から口座 #5678 に 500 ドル振り込もうとしたときに、別の人が、口座 #5678 から口座 #1234 に 1,000 ドル振り込もうとした場合、デッドロックが起きる可能性は高くなります。このような、複数のスレッドがそれぞれ異なる変数を使用して 1 つのオブジェクトを参照するという、参照の別名割り当ては頭痛の種ではありますが、きわめてよくあることです。ユーザー コードで実装されている仮想メソッドの呼び出しを行った場合も同様に、コール スタックにつながる可能性があります。コール スタックでは、想定外の順序でそれぞれのロックが動的に獲得されるので、スレッドによって同時に保有されるロックが予測不能の組み合わされ方をして、デッドロックの実際のリスクの原因となります。

ページのトップへ


2. その他の微妙なデッドロックの例

デッドロックのリスクは、相互に排他的なクリティカル セクションにのみ限定されません。プログラム中で、もっと微妙な経緯で実際のデッドロックが発生することがあります。ソフト デッドロックとは、アプリケーションはデッドロックしたように見える一方で、実際には大きな待ち時間、あるいは、やりとりの多い操作の実行が停滞しているにすぎないデッドロックのことですが、このデッドロックも問題になります。たとえば、計算に重点が置かれていて、GUI スレッド上で稼働するアルゴリズムは、応答性の完全な喪失につながることがあります。そのような場合は、ThreadPool を使用して (.NET Framework 2.0 では、新しい BackgroundWorker コンポーネントを使用したほうが良いかもしれません) 作業が行われるようにスケジューリングするほうがましな選択肢かもしれません。

プログラムから呼び出す各メソッドのパフォーマンス特性を理解することは重要ですが、実際問題としては非常に困難です。ある操作から、広範囲を対象とするパフォーマンス特性を持った別の操作を起動すると、予測不能の待ち時間、ブロッキング、および CPU の集中使用を生じる可能性があります。そのような起動を行うときに、呼び出し元がリソースに対して排他ロックをかけた場合、事態はさらに悪化します。いずれかの任意の煩雑な操作の実行中に、共用リソースへのアクセスがシリアライズ化されてしまいます。アプリケーションの各パーツは、一般的なケースでは良好なパフォーマンスを保つ一方で、いずれかの環境条件が発生した場合は、劇的に変化する可能性があります。ネットワークの入出力が好例です。これは、平均して中から高の程度の待ち時間を持っていますが、ネットワーク接続がいっぱいになったり使用不可になると、大幅に劣化します。多数のネットワーク入出力がアプリケーションに組み込まれている場合、ネットワーク ケーブルをコンピュータから引き抜いたときにソフトウェアがどのように稼働する (あるいは稼働しない) かの慎重なテストをあらかじめ完了しておくことをお勧めします。

シングル スレッド アパートメント (STA) スレッド上でコードを実行すると、排他ロックと同等のロックが発生します。 1 つのスレッドのみが、GUI ウィンドウを更新したり、STA 内のアパートメント スレッド COM コンポーネント内でコードを一度に実行したりすることができます。そのようなスレッドは、メッセージ キューを所有し、そこには、システムおよびアプリケーションの他のパーツによって処理される予定の情報が入れられます。 GUI は、再描画の要求、処理しようとしている装置入力、およびウィンドウのクローズ要求などの情報のためにこのキューを使用します。COM プロキシは、メッセージ キューを使用して、クロス アパートメント メソッド呼び出しを、コンポーネントとの親和性のあるアパートメントに遷移します。STA 上で実行されるすべてのコードは、メッセージ キューをポンプする責任を負います。それは、メッセージ ループを使用して新規のメッセージを見つけ出して処理するためです。そうしないと、キューは詰まってしまって、応答性が失われてしまいます。Win32 に関する限り、これは、MsgWaitForSingleObject、MsgWaitForMultipleObjects (およびその Ex 対照物)、あるいは CoWaitForMultipleHandles API を使用することを意味します。WaitForSingleObject や WaitForMultipleObjects (およびその Ex 対照物) などの非ポンプ待機が、受信メッセージをポンプすることはありません。

言い換えると、STA の「ロック」を解放できるのは、メッセージ キューのポンプによってのみということになります。上記のような、メッセージのポンプを行わない GUI スレッド上で大きく変化するパフォーマンス特性をもった操作を実行するアプリケーションは、簡単にデッドロックを生じることがあります。的確に作成されたプログラムでは、そのような長時間実行の作業はどこか他所で行われるようにスケジュールするか、あるいはブロッキングのたびにメッセージのポンプを行って、この問題が起きないようにしています。ありがたいことに、マネージ コードでのブロッキングのたびに、CLR は自動的にポンプを行って (連続的な Monitor.Enter、WaitHandle.WaitOne、FileStream.EndRead、Thread.Join などの呼び出しを介して)、この問題の軽減に努めています。それでも、多くのコード (しかも .NET Framework そのものの一部でさえ) は、アンマネージ コード中で結局ブロッキングすることになります。その場合、ブロッキング コードの作者によってポンプ待ちが追加されていたかどうかは関係ありません。

以下に、STA 誘引のデッドロックの古典的な例を示します。STA で実行されるスレッドは、大量のアパートメント スレッド COM コンポーネント インスタンスを生成するとともに、それに対応するランタイム呼び出し可能ラッパー (RCW) を暗黙で生成します。当然、そのような RCW は、到達不能になった時点で、CLR によってファイナライズされる必要があります。そうしないと、リークを生じます。ただし、CLR のファイナライザ スレッドは、常にプロセスのマルチスレッド アパートメント (MTA) を結合します。それは、RCW に対して Release を呼び出すために、STA への遷移を行うプロキシを使用する必要があることを意味します。特定の RCW に対して Finalize メソッドを起動しようとするファイナライザからの受信のためのポンプを STA が行わない (おそらく、非ポンプ待機を使用してブロッキングすることを選択したため) 場合、ファイナライザ スレッドは停滞してしまいます。STA がブロック解除してポンプしない限り、このスレッドはブロックされたままになります。STA がまったくポンプしない場合、ファイナライザ スレッドは進行しなくなり、やがて、ファイナライズ可能なすべてのリソースのゆっくりしたサイレント ビルドアップが発生します。そのような場合、ASP.NET では、その結果のメモリ不足クラッシュあるいはプロセスのリサイクルにつながっていくことがあります。明らかに、どちらの結果も望ましくありません。

Windows フォーム、Windows Presentation Foundation、および COM などの高水準フレームワークでは、STA の複雑さの大半は隠されていますが、デッドロックを含め、やはり予測不能な経緯で障害を起こす可能性があります。COM 同期コンテキストの場合、少しの違いはあっても、やはり似たような問題をかかえています。しかも、そのような障害の多くが発生するのは、テスト実行のごく一部においてのみであり、多くの場合は高ストレス下においてのみです。

残念なことに、デッドロックのタイプが異なれば、異なった技法に取り組む必要があります。この記事の後半では、クリティカル セクションのデッドロックにのみ焦点をあてています。CLR 2.0 には、STA の遷移問題を捕捉してデバッグするための便利なツールが付属しています。新規のマネージ デバッグ アシスタント (MDA: Managed Debugging Assistant) である ContextSwitchDeadlock は、トランジションが誘引するデッドロックをモニタするために作成されました。遷移の完了までに 60 秒より長くかかっている場合、CLR は、受信側の STA がデッドロックしていると想定し、この MDA を起動します。この MDA を使用可能にする方法の詳細は、MSDN ドキュメントを参照してください。

クリティカルセクションベースのデッドロックを処理するには、2 種類の一般的な対処法があります。

デッドロックの回避

要するに、上記で示した 4 通りの条件のうちの 1 つを排除します。たとえば、複数のリソースが 1 つのリソースを共有できるようにする (通常はスレッドの安全上、不可能です) か、ロックの保有時にブロッキングがまったく起きないようにするか、あるいは循環待機を排除します。そのためには、ある特定の構造化された規律が必要になります。残念なことに、それによって並行ソフトウェアのオーサリングに対して顕著なオーバーヘッドが付け加えられることがあります。

デッドロックの検出および軽減

たいていのデータベース システムでは、ユーザー トランザクションに対してこの技法が用いられています。デッドロックの検出は簡単明瞭ですが、それに対する応答はもっと難しくなります。概して、デッドロック検出システムは、犠牲者を選択し、それを強制的に異常終了させてそのロックを解放することによって、デッドロックを打破します。任意のマネージ コードでこのような技法を使用すると、不安定性の元になることがあるので、この技法は慎重に用いる必要があります。

多くの開発者は、理論的なレベルではデッドロックの可能性を了解していますが、デッドロックに真正面から取り組む方法を知っている開発者はほとんどいません。この両方のカテゴリに当てはまるいくつかのソリューションを見てみましょう。

ページのトップへ


3. ロックのレベル付けを使用したデッドロックの回避

大型のソフトウェア システムでのデッドロックに対処するときによく使用されるアプローチは、ロックのレベル付けと呼ばれる (ロック階層あるいはロックの順序付けとも呼ばれます) 技法です。この対処法では、すべてのロックを数値レベルに振り分けて、システム内のアーキテクチャの個々の層上のコンポーネントが、それより下位のレベルでのみロックを獲得できるようにします。たとえば、最初の例で、ロック A にレベル 10 を、ロック B にレベル 5 を割り当てたとします。すると、コンポーネントが A を獲得してから B を獲得することは適正な措置となります。なぜなら、B のレベルは A より低いからです。ただし、この逆は完全に不正な措置です。これによって、デッドロックの可能性は排除されます。ソフトウェア アーキテクチャの依存関係を手際よく振り分けることは、広く受け入れられているエンジニアリング規律です。

当然、基本的なロックのレベル付けの考え方の変種の考え方もあります。そのような変種を C# で実装しました。これは、本記事のダウンロード用コード中に用意されています。LeveledLock のインスタンスは、1 つの共用ロック インスタンスに対応します。これは、System.Threading.Monitor クラスを通して Enter および Exit を行うのに使用するオブジェクトによく似ています。インスタンス化時に、ロックは次のように int ベースのレベルを割り当てられます。

LeveledLock lockA = new LeveledLock (10);
LeveledLock lockB = new LeveledLock (5);

通常、プログラムは、たとえばプログラムの残りの部分からアクセスされる静的フィールドを使用して、中央ロケーション内のすべてのロックを宣言します。

Enter および Exit メソッドの実装は、基盤のプライベート モニタ オブジェクトを呼び出します。このタイプはまた、スレッド ローカル記憶領域 (TLS) での一番新しいロック獲得の追跡記録をとっているので、ロック階層に違反するようなロックの獲得を行えないようにすることができます。したがって、誰かが不用意に B を獲得してから A を獲得する (誤った順序) ような事態を完全に排除できるので、循環待機の可能性はなくなり、それによってデッドロックが発生することもなくなります。以下を参照してください。

// スレッド 1
void t1() {
  using (lockA.Enter()) {
    using (lockB.Enter()) {
      /* ... *
} } }

// スレッド 2
void t2() {
  using (lockB.Enter()) {
    using (lockA.Enter()) {
      /* ... */
} } }

t2 を実行しようとすると、LockLevelException がスローされます。これは、lockA.Enter 呼び出しを発生元とし、ロック階層に対する違反があったことを示します。コードを出荷する前に、ロック階層のすべての違反を見つけ出して修正するために、積極的にテストを実施する必要があることは明らかです。未処理の例外があると、ユーザーにとって不満足な結果を招きます。

Enter メソッドは IDisposable オブジェクトを戻すことに注意してください。それによって、ステートメントを使用する C# を使用できるようになります (ロック ブロックによく似ています)。これは、有効範囲から出るときに、Exit を暗黙で呼び出します。新規のロックのインスタンス化時に、コンストラクタ パラメータを通して利用できる他のいくつかのオプションがあります。再入可能パラメータは、ロックが既にかけられているときにその同じロックを再獲得できるかどうかを指示します。これは既定では true です。名前パラメータはオプションですが、デバッグの性能を向上するためにロックに名前を付けるときに使用できます。

既定では、レベル内ロックを獲得することはできません。すなわち、レベル 10 でロック A を保有している場合に、レベル 10 でロック C を獲得しようとすると、その獲得は失敗します。これを許可すると、ロック階層に対する直接の違反になります。なぜなら、2 つのスレッドがそれぞれ逆の順序 (Aと C に対して C と A) でロックの獲得を試みることになるからです。permitIntraLevel パラメータをとる Enter オーバーロードに対して true を指定すれば、このポリシーを指定変更することができます。ただし、指定変更した場合、デッドロックが起きる可能性が再び生じます。コードを慎重に見直して、ロック階層に対する明示的な違反が原因でデッドロックが起きないことを確認してください。ただし、そこでどのように多くの作業を行っても、ロックのレベル付けの厳重なプロトコルほど確実な成果は上げられません。この機能は、特に慎重に使用してください。

ロックのレベル付けには確かに効果はありますが、何も問題がないわけではありません。ソフトウェア コンポーネントのダイナミック コンポジションは、想定外の実行時の障害に結びつくことがあります。低レベルのコンポーネントがロックを保有している場合に、ユーザー提供オブジェクトに対して仮想メソッド呼び出しを行い、そしてそのユーザー オブジェクトが次に、それより高いレベルのロックの獲得を試みると、ロック階層違反例外が生成されます。 デッドロックは発生しませんが、ランタイム障害は発生します。これが、ロックを保有しているときに仮想メソッド呼び出しを行うことは、望ましくない処置であると一般的に考えられている理由の 1 つです。それは、デッドロックのリスクを犯すよりはまだましである一方で、この技法がデータベースで用いられない主な理由です。データベースでは、ユーザー トランザクションのダイナミック コンポジションを完全に有効化する必要があるからです。

実際問題として、多くのソフトウェア システムは、同一レベルのロックのかたまりに行き着きます。permitIntraLevel を使用すれば、いくつかの呼び出しが見つかります。これはめったに起きませんが、それは、開発者がきわめて賢明あるいは慎重であったからではなく、ロックのレベル付けが難しく、比較的面倒な作業であるからです。デッドロックのリスクを承知のうえで受け入れるか、あるいは、テスト中の出現や自然発生の可能性は低いと見越して、そんなリスクは存在しないふりをしたほうが、単一ロック獲得を達成するために、コードの依存関係を初めから書き直すのに何時間も費やすよりもはるかに簡単です。 ロックがかかっている時間の最小化といった、規律下に置かれたロックは、開発者の静的な制御下にあるコードを呼び出して、必要があればデータの予防的なコピーをとるだけなので、そのようなロックを使用して、ロック階層の範囲内での活動を単純化することができます。

多くのロックのレベル付けシステムは、非デバッグ ビルドではオフにされます。それは、実行時のロック レベルの保守と検査という、パフォーマンス上の代償を払わなくて済むようにするためです。ただしそれは、ロック プロトコルのすべての違反を暴くために、アプリケーションをテストする必要があることを意味します。ダイナミック コンポジションによって、それは非常に困難になります。出荷時にロックのレベル付けをオフにする場合、1 つでも見落としたケースがあると、実際のデッドロックにつながります。理想的なロックのレベル付けシステムは、コンパイラあるいは静的な分析サポートを介して、階層違反を統計的に検出および防止するシステムです。そのような技法を使用する研究はありますが、残念ながら、マネージ アプリケーションで使用できる主流製品はありません。デッドロックの防止にとって替わる第 2 の技法として、デッドロック検出を使用できる場合もあります。

ページのトップへ


4. デッドロックの検出および打破

これまで解説してきた技法は、デッドロック全体を回避するために役立ちます。しかし、多くのコードは、ロックのレベル付けを丸ごと取り入れるのに適した書かれ方をしていません。通常、デッドロックが発生すると、それはハングとして現れますが、その際、障害情報が捕捉および報告されることもされないこともあります。しかし、システムがデッドロックをまったく回避できない場合でも、バグの修正を可能にするために、デッドロックが発生したらそれを検出して速やかに報告するのが理想的です。それは、ロックのレベル付けをアプリケーションに取り入れていない場合でも可能です。

最近はどのデータベースでもデッドロック検出を利用していますが、任意のマネージ アプリケーションでそれと同じ技法を用いるのは、実際問題として困難です。データベース トランザクションが競合している場合、デッドロックにつながるようなやり方でロックの獲得が試みられると、DBMS は、それまでに時間とリソースの使用量が最も低かったトランザクションを強制終了し、何事もなかったかのように他のすべての関与トランザクションが続行できるようにすることができます。デッドロックの犠牲となった操作は、DBMS によって自動的にロールバックされます。その後、アプリケーションはデータベース呼び出しを起動して、いずれかの処置をとることができます。たとえば、トランザクションをやり直すか、あるいは、再試行、キャンセル、あるいはアクションの変更を行うオプションをユーザーに提示します。たとえば、チケット システムでデッドロックが起きた場合、処理の続行のために予約チケットが別の顧客にまわされることもあり得ます。そのような場合、ユーザーはチケット予約トランザクションをやり直す必要があります。

ロックの獲得に起因するデッドロックを取り扱うようには書かれていない平均的なマネージ アプリケーションは、ロックを獲得しようとしたことが原因の例外に要領よく対処する準備はできていないはずです。プロセスのクラッシュが、唯一可能な処理です。

SQL Server 2005 でホストされるマネージ コードは、この記事で述べているものに似たデッドロック検出機能を備えています。すべてのマネージ コードは暗黙でトランザクション内にラップされるので、この機能は、SQL Server のデータをベースとする通常のデッドロック検出と連係して大いに威力を発揮します。

デッドロックを検知する精巧なマネージ コードを作成して、累積したロックのバックオフを 1 つずつ試みることができます。たとえば、コードがロック A および B を保有していて、C を獲得しようとしたときにデッドロック障害が起きた場合、そのコードは、B を獲得した時点以降に加えた変更をロールバックし、B を解放し、実行権を放棄することによって、他のスレッドが作業を続行し、B を獲得し、前にロールバックされた変更内容を実行してから、C の獲得を試みられるようにすることができます。それが、再びデッドロックのために失敗した場合、最初の A の獲得に戻ってやり直すことができます。

CLR のホストする側のインターフェイスを使用して、モニタの獲得および解放のカスタム論理を注入することができます。この機能を使用して、デッドロックの検出および軽減を実行するための新しいサンプル ホストを構成しました (ダウンロード用のコードに入っています)。次に、そのホストを検討し、デッドロックの検出が実際にどのように作動するかをご覧いただきます。

ページのトップへ


5. アルゴリズム

広く受け入れられているデッドロックの検出の技法には、タイムアウト ベースの検出とグラフ ベースの検出の 2 種類があります。

タイムアウト ベースの検出では、ロックあるいはリソースの獲得に対して、非デッドロックのケースで見込まれよりはるかに長いタイムアウトが設けられます。そのタイムアウトに達すると、呼び出し元に対して障害が示されます。場合によっては、障害は例外の原因になりますが、関数から false が戻されるだけの場合もあります (Monitor.TryEnter の場合など)。その場合、ユーザー プログラムはそれに対して処置をとることができます。System.Transactions 名前空間は、TransactionScopes 用のこのようなモデルを使用し、既定では 60 秒以上稼働しているすべてのトランザクションを打ち切ります。

このアプローチにおける主な欠点として、デッドロックは通常、検出されるべき時点を過ぎても未検出のままになるか、あるいは、適正であっても長いトランザクションは、デッドロックしているように見えるために、強制終了されてしまいます。それ以外に、どのように応答するかをプログラマに委ねる関数は、エラーが生じやすくなります。不慣れなアプリケーション開発者は、正しい応答をわきまえていることはめったにないので、短絡的にロックの獲得を試みます (その際、それがデッドロックによって誘発される無限ループにつながるかどうかを熟考はしません)。

グラフ ベースの検出では、現在待機中のワーカーのリストと、そのリソースを所有しているワーカーのリストがあれば、誰が誰を待っているかというグラフを作成することができます。グラフを作成して、そのグラフ中でサイクルが見つかった場合、(そのサイクル内の誰かが、タイムアウト プランに組み込まれていて、デッドロックをわざわざ打破しない限り) デッドロックが起きたということです。これは、誤解を生じる余地のない対処法ですが、ロックの獲得に対する実行時の影響の見地から見て大きな犠牲を払う可能性もあります。

多くの場合、さまざまな技法を組み合わせれば、最善の結果を得ることができます。タイムアウトが発生した場合、グラフ ベースの検出を実行することができます。それによって、タイムアウトになる前に獲得が正常に完了するというよくあるケースに対して、面倒な検出を実行しなくて済みます。それが、実装した内容です。

カスタム ホストの実装に踏み込む前に、待機グラフの構造とデッドロックの検出のためのアルゴリズムをおさらいしてみましょう。図 3 は、そのプロセスを説明しています。

図 3 待機グラフの作成およびデッドロックの検出

1. 現行スレッド t1 を、waitGraph という名前の、先に表示されたスレッドのリストに追加します。
2. 現行ロックを、t1 が獲得しようとしているロックに設定します。
3. 所有者を、現行ロックを現在所有しているスレッドに設定します。
4. 所有者が Null (現行ロックは未所有であることを意味します) の場合、サイクルはありません。検索を停止し、プロシージャを終了します。
5. 所有者が waitGraph 内にいて、既に表示済みの場合、サイクルが検出されたことになります。それは、デッドロックです。グラフは、waitGraph 内の所有者の索引から開始し、終わりまで続きます。waitGraph に関する情報を呼び出し元に戻します。
6. 現行ロックを、所有者が現在ブロッキング中の対象のロックに設定します。
7. 現行ロックが Null (現行ロックは、ロック獲得のためにブロッキング待機されていないことを意味します) の場合、サイクルはありません。検索を停止し、プロシージャを終了します。
8. それ以外の場合にここまで到達した場合は、ステップ 3 に戻ります。

デッドロックの打破 (たとえば、ステップ 5 でデッドロックが見つかった場合) に使用するポリシーは、獲得しようとするとデッドロックの原因になりうる競合タスクを強制終了するポリシーです。これは、最も単純でしかも最も手間のかからない対処法ですが、大半の DBMS ほど巧妙な対策ではありません。DBMS では、投入された作業量の最も少ないトランザクションをグラフ内で強制終了することによって、システム全体のスループットが最適化されます。任意のマネージ プログラムのほうが、トランザクション システムにおけるよりも作業量を測定するのが困難であるため、もっと簡単な対処法を選択しました。(それに代わる興味深い対処法としては、代替技法を使用するようにホストを変更します。たとえば、ロック獲得タイムスタンプを使用して作業をシミュレートし、一番後で最初のロックを獲得したワーカーを終了します。)

それがどのように功を奏するかを見て見ましょう。1、2、および 3 の 3 つのスレッドと、A、B、および C の 3 つのロックがあり、それらは、特定の形式の共用メモリ関係に組み入れられていると想定します。スレッド 1 はロック A を保有し、ロック B の獲得のためにブロッキングしてスレッド 2 はロック B を保有し、ロック C の獲得のためにブロッキングしています。その後、スレッド 3 がロック A を獲得しようとすると、アルゴリズムが割って入って、図 4 に図示されているような待機グラフを作成します。すると、サイクルを検出するので、スレッド 3 の終了という処置をとります。それによって、ロック C が解放されて、スレッド 2 のブロッキング解除、C の獲得、および B の解放が可能になります。それによって、スレッド 1 のブロッキングが解除されるので、B を獲得して実行し、完了できるようになります。スレッド 3 はおそらく目的を達成していないと思われますが、スレッド 1 と 2 は達成しています (プログラムが例外で終了しなかったと想定した場合)。

図 4 待機グラフの例
図 4 待機グラフの例

当然、この解説は、アルゴリズムの中心要素を分かりやすくするために単純化されています。今度は、この論理を実行するために CLR を実際にどのように拡張するかを見てみましょう。それには、少しわき道にそれて、CLR のホストする側の API の機能の利用について述べる必要があります。

ページのトップへ


6. ホストする側の API を通した考察

CLR のホストする側の API は、アセンブリのロード、メモリ管理、スレッドのプール、および起動およびシャットダウンを制御するために独自のマネージャ オブジェクトにプラグインする手段になります。ここのサンプルでは、タスクおよび同期の管理拡張を使用しています。新規のホストは、Visual C++ で書かれた単純な実行可能プログラム deadhost.exe です。これを実行するときは、デッドロック検出機能をオンにして実行されるマネージ実行可能プログラムのパスと、その実行可能プログラムに対するすべての引数をそのホストに渡します。すると、mscoree.dll から公開されている一連の API を使用してプロセス内で CLR が開始され、ターゲット プログラムがロードされて実行されます。発生しているすべてのデッドロックが即時に報告されます。

簡潔さを目指したために、多数の基本事項を省略しました。CLR のホスト機能の完全な概要に関しては、Steven Pratschner 氏の著書 Customizing the Microsoft .NET Framework Common Language Runtime (Microsoft Press、2005 年版) を参照してください。

実装した 2 つのホスト コントロールは、IHostTaskManager インターフェイス用の DHTaskManager と、IHostSyncManager インターフェイス用の DHSyncManager です。この種のインスタンスは、オン デマンドで CLR に戻されますが、一部のイベントの場合はその時点で、CLR から当方のコードに対してコールバックが行われます。もちろん、IHostCrst、IHostAutoEvent、IHostManualEvent、IHostSemaphore、および IHostTask といった、すべての関連インターフェイスも実装しました。これらのインターフェイスは比較的大きいですが、たいていの関数は、それに対応する Win32 関数に変換されたものにすぎません。無視すると安全でないような関数 (スレッド親和性、棄却域、遅延異常終了) と、その他の少数の関数だけが、デッドロック検出に対して特殊論理を必要とします。ここでは、後者のみを考察します。

CLR は、Monitor.Enter および Exit の基本機能を実装しています。競合の起きにくいロック獲得の場合 (所有者のいないモニタの場合)、CLR は、内部データ構造に対して簡単な比較交換 (InterlockedCompareExchange) だけを行います。その試みが失敗した場合、競合が検出されたので、Windows の自動リセット イベントが使用されたことを意味します。そのイベントは次に、ロックが使用可能になるまで待機するために使用されます。誰かがロックを解放するたびに、このイベントが設定されます。それには、待機中の 1 つのスレッドがモニタ内に解放される効果があります。いずれかのモニタが初めて競合状態を示したときに、このイベントがゆっくり作成されます。ホストする側の API では、イベントの作成および、そのイベントを待機あるいは設定するための任意の呼び出しを制御することができます。ホストする側の API はまた、読み取り/書き込みプログラムのロック (System.Threading.ReaderWriterLock) もサポートしますが、紙面の関係上、この記事ではこのロックは取り上げていません。

ホストが行う必要のある詳細な内容は次のとおりです。すなわち、他の誰かによって既に保有されているモニタをマネージ コードが獲得しようとするたびに、CLR から IHostSyncManager::CreateMonitorEvent を呼び出します。すると、単純なイベントの待機ハンドル形式のインターフェイス (Wait および Set メソッドを指定された IHostAutoEvent) を実装するタイプを持ったオブジェクトが戻されます。その中に、誰かがモニタのブロッキングあるいは解放を行うたびに起動されるカスタムの検出論理を入れることができます。ブロックされたモニタの獲得を試みると、呼び出しが待機することになる一方で、モニタ呼び出し Set が解放されます。

CreateMonitorEvent から戻されたイベントに対して Wait メソッドが呼び出されるごとに、待機レコードをプロセス ワイド リストに挿入して、モニタの獲得に対してスレッドはブロッキングしようとしていることを示します。その後そのレコードは、さらに次のデッドロック検出に使用されます。

次に、時刻指定の待機を実行します。それは、100 ミリ秒から開始し、急激にバック オフしながら、その都度デッドロック検出アルゴリズムを実行します。ロックが短時間だけかけられる一般的なケースでは、タイムアウトをまず使用すれば最適化が行われ、待機グラフの作成や全探索といった犠牲を伴うプロセスを回避することができます。デッドロックが見つかった場合、HOST_E_DEADLOCK (0x80131020) を CLR に戻します。すると CLR は、「The current thread has been chosen as a deadlock victim」というメッセージを添付した新規の COMException を生成してそれに応答します。これは、CLR 2.0 の新機能です。この機能を使用すれば、SQL Server 2005 でのデッドロックの検出と打破を簡単に行うことができます。私は、マネージ プログラムをそれ自身のスレッド内で実行します。その目的は、そのような例外をすべてまったく処理しないまま先に進み、最初のパス上で付属のデバッガ (ある場合) を始動し、プロセスをクラッシュし、そしてデバッガを付加する 2 番目の機会をエンド ユーザーに与えられるようにするためです。モニタを正常に獲得した場合、待機レコードを除去し、実行権をマネージ プログラムに戻すだけです。

検出アルゴリズムは、可能な限り少量のオーバーヘッドしか生じないように作成されています (その大半はロック フリーです) が、 待機レコードの管理などの、共用データ構造へのアクセスでは何らかの同期が必要になります。プログラムがこの論理をまったく実行する必要がない場合、ファーストパスが発生します。最良のケースが発生するのは、CLR がイベントおよび呼び出し Wait を作成しなくてもモニタを獲得できる場合、つまり、手間のかかるカーネルモードの遷移をどちらの操作でも必要とするために、モニタで競合が起きていない場合です。

他にも、簡単に述べておいたほうが良い事項があります。Windows の内部 KTHREAD スレッド別データ構造には、特定のスレッドが現在待機しているディスパッチャ オブジェクトのリストが入っています。そのデータは、Ntddk.h に定義されている _KWAIT_BLOCK データ構造内に保管されます。Windows XP では、Driver Verifier プログラムがこれを使用して、装置ドライバ内のデッドロックの検出を実行します。待機レコードを挿入するのではなく、同じようにこのデータを使用してもかまいません。その措置は、マネージおよびアンマネージの両方のコードに対して効果があります。ただし、このデータを使用するには、内部データ構造を使用したメモリに対する特定の厄介な操作が必要であり、さらに、現在の所有権情報を得るための代替対策が必要になります。ここのサンプルでは、待機レコードのカスタム リスト全体をユーザー モードで保守するほうが、はるかに簡単でした。というわけで、私は、CLR 内部ロック、アンマネージおよび Windows のロック、および OS ローダー ロックの結果として生じるデッドロックを明示的に無視します。

ページのトップへ


7. 待機グラフの作成および全探索

CLR がモニタに関連した何かを求めてホストをフックするときは、各モニタごとに固有の ID である cookie (SIZE_T のタイプのもの) を軸にして処理を行います。すべてのデッドロック検出関連の機能の実装を一般的に担当する DHContext というタイプを作成しました。ホスト インスタンスは、すべてのマネージ スレッドを対象として、プロセス ワイドなコンテキストを共有します。コンテキストは、待機レコードのリストを所有し、そのリストは、待機の対象となるモニタの cookie にタスクを関連付けます。

map<IHostTask*, SIZE_T> *m_pLockWaits

コンテキストはまた、デッドロック検出を自身が起動するための一連のメソッドも提供します。TryEnter は、待機レコードを挿入し、デッドロックがあるかどうかをチェックし、見つかった場合は DHDetails オブジェクトを指すポインタを戻し、見つからなかった場合は NULL を戻します。また EndEnter は、モニタの獲得後に待機レコードの削除などの何らかの保守を行います。このアーキテクチャを図 5 に図示してあります。

図 5 ホストのアーキテクチャ
図 5 ホストのアーキテクチャ

競合のあるモニタのイベントがホストで必要になった場合、DHSyncManager::CreateMonitorEvent 関数からイベントを 1 つ作成して提供します。タイプ DHAutoEvent のオブジェクトを戻します。これは、タイプ DHContext のフィールド m_pContext を通して、ターゲットとなった関数呼び出しをコンテキストに変える IHostAutoEvent インターフェイスのカスタム実装です。Wait メソッド (図 6 を参照) は、最も興味深いメソッドです。これは、図 3 に概略されている最初の 4 つのステップを実行します。

図 6 Wait メソッド

STDMETHODIMP Wait(DWORD dwMilliseconds, DWORD option)
{
    DHContext *pctx = m_pContext;

    if (!pctx || option & WAIT_NOTINDEADLOCK)
    {
        return DHContext::HostWait(m_hEvent, dwMilliseconds, option);
    }
    else
    {
        DWORD dwCurrentTimeout = 100;
        DWORD dwTotalWait = 0;

        HRESULT hr;
        while (TRUE)
        {
            if (HOST_E_TIMEOUT != (hr = DHContext::HostWait(m_hEvent,
                min(dwCurrentTimeout, dwMilliseconds), option)))
                break;

            DHDetails *pDetails = pctx->TryEnter(m_cookie);
            if (pDetails)
            {
                pctx->PrintDeadlock(pDetails, m_cookie);
                hr = HOST_E_DEADLOCK;
            }
            else
            {
                dwTotalWait += dwCurrentTimeout;
                if (dwTotalWait >= dwMilliseconds)
                {
                    hr = HOST_E_TIMEOUT;
                }
                else
                {
                    if (dwTotalWait >= dwMilliseconds / 2)
                    {
                        if (dwMilliseconds != INFINITE)
                            dwCurrentTimeout = dwMilliseconds ?
                                dwTotalWait;
                    }
                    else
                    {
                        dwCurrentTimeout *= 2;
                    }
                    continue;
                }
            }
            pctx->EndEnter(m_cookie);
            break;
        }

        return hr;
    }
}

DHContext::HostWait は、CLR によって Wait のオプションの引数に指定されているフラグに基づいて待機を実行します。これは、WAIT_NOTINDEADLOCK が指定された場合はデッドロック検出を完全にう回します。また、WAIT_MSGPUMP あるいは WAIT_ALERTABLE (あるいはこの両方) が指定された場合は、必要に応じてポンプ スタイルあるいは alertable 待機 (あるいはこの両方) を使用します。

このコードの大部分は、急激なバックオフ タイムアウトの管理の専用であることに注意してください。最も興味深い部分は、TryEnter の呼び出しです。待機レコードの挿入後、これは、実際の検出論理の実行を内部メソッド DHContext::DetectDeadlock に委任します。 TryEnter から NULL 以外の値が戻された場合、デッドロックを検出したということです。それに応答するには、カスタムの PrintDetails 関数を呼び出して、デッドロック情報を標準エラー ストリームに出力し (これより堅実なソリューションとしては、これを Windows イベント ログに書き出します) てから、HOST_E_DEADLOCK を戻し、上述のとおりに CLR がそれに対応できる (COMException をスローして) ようにします。

上記で概略したデッドロック検出アルゴリズムは、図 7 のコードに示されている DetectDeadlock 関数内部に実装されます。

図 7 DetectDeadlock メソッド

STDMETHODIMP_(DHDetails*)
DHContext::DetectDeadlock(SIZE_T cookie)
{
    ICLRSyncManager *pClrSyncManager =
        m_pSyncManager->GetCLRSyncManager();

    IHostTask* pOwner;
    m_pTaskManager->GetCurrentTask((IHostTask**)&pOwner);

    vector<IHostTask*> waitGraph;
    waitGraph.push_back(pOwner);

    SIZE_T current = cookie;
    while (TRUE)
    {
        pClrSyncManager->GetMonitorOwner(current, (IHostTask**)&pOwner);
        if (!pOwner)
            return FALSE;

        BOOL bCycleFound = FALSE;
        vector<IHostTask*>::iterator walker = waitGraph.begin();
        while (walker != waitGraph.end())
        {
            if (*walker == pOwner)
            {
                bCycleFound = TRUE;
                break;
            }
            walker++;
        }

        if (bCycleFound)
        {
            DHDetails *pCycle = new DHDetails();
            CrstLock lock(m_pCrst);
            while (walker != waitGraph.end())
            {
                map<IHostTask*, SIZE_T>::iterator waitMatch =
                    m_pLockWaits->find(*walker);
                waitMatch->first->AddRef();
                pCycle->insert(DHDetails::value_type(
                    dynamic_cast<DHTask*>(waitMatch->first),
                    waitMatch->second));
                walker++;
            }
            lock.Exit();

            return pCycle;
        }

        waitGraph.push_back(pOwner);

        CrstLock lock(m_pCrst);
        map<IHostTask*, SIZE_T>::iterator waitMatch =
            m_pLockWaits->find(pOwner);
        if (waitMatch == m_pLockWaits->end())
            break;
        current = waitMatch->second;
        lock.Exit();
    }

    return NULL;
}

このコードは IHostTaskManager::GetCurrentTask メソッドを使用して、現在実行中の IHostTask である ICLRSyncManager::GetMonitorOwner を取得して、該当するモニタ cookie の所有側の IHostTask と、コンテキストの m_pLockWaits リストを取り出して、該当タスクがどのモニタを獲得しようと待機しているかを見つけ出します。この情報がそろったら、上述の論理を完全に実装することができます。このホストを通して実行されるどのコードも、正しくデッドロックを特定して打破します。これこそ、完全機能のデッドロック検出用の CLR ホストです。

当然、1,000 行を超えるコードを省略したので、カスタムの CLR ホストを作成するのは簡単だという印象を与えたかもしれませんが、実際はそうではありません。デッドロックは別として、強力な CLR ホスト インターフェイスのおかげでいくつかの可能性が出現することは驚嘆に値します。その範囲は、障害注入から、未完成の AppDomain のアンロードを物ともしない信頼性の高さのテストにまでいたります。エンド ユーザーに対するカスタム ホストのデプロイメントは明らかに困難ですが、たとえば、ご自分の一連のテストの中でデッドロック検出を実行するのにホストを使用するのは簡単です。

ページのトップへ


8. カスタム デッドロック ホストの稼働

deadhost.exe の作成中にいくつかの簡単なテストを開発しました。これらは、ダウンロード用のコード内の \tests\ ディレクトリに入れてあります。たとえば、図 8 に示されているプログラム test1.cs は、どのような場合でもほぼ 100% デッドロックになります。

図 8 デッドロックの体験

static void Main() {
    Console.WriteLine("Main: locking a");
    lock (a) {
        Console.WriteLine("Main: got a");
        ThreadPool.QueueUserWorkItem(delegate {
            Console.WriteLine("TP : locking b");
            lock (b) {
                Console.WriteLine("TP : got b");
                Console.WriteLine("TP : locking a");
                lock (a) {
                    Console.WriteLine("TP : got a");
                }
            }
        });
        Thread.Sleep(100);
        Console.WriteLine("Main: locking b");
        lock (b) {
            Console.WriteLine("Main: got b");
        }
    }
}

以下のコマンド行を使用してホストのもとで実行すると、未処理の例外が表示されます。

deadhost.exe tests\test1.exe

デバッガを中断する (既に接続済みの場合、あるいはエラーに対する応答として接続することを選択した場合) と、実行すればすぐデッドロックに結び付くであろう行に進みます。その時点で、すべての関与スレッドおよび保有されているロックを検査し、問題点のデバッグを開始することができます。まったく未処理のままにすると、図 9 のようなメッセージが標準出力に出力されます。

図 9 デッドロックを未処理にした結果の出力

Main: locking a
Main: got a
TP : locking b
TP : got b
TP : locking a
Main: locking b
A deadlock has occurred, terminating the program. Details below.
 600 was attempting to acquire 18a27c, which created the cycle
 600 waits on lock 18a27c (owned by 5b4)
 5b4 waits on lock 18a24c (owned by 600)

Unhandled Exception: System.Runtime.InteropServices.COMException (0x80131020):
 The current thread has been chosen as a deadlock victim.

 at Program.Main() in c:\...\tests\test1.exe:line 23
 at ...

コンソールに出力されるメッセージは、エラーの理解や徹底的なデバッグには直接役に立ちません。しかしそれによって、待機中の生のスレッド ハンドルと、その待機の対象のモニタ cookie を得ることができます。さらに時間と労力をかければ、ホストを拡張して、ロックの獲得と解放中に収集するデータを増やし、たとえば、ロックに対して使用されるオブジェクトとその獲得時のソース コードのロケーションに関する詳細を出力することができます。ただし、図 8 に示されている情報を見ただけでも、スレッド 600 は 18a27c の獲得を試みたことが簡単に分かります。5b4 は、18a27c を所有し、ロック 18a24c を待機中でした。しかし、18a24c は 600 によって所有されているので、デッドロック サイクルが成立したということです。また、その原因を作ったデッドロックの犠牲者のスタック トレースを見ることもできます。最もありがたいのは、デバッガを入れて、その場で簡単に問題をトラブルシューティングできることです。

ページのトップへ


9. まとめ

この記事では、デッドロックに関するいくつかの基本情報を取り上げて、デッドロックを回避し対処するための対策の概要を述べました。一般的に、ロックのきめの細かさが細かくなればなるほど、一度にかけられるロック数が増え、かけている時間が長ければ長いほど、デッドロックのリスクは高くなります。新しいやり方や興味深いやり方での命令が入り乱れると、サーバーおよびデスクトップ コンピュータで並列性が向上しても、そのようなリスクの高まりおよび出現にしか結びつきません。

デッドロックの回避は、並行ソフトウェアを慎重にエンジニアリングする際の最善策と広く認知されている一方で、動的に構成される複雑なシステム、特にロック障害を取り扱うために装備されるシステム (トランザクション システムおよびホストされるソフトウェアあるいはプラグイン ソフトウェアなど) に対しては、デッドロックの検出が便利であるかもしれません。アプリケーションがデッドロックに悩まされないようにするために何らかの手を打つのは、何もしないよりはましです。この記事を参考にして、より保守の簡単な、デッドロックのないコードを作成し、プログラムの応答性を改善し、究極的に、並行性が原因の障害のデバッグ時にかなりの時間が節約されればさいわいです。

ページのトップへ


Joe Duffy は、Microsoft の共通言語ランタイム (CLR) チームの技術担当プログラム マネージャであり、マネージ コードの並行性プログラミング モデルに取り組んでいます。同氏は、www.bluebytesoftware.com/blog に定期的にブログを掲載しています。Professional .NET Framework 2.0 (Wrox、2006 年版) の著者でもあります。


この記事は、MSDN マガジン - 2006 年 4 月からの翻訳です。

QJ: 060402

ページのトップへ