次の方法で共有


高可用性

.NET Framework の信頼性機能でコードを実行し続ける

Stephen Toub


翻訳元: Keep Your Code Running with the Reliability Features of the .NET Framework (英語)

この資料には、.NET Framework 2.0 のプレリリース版を元に書かれています。本資料中の情報は予告なしに変更されることがあります。

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

  • .NET Framework、SQL Server 2005

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

  • OutOfMemoryException、StackOverflowException および ThreadAbortException の理解
  • 制約付き実行領域
  • クリティカル ファイナライザと SafeHandle
  • FailFast と MemoryGates

目次

  1. "悪役"
  2. 制約付き実行領域
  3. RuntimeHelpers
  4. 信頼性規約
  5. 明示的準備作業
  6. StackOverflowException の処理
  7. CLR ホストとエスカレーション ポリシー
  8. クリティカル ファイナライザと SafeHandles
  9. クリティカル領域
  10. FailFast と MemoryGates
  11. まとめ
  12. 補足記事: 未処理例外
  13. 補足記事: マネージド デバッグ アシスタント

あなたは、信頼性のあるマネージ コードを書いていますか。同じ質問を上司にされたら、もちろん、「イエス」と答えたいはずです。リソースは try/finally ブロックを使って決定的に開放しているし、破棄可能なオブジェクトはすべてきちんと破棄している、だからもちろん、コードの信頼性は大丈夫、問題はない・・・はずですね。

残念ながら、これですべてというわけにはいきません。マネージ コードの記述を考えた場合、信頼性 (Reliability) においては、いかなる例外状況下であっても、一連の処理を決定的な方法で実行する能力が不可欠です。これにより、リソース リークの無い状況が保証され、万一不整合状態が発生しても、アプリケーション ドメインをアンロード (最悪の場合は、プロセスの再起動) せずとも状態の一貫性を保持することができます。残念なことに、Microsoft .NET Framework の例外は、すべてが決定的で同期がとれているわけではありません。このことが、あらかじめ定められた一連の処理を「決定的」に実行できるコードの記述を難しくしています。.NET Framework 1.x に関しては、状況により、このようなコードの記述はほぼ不可能なこともあります。本記事では、こうしたコードの記述が難しい理由と、状況を改善して少しでも信頼性の高いコードの記述を可能にする、.NET Framework 2.0 の新機能について探っていきたいと思います。

先ず、信頼性が重要な理由の一例として、SQL Server 2005 リリースの話から始めましょう。SQL Server は共通言語ランタイム (CLR) をホストすることができるため、ストアド プロシージャ、関数、トリガーをマネージ コードで記述することが可能です。これらのストアド プロシージャへのアクセスは、高速でなければならないことから、SQL Server は CLR をインプロセスでホストします。ASP.NET の場合、プロセス リサイクリングを利用して高可用性を保証しているため、現在のワーカー プロセスが正しく機能していないと判断すると、新しいワーカー プロセスを起動します。しかし、インプロセス ホスティングを行っている SQL Server の場合、ASP.NET のような対応ができません。メイン データベース プロセスの再起動にはダウンタイムが生じるため、ワーカー プロセスを再起動することができないからです。代わりに、SQL Server では、予期せぬ障害に対するバックストップとして、問題の発生しているアプリケーション ドメインを速やかにアンロードして、新しいドメインに置き換えることのできる、アプリケーション ドメイン分離を採用しています。このため、SQL Server で実行するコードはできるだけ信頼性の高いものにすることと、共有ステートのデータが破損した場合にサーバーが回復できるよう問題箇所を制約することは、非常に重要です。(問題がプロセスワイドであると、SQL Server は CLR を強制的に無効にします)。信頼性は、システムワイドなリソースを使用するクライアント アプリケーションにとって重要な要素ですが、特に、長時間のアップタイム (動作可能時間) を必要とする SQL Server、Windows サービスといったアプリケーションや、長時間の実行を必要とする可能性のあるホストやアプリケーションにとっては、極めて重要な要素です。コードの信頼性を保証するということは、こうした環境下で実行できるようにするための重大なステップなのです。

1. "悪役"

私にとって最高と思えるドラマには、状況さえ違っていたらきっとファンになったであろう、誤解された悪役やライバルが登場します。今日お話するストーリーの悪役達も全く同じで、ほとんどの状況では頼りになるのですが、信頼性のあるコードにとっては「目の上のこぶ」です。「彼ら」の正体は、OutOfMemoryException、StackOverflowException、ThreadAbortException です。

OutOfMemoryException は、プロセスにメモリを確保しようとしたときに、要求を満たすだけの十分な連続したメモリがない場合に投げられます。通常、このような例外は、コード内の新規オブジェクトを生成する明示的な命令 (Microsoft の中間言語 (MSIL) レベルでいう newobj や newarr 命令に相当) へのレスポンスとして発生するものと考えられていますが、他の処理でもメモリ アロケーションは発生します。たとえば、(MSIL の box 命令による) ボックス化処理 では、値タイプの格納にヒープ アロケーションが要求されます。初めて型参照を行うメソッドを呼び出すと、該当するアセンブリがメモリに遅延ロードされますが、このときにもアロケーションが必要です。また、一度も実行されたことがないメソッドを実行すると、そのメソッドはジャストインタイム (JIT) コンパイルされなければいけませんが、その際にも、生成されたコードと関連付けされた実行時データ構造体を格納するためのメモリ アロケーションが要求されます。

マネージ コードのメモリ アロケーションは、実際、考えられない場所で発生することがあります。そのため、たとえ try/catch/finally やファイナライザが自由に使えたとしても、メモリ アロケーションによる例外は、正確に処理することが非常に難しくなっています。では、これらのバックアウト コード ブロックやファイナライザがメモリ アロケーションを要求したらどうなるのでしょうか。そのとき、まだ JIT コンパイルされていなかったとしたらどうなるのでしょうか。Framework の多くの API のように、呼び出したメソッドがメモリを割り当てた場合はどうなるのでしょうか。実を言うと、.NET Framework 1.x の CLR は、バックアウト コードが実行されるという保証を全くしていません。バックアウト コード パスの実行保証がないために、メモリ不足状態に確実に対処できるアプリケーションの作成は、極めて困難となります。

StackOverflowException もまた厄介です。この例外は、使用頻度の高い巡回関数や、スタック領域を著しく消費するスタック フレーム (たとえば、C# の stackalloc キーワード (MSIL の localloc 命令に相当) 等) 等の結果として、ペンディング状態のメソッド呼び出し数が増えすぎてしまい、現在のスレッドの実行スタックがオーバーフローを起こした場合に発生します。メソッド呼び出しの中には、ターゲット メソッド自体はそれ程でなくても、(.NET リモーティングを伴う) 異なるアプリケーション ドメインへのメソッド呼び出しのように、著しいスタック消費を引き起こすものがいくつかあります。OutOfMemoryException と同様、.NET Framework 1.x の CLR は、バックアウト コードが実行されるという保証をしていません。実のところ、StackOverflowException をキャッチできるという保証すらありません。バージョン 1.x では、オーバーフローがマネージ コード内で発生した場合は、例外が投げられますが、ランタイム内で発生した場合は、プロセスが破棄されます。

Windows により、スタック オーバーフローを発生させているスレッドが検出されると、プロセスはエラー処理を試行できます。ただし、プロセスの例外処理コードがきちんと記述されていないと、新たなスタック オーバーフローを引き起こすことがあります。同じスレッドが、スタックのガード ページをリセットしないまま、二度目のスタック オーバーフローを発生させると、プロセスはオペレーティング システムにより強制終了されます。メソッド呼び出しによるオーバーフローにその都度対処できるコードの記述は、非常に難しいため (スタック オーバーフローの処理では、スタックの一部をアンワウンドしなければいけません。つまり、オーバーフロー発生箇所に近い finally ブロックも一緒にスキップされてしまう可能性があるということです。)、ほとんどのアプリケーションおよびライブラリで、この問題には対応していません。実際、バージョン 1.x では、CLR がマネージ コードによりスタック オーバーフローを処理している間に、自身がスタック オーバーフローしてしまうことがあります。その場合、プロセスはオペレーティング システムにより強制終了されます。.NET Framework 2.0 の CLR は、スタック オーバーフローを確実に検出することができ、さらにホスト ポリシーに応じて、プロセスの破棄か、StackOverflowException を投げて関連するマネージド catch および finally ブロックを実行させるかを決定します。

一番最悪なのは、おそらく ThreadAbortException です。スレッドが、自分に対して Thread.Abort を呼び出した場合は (Thread.CurrentThread.Abort 等)、これといった問題にはなりません。同期 ThreadAbortException が投げられるだけです。ところが、Abort を使って別のスレッドを終了しようとしたり、AppDomain.Unload (ターゲット ドメイン内で現在実行中のすべてのスレッド、あるいはそのドメインからの既存のスタック フレームを持つのすべてのスレッド上で Abort を呼び出すのと同じ効果があります) が呼び出されたりすると、信頼性の問題が発生します。このとき、ランタイムはターゲット スレッドに対して ThreadAbortException を投入します。投入された ThreadAbortException は、2 つのマシン命令間にあるターゲット スレッド内で発生することができます。次のコードを考えてみましょう。

IntPtr memPtr = Marshal.AllocHGlobal(0x100);

try

{

    ... // ここで割り当てられたアンマネージ メモリを使用します。

}

finally { Marshal.FreeHGlobal(memPtr); }

ThreadAbortException がメモリの割り当て後 (ただし try ブロックの実行前) に投げられた場合はどうなるのでしょうか。その場合は、finally ブロックが実行されません (例外が、対応する try ブロック内で発生しなかったため)。その結果、ガーベージ コレクター (GC) がこのアンマネージ メモリを認識しないためにメモリ リークとなります。メモリの割り当てが try ブロック内で行われるよう、コードを書き直すという選択肢も考えられますが、例外が、AllocHGlobal がリターンしてから、マシン命令が実行されてその値が memPtr に格納されるまでの間に発生してしまったとしたら、書き直しても意味はありません。その場合、割り当てられたメモリのポインターが失われてメモリ リークとなります。

あるいは、.NET Framework 1.x や、.NET Framework 2.0 の特定のシナリオのように、例外が finally ブロックで発生した場合はどうでしょうか。端的に言ってしまうと、ThreadAbortExceptions が割り込みできないコード領域を示す手段がユーザー コードになければ、非同期例外に対して信頼性のあるコードを記述することはほぼ不可能です。

ページのトップへ


2. 制約付き実行領域

.NET Framework 2.0 では、ランタイムと開発者の両方を制限する、制約付き実行領域 (CER) を取り入れています。ランタイムは、CER とマークされた領域では、領域全体を実行付加にするような非同期例外を投げることができません。また、領域内で実行できる動作も制限されるため、開発者もまた制約を受けます。しかしながら、これにより、信頼性のあるマネージ コードを記述するためのフレームワークと実行メカニズムが確保されるわけですから、CER は .NET Framework 2.0 の信頼性を語る上では欠かせない存在です。

CER の制約を充たすために、ランタイムは次の 2 つの対応をとっています。1 つ目の対応は、CER で実行中のコードでのスレッド破棄の遅延です。つまり、あるスレッドが、Thread.Abort 呼び出しにより、CER 内で実行中の別スレッドを破棄しようとした場合、ランタイムは CER 内の実行が完了するまで、ターゲット スレッドを破棄しません。2 つ目の対応は、メモリ不足状態を回避するための迅速な CER の準備です。つまり、ランタイムは、コード領域の JIT コンパイルでやっているように、すべてのことを前もって行うことになります。同時に、スタックの空き領域が一定量あるかどうかを調べ、スタック オーバーフロー例外の解消をサポートします。この作業を前もって行うことにより、領域内で発生する可能性のある例外や、リソースの適切なクリーンアップを阻む例外をより適切に回避することができます。

CER を有効に使うために、開発者は、非同期例外を引き起こす可能性のある動作を回避するようにしなければいけません。コード側では、明示的なアロケーション、ボックス化処理、仮想メソッド呼び出し (ただし、仮想メソッド呼び出しのターゲットがすでに用意された状態であれば問題ありません)、リフレクションによるメソッド呼び出し、Monitor.Enter (または C# の lock キーワードや Visual Basic の SyncLock) の使用、COM オブジェクト上での isinst および castclass 命令、トランスペアレント プロキシによるフィールド アクセス、シリアライゼーション、多次元配列アクセス等の動作を制限しています。

簡単に言ってしまうと、CER とは、実行時に発生する障害箇所を、コードの実行前 (JIT コンパイルの場合) あるいはコードの完了後 (スレッド破棄の場合) に移動してしまう手段であるといえます。しかしながら、CER が本当に制限しているのは、記述可能なコードです。ほとんどのアロケーションが不可、あるいは準備されていないターゲットへの仮想メソッド呼び出しの不可といった制限事項は、それを実現するには高い開発コストがかかるというニュアンスを含んでいます。つまり CER は、大型の汎用コードには向いておらず、どちらかというと小規模領域のコードの実行を保証するテクニックであると考えた方がいいかもしれません。

ページのトップへ


3. RuntimeHelpers

デフォルトのランタイムは、制約付き実行領域 (CER) での実行を前提としていません。開発者は、System.Runtime.CompilerServices.RuntimeHelpers クラスのメソッドを使って、保護および CER にしたいコード領域を明示的にマークしなければいけません。このクラスの中で最も重要なメソッドが、PrepareConstrainedRegions スタティック メソッドです。このメソッドは、十分なスタック領域を精査することができ、CER の開始を示す、CLR マーカーとしての役割を果たします。MSIL レベルでは、try ブロックの開始直前に実行しなければいけません。また、バックアウト コードが必要とするリソースすべてを事前に確保しておくことで、try ブロックを "確実な try " にすることができます (これにより、この領域内で発生する CLR によるメモリ不足例外から領域を保護します)。

ただし、準備されるコードは、try ブロックに関連付けされた、catch、finally、fault および filter ブロック内のコードのみで、try ブロック自体のコードは準備されないため注意が必要です。それでも、try ブロック内のコードが、JIT コンパイラから OutOfMemoryException を投げたり、ThreadAbortException によって割り込まれる可能性はあります。ただし、すでにバックアウト コードが準備されているため、対応する catch、finally、fault、filter ブロックでこれらの例外を実行、処理することができます (バックアウト コードが準備されていないと、メモリ不足により、バックアウト コードの実行が不可になることがあります)。図 1 の Visual Basic コードは、準備されるコードとされないコードを示しています (ただし、fault ブロックについては、MSIL 以外のマイクロソフト言語からは現在利用できないため、ここにはありません)。

図 1 RuntimeHelpers.PrepareConstrainedRegions

Imports System

Imports System.Runtime.CompilerServices



Class RegionsDemo

    Shared Sub Main()

        ... ' 準備されません

        RuntimeHelpers.PrepareConstrainedRegions()

        Try

            ... ' 準備されません

        Catch exc As SomeException When SomeFilter(exc)

            ... ' 準備されます

        Finally

            ... ' 準備されます

        End Try

        ... ' 準備されません

    End Sub



    Shared Function SomeFilter(ByVal exc as Exception) As Boolean

        ... ' 準備されます

    End Function

End Class

PrepareConstrainedRegions でマークされている try ブロック内のコードは、準備されないため、割り込み不可なコード領域の作成パターンは次のようになることが多くなります (また、役に立つこともあります)。

RuntimeHelpers.PrepareConstrainedRegions();

try {} finally

{

    ... // ここで割り込み不可なコードを記述します。

}

ここでは、try ブロックにおけるステート変更からの復旧の際にバックアウト コードの役割を果たす finally ブロックの代わりに、finally ブロックには前進するコードが含まれています。

バックアウト ブロック内のコードの準備に加えて、PrepareConstrainedRegions の実行は推移的です。つまり、CLR はバックアウト コードからの呼び出しグラフを参照しに行き、グラフ中に見つかったメソッドの準備をします。しかしながら、準備をするメソッドについては、いくつか制限が存在します。まず、CLR は自身が認識できるメソッドしか準備することができません。呼び出しサイトに、インターフェイス、委任、仮想メソッドやリフレクション経由の呼び出し等、「迂回」を伴っていると、CLR は呼び出しグラフからターゲットを追跡することができないため、ターゲットの準備ができません。その結果、実行時にもメモリ不足例外が発生することがあり、スレッド破棄も正しく遅延されません。もう 1 つの制限として、呼び出しグラフ内のどのメソッドも、適切な信頼性規約で保護されていなければいけません。

ページのトップへ


4. 信頼性規約

ランタイムに積極的に CER を準備させるには、当該 CER 内のコードとその呼び出しグラフが CER 内での実行に必要な制約に従っていることを認識していなければいけません。これを実現するため、.NET Framework は System.Runtime.ConstrainedExecution namespace で ReliabilityContractAttribute (図 2 を参照) を提供しています。

図 2 ReliabilityContractAttribute

[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Method |  

    AttributeTargets.Constructor | AttributeTargets.Struct | 

    AttributeTargets.Class | AttributeTargets.Assembly, Inherited=false)] 

public sealed class ReliabilityContractAttribute : Attribute

{

    public ReliabilityContractAttribute(

        Consistency consistencyGuarantee, CER cer);

    public Cer Cer { get; set; }

    public Consistency ConsistencyGuarantee { get; set; }

}



[Serializable]

public enum Cer { None = 0, MayFail = 1, Success = 2 }



[Serializable]

public enum Consistency

{

    MayCorruptProcess = 0, MayCorruptAppDomain = 1,

    MayCorruptInstance = 2, WillNotCorruptState = 3

}

信頼性規約では、異なっているが関連性のある 2 つのコンセプトを示します。メソッドの実行中に投げられた非同期例外が原因となり発生するかもしれないステートのデータ不正の種類と、もし CER 内で実行された場合にメソッドが実現可能な完了保証の種類です。メソッド レベルでの提供に加え、規約はクラスおよびアセンブリ レベルでも規定することができます。メソッドに定義された規約は、クラスまたはアセンブリ レベルで定義されたいかなる規約もオーバーライドし、クラスに定義された規約はアセンブリ レベルで定義された規約をオーバーライドします。

規約の最初の部分は、Consistency 列挙からの値を取る、ConsistencyGuarantee プロパティの属性により定義されます。このプロパティは、メソッドの実行中に非同期例外が投げられた場合に起こりえるステート不正のレベルを表します。(このプロパティは、よい状態と言われるところまで回復するためにはどのステートを破棄したらよいかの情報を呼び出し側に提供する計測器のようなものと考えてください。) 最悪のデータ不正はプロセスのデータ不正 (MayCorruptProcess) です。これは、疑わしいメソッドが例外の発生時にプロセスワイドのステートで不正な処理を行った結果、ステートに不整合が発生している可能性を示すときに使われます。同じように、MayCorruptAppDomain は、メソッドがアプリケーション ドメインから切り離されたステート (スタティック変数等) で不正な処理を行った結果、ステートに不整合が発生している可能性 (ただし、プロセスワイドなステートの整合性は保たれているはずですが) を知らせます。MayCorruptInstance は、メソッドが矛盾したステート内にインスタンスを残したままにしている可能性を知らせるために使われます (ただし知らせるだけでそれ以上のことはしません)。WillNotCorruptState は、メソッドがどうしてもステートを矛盾した方法で放置することができなかった (ステート情報を読み取るだけのメソッドの場合がほとんど) ことを意味します。

Cer プロパティと列挙は、メソッドが CER で実行された場合に、どのような完了保証を実現できるかを示します (CER で実行しなければ、この保証は無価値です)。Success の値は、このメソッドは、CER で実行された場合は (ただし有効な入力が前提) 必ず正常終了するという意味です。Success が付けられたメソッドでも、不正なパラメータが渡されれば、例外を投げることがあります。

MayFail の Cer 値は、コードが非同期例外に直面した場合に期待された方法で完了しない可能性があることを知らせます。制約付き実行領域では、スレッド破棄が遅延されるため、実際には、コードがメモリ割り当てを発生させる、あるいはスタック オーバーフローの原因となるような処理を行っているという意味になります。もう 1 点、このメソッドを呼び出す際は起こりえる問題を十分考慮しなければいけないという重要な意味があります。

CER ルートの呼び出しグラフ内のメソッドの場合、Cer と Consistency 値の有効な組み合わせは次の 3 つしかありません。

[ReliabilityContract(Consistency.MayCorruptInstance, Cer.MayFail)]

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

1 つ目は、例外的状況に陥った場合、メソッドは失敗するかもしれないが、不正となるのは最悪でも特定のインスタンスのみという意味です。アプリケーション ドメインとプロセスワイド ステートは無傷のままです。2 つ目は、メソッドは失敗するかもしれないが、たとえそうなっても、すべてのステートは有効のまま保たれるという意味です。これは、副次的な悪影響や例外の発生を伴わずに動作が完了または失敗することを意味する、C++ で "厳密例外保証" と言われているものに相当します。3 つ目は、メソッドは常に正常終了し、いかなるステートも不正な状態にすることはないというニュアンスを含みます。この最後の組み合わせは、考えられる中で最も厳密な保証ですが、それだけに、この規約をマークできるメソッドはごくわずかです。実際、皆さんが目にするほとんどのメソッドが、例外的状況に陥っても何の保証もしないことを意味する、Cer 値に None を指定しているか、信頼性規約を全く指定していないかのいずれかです。メソッドに信頼性規約を定義していないと、暗黙で、Cer.None および Consistency.MayCorruptProcess となります。

独自のメソッドに信頼性規約を設定する際は、ブレーキング チェンジは Cer または Consistency 保証を弱めるとされていることを覚えておいてください。呼び出し側は、信頼性レベルと依存状態を作っていることがあります。そのため、信頼性レベルを変更することで、そうした依存状態を絶ち切ってしまうことがあります。信頼性規約を省略すると、制約付き実行領域内から使われる可能性のあるメソッドを正確に把握していない限り、ほとんどのメソッドは便利な first-pass アプローチです。

信頼性規約にも、興味深いインタープリテーションの問題がいくつかあります。たとえば、CLR 設計者が遭遇する問題の一つに次のようなものがあります。きちんと実装されたメソッドのほとんどは、パラメータ チェックを行い、不正な入力に対しては例外を投げます。その結果、CER 内からのすべてのアロケーションを禁止するだけのアプローチはほとんど役に立ちません (メソッドの中には、アロケーションも行って、メモリ不足例外から回復するものもあります)。信頼性規約は、ランタイムからそのタイプの詳細を抜き出そうとする試みであり、コードの作成者が、呼び出し側が障害を心配する必要があるかどうか、その場合は、どれくらいのステートを破棄しなければならないかを明確に示すことができるようになります。結果的に、信頼性規約は、メソッドに渡された引数が不正である、またはメソッドの用法が不正であると、null および void であると見なされます。さらに、規約で簡単に表現されないコンセプトが存在します。Object をパラメータとして取り、そのオブジェクトの Equals メソッドを呼び出した場合を想像してみてください。このメソッドは信頼できるかもしれませんが、それは信頼できる Equals をオーバーライドしているオブジェクトを渡した場合に限られます。このコンセプトを表す方法は、現時点では用意されていません。

ページのトップへ


5. 明示的準備作業

前述しましたが、PrepareConstrainedRegions が実行されると、ランタイムは、すべての呼び出しグラフを CER ルートから参照します。残念ながら、ランタイムは全能ではないため、仮想呼び出しサイトからの実際のターゲット メソッドを予測することができません。そのため、インターフェイス、仮想メソッド、委任、ジェネリック メソッドが制約された領域から使用された場合、ターゲット メソッドは実行時まで決定されないために (ただし、ジェネリック メソッドの場合は決定できることもありますが、メソッド呼び出しが独自の型のパラメータで初めて行われた場合は、何らかのメモリ アロケーションが要求されることがあります)、ランタイムはターゲット メソッドの準備を前もってすることができなくなります。CLR を支援するため、開発者は、RuntimeHelpers クラスの別の 2 つのメソッド、PrepareMethod と PrepareDelegate を利用することができます。PrepareMethod は、ターゲット メソッドの MethodBase 用の RuntimeMethodHandle をアクセプトします。実行時、コードは、実際に呼び出されるメソッドのメソッド ハンドルを取得することができます。続いて、PrepareMethod を使用することで、CER の開始前に CER を準備することができます。

例として、図 3 の一番上のコード ブロックを考えてみます。BaseObject は、CER で使われる仮想メソッド、VirtualMethod を提供しています。このメソッドはバーチャルであるため、CLR のスタティック解析では、実際の呼び出しのターゲットがどれになるかを決定することができません。SomeMethod に渡されたオブジェクトが DerivedObject であると、DerivedObject の VirtualMethod オーバーライドを呼び出し時までに正しく準備できないことがあり、結果として CER は危険にさらされることになります。この状況を解消するには、実際のターゲットが認識された時点で、PrepareMethod を使用します。もちろん、メソッド ハンドルを取得するリフレクションは、負荷の高い処理となることがあります。実行されるであろうすべてのメソッドを把握していれば、すべての準備作業を (スタティック コンストラクタ等で) あらかじめ行おうと考えたかもしれません。

図 3 CER における仮想メソッド呼び出し

  • CER における誤った仮想メソッド呼び出し
public void SomeMethod(BaseObject o)

{

    RuntimeHelpers.PrepareConstrainedRegions();

    try { ... } finally { o.VirtualMethod(); }

}
  • CER における正しい仮想メソッド呼び出し
public void SomeMethod(BaseObject o)

{

    RuntimeHelpers.PrepareMethod(

        o.GetType().GetMethod("VirtualCall").MethodHandle));

    RuntimeHelpers.PrepareConstrainedRegions();

    try { ... } finally { o.VirtualMethod(); }

}
  • すべてのターゲットが前もって分かっている場合の早期準備作業
static SomeType()

{

    foreach (Type t in 

        new Type[]{typeof(DerivedType1), typeof(DerivedType2), ...})

    {

        RuntimeHelpers.PrepareMethod(

            t.GetMethod("VirtualMethod").MethodHandle);

    }

}



public void SomeMethod(BaseObject o)

{

    RuntimeHelpers.PrepareConstrainedRegions();

    try { ... } finally { o.VirtualMethod(); }

}

PrepareDelegate は PrepareMethod と似ていますが、委任をアクセプトして参照メソッドの準備をするメソッドです。PrepareDelegate が準備するのは、参照された特定の委任のみで、その委任によりリンクされている委任については準備を行いません。つまり、マルチキャスト委任が複数の委任から成り立っていたとしても、準備されるのは明示的に参照されている委任のみとなります。委任が CER 内から行われた場合は、委任の呼び出しリストを取得してそれぞれの委任の準備を行うか、他の委任と結合される前に、個々の委任の準備をしなければいけません。これには、イベントへの波及効果があります。CER 内からのイベントの発生を考えている場合は、そのイベント用にカスタム add アクセッサの提供を検討してもいいでしょう。このカスタム アクセッサにより、提供された委任とその前に登録されていたすべての委任を結合する前に、このイベントで登録されたすべての委任の準備が行われます。

public event EventHandler MyEvent {

    add {

        if (value == null) return;

        RuntimeHelpers.PrepareDelegate(value);

        lock(this) _myEvent += value;

    }

    remove { lock(this) _myEvent -= value; }

}

AppDomain のイベントのいくつかは、上記の方法で実装されています。たとえば、ProcessExit や DomainUnload などがそうで、これらはいずれも、CLR 内で暗黙にルートされた CER 内から発生します。

本来は使うべき、PrepareMethod と PrepareDelegate が使われていない問題は、追跡が難しくなることがあります。CLR は、こうした CER 関連の問題を診断する仕組みとして、いくつかのマネージド デバッグ アシスタント (MDA) を用意しています。詳細については、本記事最後の マネージド デバッグ アシスタント を参照してください。

ページのトップへ


6. StackOverflowException の処理

CLR では、try ブロック内の StackOverflowException が発生した場合に、必ずしも対応するバックアウト コードが実行されるわけではありません。StackOverflowExceptions が発生した場合に、絶対、バックアウト コードを実行しなければならないシナリオを考慮して、RuntimeHelpers クラスには ExecuteCodeWithGuaranteedCleanup メソッドが用意されています。このメソッドは、try ブロックで実行されるコードを持つ RuntimeHelpers.TryCode 委任、finally ブロックで実行されるコードを持つ RuntimeHelpers.CleanupCode 委任、そして、これら 2 つの委任に渡されるユーザー データの 3 つのパラメータを取ります。このメソッドの用法を図 4 に示してあります。

図 4 ExecuteCodeWithGuaranteedCleanup

try

{

    RuntimeHelpers.ExecuteCodeWithGuaranteedCleanup(

        TryMethod, CleanupMethod, this);

}

catch(Exception exc) { ... } // 予期せぬ問題を処理します



...



[MethodImpl(MethodImplOptions.NoInlining)]

void TryMethod(object userdata) { ... } 



[MethodImpl(MethodImplOptions.NoInlining)]

[ReliabilityContract(Cer.Success, Consistency.MayCorruptInstance)]

[PrePrepareMethod]  // アセンブリが ngen で生成されている場合のパフォーマンス改善

void CleanupMethod(object userdata, bool fExceptionThrown) { ... }

ExecuteCodeWithGuaranteedCleanup は、CLR 内に構造化例外処理 (SEH) ハンドラーを構築します。この防護策を適宜配置することで、メソッドはマネージド TryCode にコール バックします。このコード内でスタック オーバーフローが発生すると、ランタイムはそれをキャッチして、CleanupCode を正しく実行できるだけの空きスタック領域を確保します。もちろん、バックアウト コード自身がスタック オーバーフローを起こさないよう、十分な注意が必要です (たとえば、使用頻度の高い巡回呼び出しを行わないようにしたり、巨大なスタック領域や未知量のスタック領域を必要とするコードに依存しない等)。それから、あまり大きな声ではいえませんが、現在の C# では、匿名メソッドにカスタム属性を指定することができません。そのため、バックアウト コードでは匿名メソッドを使わないようにしてください。

ページのトップへ


7. CLR ホストとエスカレーション ポリシー

メモリ不足状態が発生しても OutOfMemoryException が発生しないのはどのような条件のときでしょうか。どのような条件のときに、CLR ホストは例外を許可しないのでしょうか。CLR をホストするアンマネージ アプリケーションでは、リソース アロケーションの失敗や、コードのクリティカル領域でのリソース アロケーションの失敗、所有者を失ったロック、ランタイム自身の致命的エラー等、特定の状況に対してランタイムの反応を制御することができます。ホストは、アンマネージド ICLRPolicyManager インターフェイスを使用することで、特定の障害が発生した際、CLR が取るアクションを制御することができます。これらのアクションには、例外の投入、スレッドの破棄、アプリケーション ドメイン、既存プロセスのアンロード、ランタイムの無効化、何もしない (障害の無視) などがあります。例外の投入と障害の無視は、デフォルトの CLR で使用されるアクションです。そのため、たとえば、メモリの割り当てができない場合、ランタイムは OutOfMemoryException を投げます。また、ランタイムに、所有者を失ったロックの存在が通知された場合は、問題を無視します。ICLRPolicyManager とその SetActionOnFailure メソッドにより、ホストはこれらのデフォルト動作を変更することができます。つまり、メモリの割り当てができない場合に、OutOfMemoryException を投げる代わりに、ThreadAbortException を投入することで、ランタイムにスレッドを破棄させるといった選択が可能です。また、所有者を失ったロックが見つかった場合には、エラーを無視せず、ランタイムにアプリケーション ドメインをアンロードさせてもかまいません。これらは、コードの記述時に可能になるということを認識しておいてください。

これらのタイプのポリシーだけでは、すべてのホストはカバーできません。ホストには、直前のアクションが不十分な場合に、実行したアクションをエスカレーションする能力が必要なことがあります。例として、try ブロック内に ThreadAbortException が投げられ、その try に関連付けされている finally ブロックが無限ループに陥った場合を考えてみましょう。デフォルトのランタイム ポリシーでは、上記のスレッドが破棄されることはありません。また、確実な保証を必要とするアンマネージド ホストの場合は、このシナリオに対応するためには何らかの方法を必要とします。そこで、ICLRPolicyManager の SetTimeoutAndAction メソッド という解決策の登場です (ちなみに、それほどではありませんが SetDefaultAction メソッドも使えます)。このメソッドは、アクション、タイムアウト、レスポンス アクションの 3 つのパラメータを取ります。たとえば、ランタイムが、タイムアウトを設定したアクションを実行しているときに、タイムアウトとなった場合、ランタイムはそのアクションをレスポンス アクションにアップグレードします。具体例を挙げると、たとえば、ホスト側で、すべてのスレッド破棄をわずか 5 秒で行うように指定した上で、それが実行できた場合に、アプリケーション ドメインをアンロードするよう指定することが可能です。ランタイム自身には 1 つだけデフォルト タイムアウトが用意されています。プロセス終了用のタイムアウトです。たとえば、プロセスを正常終了しようとしたときに、約 40 秒以内に処理 (すべてのスレッドを破棄、すべてのアプリケーション ドメインのアンロードなど) が成功しないと、ランタイムはそのプロセスを強制終了します。

先ほど、スレッドの破棄やアプリケーション ドメインのアンロードなど、決まった障害のレスポンスとしてホストが取ることのできるアクションには複数のアクションがあると述べましたが、スレッドの破棄やアプリケーション ドメインのアンロード、プロセスの終了といった一部のアクションには、複数段階のシビリティ(深刻度) レベルが存在するということは言っていませんでした。ここまでは、単純に、ランタイムがスレッド上で ThreadAbortException を投入した結果のスレッド破棄の話だけをしてきました。通常、このような場合、スレッドは強制終了しますが、スレッド破棄を処理して、強制終了を回避することができます。これに対応するため、ランタイムはより強力アクション、ルード (乱暴な) スレッド破棄を実施します。ルード スレッド破棄が発生すると、スレッドは実行を中断します。このとき、CLR は、(コードが CER 内で実行中でない限り) そのスレッド上のバックアウト コードの実行を一切保証しません。実に、「乱暴」です。

同様に、通常のアプリケーション アンロードでは、ドメイン内のすべてのスレッドをきれいに破棄するのですが、ルード アプリケーション ドメイン アンロードでは、ドメイン内のすべてのスレッドをいきなり破棄し、さらにそのドメイン内のオブジェクトに関連付けされた標準ファイナライザの実行を一切保証しません。SQL Server 2005 は、ルード スレッド破棄とルード アプリケーション ドメイン アンロードをエスカレーション ポリシーとして利用する CLR ホストの 1 つです。非同期例外が発生した場合、リソース アロケーションの失敗はスレッド破棄にアップグレードされます。続いて、スレッド破棄が発生しますが、SQL Server が設定した時間内に処理が終了しないと、次はルード スレッド破棄にアップグレードします。アプリケーション ドメイン アンロードの場合も同様で、アプリケーション ドメイン アンロードが SQL Server が設定した時間内に終了しないと、ルード アプリケーション ドメイン アンロードにアップグレードされます。(SQL Server は、コードの実行箇所がクリティカル領域かどうかという点も考慮するため、上記で説明するポリシーは、SQL Server が実際に採用しているポリシーとは若干異なります。)

CLR によるグレイスフル (きちんとした) スレッド破棄とルード スレッド破棄の処理方法の違いを理解することは重要です。.NET Framework 1.x では、ルード スレッド破棄というものが存在しないため、スレッド破棄は、CLR が提供する保護がない状態で、マネージ コード内のどこでも発生することができます。一方、.NET Framework 2.0 の場合、デフォルトの CLR では、CER、finally ブロック、catch ブロック、スタティック コンストラクタ、アンマネージ コード上のグレイスフル スレッド破棄を遅延しますが、ルード スレッド破棄の場合は、CER およびアンマネージ コード上でしか遅延されません (CLR は、後者についてはほとんど自由にすることができません)。

ルード スレッド破棄とルード アプリケーション ドメイン アンロードは、CLR ホストが、コードの暴走を確実に回避するために使用します。ただし、こうしたアクションは、バックアウト コードがクリーンアップすることになっていたリソースでリークを起こしやすいため、ルード スレッド破棄やルード アプリケーション ドメイン ロードが原因でファイナライザや非 CER finally ブロックの実行に失敗した場合、CLR ホストでは新たな信頼性の問題が発生することになります。

ページのトップへ


8. クリティカル ファイナライザと SafeHandles

グレイスフル スレッド破棄では、対応するすべての blocks が実行できますが、ルード スレッド破棄では (CER で実行されない限り) このような保証はありません。グレイスフル アプリケーション ドメイン アンロードでは、グレイスフル スレッド破棄を使用し、対応するすべての finally ブロックおよびそのドメイン内オブジェクトのすべてのファイナライザの実行を可能にしていますが、ルード アプリケーション ドメイン アンロードでは (CER で実行されない限り)、やはりこのような保証はありません。ルード アプリケーション ドメイン アンロードを使用した場合、ランタイムは、バックアウト コードまたは標準ファイナライザの実行を一切保証しません。では、このような条件下のシステムは、どうすれば信頼性を確保することができるのでしょうか。

.NET Framework 2.0 には、クリティカル ファイナライザという、新しい種類のファイナライザが導入されています。クリティカル ファイナライザは、ルード アプリケーション ドメイン アンロード中でも動作し、CER 内でも動作するという点で、特殊なファイナライザと言えます。クリティカル ファイナライザは、最も重要なファイナライザ、つまりセキュリティや信頼性を必要とするファイナライザが求められるとき以外は使うべきではありません。.NET Framework でも、クリティカル ファイナライザを利用できるのはほんの一握りのクラスです。

クリティカル ファイナライザを実装するには、問題のクラスを System.Runtime.ConstrainedExecution 名前空間の CriticalFinalizerObject クラスから派生するだけです。

[SecurityPermission(

    SecurityAction.InheritanceDemand, UnmanagedCode=true)]

public abstract class CriticalFinalizerObject

{

    protected CriticalFinalizerObject();

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

    protected override void Finalize();

}

派生クラスで実装しているすべてのファイナライザは、CER の一部として呼び出されるため、CriticalFinalizerObject.Finalize 上の信頼性規約の内容に一致した信頼保証を保持していなければいけません。つまり、CriticalFinalizerObject は、CER 環境下での呼び出し時に、ファイナライザの成功と、ステート不正を起こさないことが保証されている場合にしか派生できません。CriticalFinalizerObject から派生したオブジェクトが継承されると、ランタイムはその場で Finalize メソッドを用意します。そのため、初めてメソッドを実行する段階でのコードの JIT (あるいはその他の準備作業) は必要なくなります。

CriticalFinalizerObject から派生された .NET Framework のクラスの 1 つに、System.Runtime.InteropServices 名前空間で有効な、SafeHandle があります。SafeHandle が、.NET Framework に加わったことは非常に喜ばしいことであり、前バージョンからある多くの信頼性の問題解決に大きな役割を果たすことでしょう。本来、SafeHandle は、IntPtr によって参照される基底リソースの開放方法を認識したファイナライザを持つ、IntPtr のマネージド ラッパにすぎません。SafeHandle は CriticalFinalizerObject から派生されているため、このファイナライザは、SafeHandle のインスタンス生成のタイミングで準備され、非同期スレッド破棄がファイナライザを中断しないよう、CER 内から呼ばれます。

ディレクトリ内のファイルの列挙に使用する、Win32 FindFirstFile 関数を考えてみましょう。

HANDLE FindFirstFile(

    LPCTSTR lpFileName, LPWIN32_FIND_DATA lpFindFileData);

通常、.NET Framework 1.x では、この関数の P/Invoke 宣言を次のように宣言します。

[DllImport("kernel32.dll", CharSet=CharSet.Auto, SetLastError=true)]

private static extern IntPtr FindFirstFile(

    string pFileName, [In, Out] WIN32_FIND_DATA pFindFileData);

残念ながら、非同期例外が、FindFirstFile のリターン後から、取得した IntPtr ハンドルの格納後までの間に発生した場合、そのオペレーティング システム リソースは、開放の望みをほとんど絶たれたメモリ リークとなりますが、ここで救いとなるのが SafeHandle です。.NET Framework 2.0 では、この宣言部分を次のように書き直すことができます。

[DllImport("kernel32.dll", CharSet=CharSet.Auto, SetLastError=true)]

private static extern SafeFindHandle FindFirstFile(

    string fileName, [In, Out] WIN32_FIND_DATA data);

書き直し前と唯一違うのは、IntPtr リターン型を SafeFindHandle に置き換えている点です。ちなみに、SafeFindHandle は、SafeHandle から派生したカスタム型です。ランタイムが FindFirstFile 呼び出しを行うと、まず最初に SafeFindHandle のインスタンスを生成します。FindFirstFile がリターンすると、ランタイムは取得した IntPtr をすでに生成されている SafeFindHandle に格納します。ランタイムは、このオペレーションがアトミックであること、すなわち、P/Invoke メソッドが正常にリターンすると、IntPtr が SafeHandle 内に安全に格納されることを保証します。一旦、SafeHandle 内に格納されてしまえば、たとえ非同期例外が発生して、FindFirstFile の SafeFindHandle のリターン型が格納できなかったとしても、対応する IntPtr は、すでにマネージド オブジェクト内に格納されているため、そのファイナライザにより、適切な開放が実施されます。

内部的には、.NET Framework は、大量の SafeHandle 派生の型を使用します (それぞれ、処理を必要とするアンマネージド リソースの各型用です)が、外からは、SafeFileHandle (ファイル ハンドルのラップに使用) と SafeWaitHandle (同期ハンドルのラップに使用) 等、数個しか見ることができません。もちろん、これらがカバーしないアンマネージド リソースを処理する場合は、独自の SafeHandle 派生型を作成することができます。図 5 では、SafeHandle の実装例をいくつか示しています。独自の派生型の記述が楽になるよう、.NET Framework では、2 つのパブリック SafeHandle 派生型、SafeHandleZeroOrMinusOneIsInvalid と SafeHandleMinusOneIsInvalid を提供しています。SafeHandle は、自分が格納している IntPtr が対応するリソース タイプにとって有効かどうかを判断できなければいけません。Win32 の世界では、大部分のリソース ハンドルが -1 あるいは 0 か -1 である場合に不正となることから、これらのクラスは、コード内での手間を省けるよう、こうしたチェックを組み込んで提供されています。

図 5 SafeHandle の実装サンプル

  • ヒープ メモリ用 SafeHandle
[SecurityPermission(SecurityAction.LinkDemand, UnmanagedCode=true)]

public sealed class SafeLocalAllocHandle : 

    SafeHandleZeroOrMinusOneIsInvalid

{

    [DllImport("kernel32.dll")]

    public static extern SafeLocalAllocHandle LocalAlloc(

       int uFlags, IntPtr sizetdwBytes);



    private SafeLocalAllocHandle() : base(true) { }



    protected override bool ReleaseHandle()

    {

        return LocalFree(handle) == IntPtr.Zero;

    }



    [SuppressUnmanagedCodeSecurity]

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

    [DllImport("kernel32.dll", SetLastError=true)]

    private static extern IntPtr LocalFree(IntPtr handle);

}
  • LoadLibrary 用 SafeHandle
[SecurityPermission(SecurityAction.LinkDemand, UnmanagedCode=true)]

public sealed class SafeLibraryHandle : SafeHandleZeroOrMinusOneIsInvalid

{

    [DllImport("kernel32.dll", SetLastError=true)]

    public static extern SafeLibraryHandle LoadLibrary(

        string lpFileName);



    private SafeLibraryHandle() : base(true) { }



    protected override bool ReleaseHandle()

    {

        return FreeLibrary(handle);

    }



    [SuppressUnmanagedCodeSecurity]

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]

    [DllImport("kernel32.dll", SetLastError=true)]

    private static extern bool FreeLibrary(IntPtr hModule);

}

SafeHandle には、リソースのライフタイム管理以外にも利点があります。1 つは、ファイナライゼーションのグラフ プロモーションを少なくすることによる、メモリ管理の支援です。.NET Framework 1.x では、通常、アンマネージド リソースを必要とするクラスは、対応する IntPtr を、そのクラスが利用するその他のマネージド オブジェクトと一緒に、クラス自身に格納します。このクラスは、ほとんど間違いなく、IntPtr を適切に開放するためのファイナライザを実装しています。ファイナライズが可能なオブジェクトは、少なくとも一回のガーベージ コレクションを経ているため、この 1 つの IntPtr のおかげで、最終的には、問題のクラスから始まるオブジェクト グラフ全体がコレクションから免れます。SafeHandle は、その IntPtr を差し替えし、独自のファイナライザを装備しているため、SafeHandle を格納するクラスは、ほどんどの状況において、自分のファイナライザを必要としなくなります。これにより、これらの GC グラフ プロモーションが排除され、結果として、GC の負荷が軽減されます。また、通常、ファイナライザを削除するということは、それらのオブジェクト内でのすべての GC.KeepAlive(this) 呼び出しを取り除くことができるということを意味します。

SafeHandle は、ハンドルの再利用問題によるセキュリティ ホールについても解消してくれます。Windows には、ハンドルと、それぞれ関連付けされたカーネル オブジェクトとをマップする内部テーブルを保持しています。ハンドルが開放されると、Windows はそのハンドルを再利用して、別のリソースに割り当てることができます。環境条件次第ですが、複数のスレッドと Windows のハンドルの再利用を利用することで、あるスレッド上でハンドルをクローズして、別のスレッド上でクローズしたハンドルを利用することが可能になります。その結果、本来であれば利用できないはずのリソースへのアクセス権を不正に得ることができてしまいます。この可能性を打ち消すために、SafeHandle は参照カウンティングを実装しています。SafeHandle が P/Invoke 呼び出しに渡されると、ランタイムは SafeHandle の使用カウントをインクリメントします。P/Invoke がリターンすると、使用カウントはデクリメントされます。SafeHandle 上で Dispose または Close への呼び出しが行われると、使用カウントがチェックされます。使用カウントが 0 であると、処理を継続することができます。使用カウントが 0 より大きな数であると、処理はカウントが 0 になるまで遅延されます。この方法であれば、SafeHandle からハンドルの再利用攻撃を実行することは不可能です。ただし、この参照カウンティングには、それ程大きいものではありませんが負荷が伴います。この負荷が、何らかの理由で大きすぎる場合は、代替として、やはり System.Runtime.InteropServices 名前空間で提供される CriticalHandle クラスを利用することができます。CriticalHandle クラスは SafeHandle と似ていますが、参照カウンティングを実装しないという点が異なります。

SafeHandle の用法は単純です。図 5 に示す、SafeLibraryHandle の実装を考えてみましょう。このクラスは、LoadLibrary が提供するライブラリへのハンドルの格納に使われます。LoadLibrary は、参照カウンティングにより、参照がすべて開放されてはじめてライブラリを開放するようにしています。そのため、LoadLibrary への呼び出しが、FreeLibrary への呼び出しに一致するようにすることは重要です。また、LoadLibrary はハンドルを返すため、SafeHandle の代替としては申し分ありません。.NET Framework 1.x への最も多い要望は、P/Invoke 機能を使って、実行時に動的連結されたアンマネージド関数を呼び出すことができることでした。図 6 では、.NET Framework 2.0 による、この実践例を示しています。

図 6 SafeLibraryHandle を使用する

[return: MarshalAs(UnmanagedType.Bool)]

private delegate bool ReturnBooleanHandler();



[DllImport("kernel32.dll")]

private static extern IntPtr GetProcAddress(

    SafeLibraryHandle hModule, string procname);

...

using (SafeLibraryHandle lib = 

    SafeLibraryHandle.LoadLibrary("user32.dll"))

{

    IntPtr procAddr = GetProcAddress(lib, "LockWorkStation");

    Delegate d = Marshal.GetDelegateForFunctionPointer(

        procAddr, typeof(ReturnBooleanHandler));

    d.DynamicInvoke();

}

ページのトップへ


9. クリティカル領域

クリティカル領域は、領域内で実行中のコードがロックを保持しており、共有ステートを編集中である可能性を示すために使われます。要するに、別の形のロック カウントであるといえます。

クリティカル領域内でのスレッド破棄または未処理例外の影響は、現在のタスクだけにはとどまりません (.NET Framework 2.0 における未処理例外ポリシーへの変更については、本記事最後の 未処理例外 を参照してください)。ここで、スタティック メンバ変数 _someSharedLock を利用する次のようなコードを考えてみましょう。

lock(_someSharedLock) { /* ここで重要な処理を行います */ }

さて、ルード スレッド破棄がロック内で発生するとどうなるでしょう。破棄されたスレッドは、共有ステートを編集していた可能性が高く、もしそうであれば、その共有ステートが不整合状態に陥っている可能性も高くなります。さらに、スレッドの終了が早すぎたために、ロックを終了することができなかったため、そのロックは所有者を失った状態になっています。また、_someSharedLock 上のロックが開放されないと、他のスレッドも非常にデッドロックに陥りやすい状態になります。信頼性という観点から見た場合の最善策は、このスレッドが実行されていたアプリケーション ドメイン全体を破棄することです。

CLR ホストでは、何らかの問題が発生した場合にアプリケーション ドメイン全体を破棄したいと、クリティカル領域の開始、終了直前にマネージ コードで宣言することで、上記の選択肢を実装することができます。具体的には、Thread クラスの 新しい BeginCriticalRegion と EndCriticalRegion スタティック メソッドで実現可能です。クリティカル領域内でスレッドの強制終了が発生すると、その旨がホストに通知され、ホストは状況に合った処理を選択することができます (また、メモリ要求がクリティカル領域内で行われると、マネージド メモリ アロケーションに関わるホストにも、その旨が通知されます。これにより、ホストは要求の優先順位付けが可能になり、クリティカル領域の問題を最小限に抑えることができます)。SQL Server は、図 7 に示すような、この仕組みを利用した障害エスカレーション ポリシーを採用しています。ここでは、スレッドがクリティカル領域で破棄された場合に、ロックが保持されていて、コードが共有ステートを編集中である可能性が高い場合、アクションはアプリケーション ドメイン アンロードにエスカレーションされます。

図 7

図 7 ホスト エスカレーション ポリシー例

前に示した例では、クリティカル領域のモティベーションとして C# の lock キーワードを使用しましたが、これはあまりよい例とはいえません。C# の lock キーワード (Visual Basic でいう SyncLock) は、Monitor クラスを中心に構築されたものであるため、上記のような通知システムはすでに組み込まれています。それ以外のロッキング メカニズムは、Monitor ほどはシンプルではありません。

自動リセット イベントを考えてみましょう。スレッドが自動リセット イベントを "獲得する" とはどのような意味だと思いますか。実はそれほど大きな意味があるわけではありません。そのため、コードがクリティカル領域で実行中であることを認識するため、ランタイムは開発者の助けを必要とします。おかげで、皆さんが、EventWaitHandle (.NET Framework 2.0 では、ManualResetEvent および AutoResetEvent 派生による) やセマフォ、スピンロック (スピン ロックの詳細については、MSDNMagazine 今月号に掲載されている Jeffrey Richter のコラムを参照してください)、カスタム マネージド ロック メカニズム、あるいは P/Invoke によりアクセスするアンマネージド ロッキング メカニズムを使用するときは、必ず、Thread.BeginCriticalRegion と Thread.EndCriticalRegion の使用を検討しなければいけなくなってしまいました。

ページのトップへ


10. FailFast と MemoryGates

CER とクリティカル領域のおかげで、ランタイムと CLR ホストは、皆さんのコードをより確実に実行してくれるようになりましたが、ときには、最悪の事態を避けるために、誰の手も借りずに、一刻も早くアプリケーションを終了させなければいけない事態に遭遇することがあります。そのような状況では、アプリケーションのプロセスを迅速に終了することのできる方法が必要になります。そこで、FailFast の登場です。

System.Environment.FailFast は、次の 3 つの処理を行うだけのメソッドです。まず、致命的なエラーが発生していることを示す、Windows Application ログへのイベントを書き込みます。このメッセージには、FailFast に文字列パラメーターとして渡されたカスタム情報が含まれます。次に、Watson エラーを通知して、ミニダンプを生成し、Microsoft Windows Error Reporting (WER) サービスへのアップロードを行います。アプリケーションの WER データには、Windows Quality Online Services (winqual.microsoft.com (英語)) を使用することでアクセスできるので、そのデータを解析することで問題の原因を特定することができます。最後に、プロセスを強制終了します。以上が、FailFast が行う処理です。

FailFast は、問題が発生してしまった後の状況処理に適しています。では、問題が発生しないようにするための事前チェックについてはどうなのでしょうか。大量のメモリを必要とする処理を開始する前に、十分なリソースがあるかどうかをチェックする、メモリ ゲートというチェックの仕組みがあります。このチェックに失敗すると、処理が開始できないため、実行中のリソース不足が原因でアプリケーションが失敗するといった事態の可能性を減らすことができます。

.NET Framework 2.0 の場合、メモリ ゲートは、System.Runtime.MemoryFailPoint クラスにより実装されます。メモリ ゲートを使用するには、MemoryFailPoint のインスタンスを生成し、コンストラクタに対して、これから実行する処理が使用しようとしているメモリ量をメガバイト単位で渡します。指定のメモリ量が利用できない状態であると、(OutOfMemoryException から派生された) InsufficientMemoryException 例外が発生します。この仕組みは、実行中ではなく、実際の処理が開始する前に例外が発生し、一部の状況でのバックアウト コードへの依存を回避することができるという点で、非常に便利な仕組みであるといえます。通常の MemoryFailPoint の用法は次のようになります。

using(new MemoryFailPoint(10)) // 10 MB のメモリを必要とする処理
{
    ... // 処理の実行
}

MemoryFailPoint の現在の実装では、指定された分のメモリ量があるかどうかのチェックをシステムに対して行いますが、メモリの確保までは行いません。同じタイミングで、別のスレッドまたはプロセスが、それらのリソースを要求するといった状況も考えられますが、それでも、このようなメモリ ゲートは、何年もの間採用され続け、リソース消費の決定的なチェックポイントを提供する便利な手段として使われ続けています。

ページのトップへ


11. まとめ

色々なことがうまく行かない中で、信頼性のあるコードを記述することは、気の遠くなるような作業です。さいわい、長時間のアップタイム (動作可能時間) を必要とする CLR ホスト用のフレームワークやライブラリを記述しない限り、そうしたことをあまり気にする必要はないでしょう。そうでない人達には、.NET Framework 2.0 で、作業を簡便化して、信頼性のあるコードの記述を可能にする便利なツールを提供するという朗報をお届けします。こうしたシステムの仕組みや用法を理解することで、注意深く記述したアンマネージ コードに引けを取らない、信頼度の高いマネージ コードを記述することができるようになります。

ページのトップへ


12. 補足記事: 未処理例外

.NET Framework 1.x では、ときどき未処理例外によりプロセスが強制終了されることがあります。強制終了時の動作は的確で、例外が投げられたスレッドの種類や例外の種類によって異なります。スレッド プール スレッド上、ファイナライザ スレッド上、Thread.Start で生成されたスレッド上の未処理例外は、実行時にすべてランタイムによりバックストップされます。スレッド プール スレッドは、速やかにスレッド プールに返されます。ファイナライゼーション スレッドは、例外を消滅させます。Thread.Start で生成されたスレッドは、適切に終了します。アプリケーションのメイン スレッド上で未処理例外が発生すると、プロセスが強制終了します。また、Win32 CreateThread 関数で明示的に生成されたスレッドは、独自の規則セットを持ちます。

未処理例外を無視することにより、様々な信頼性の問題が発生してきました。本来であれば、システム クラッシュになるはずのエラーは?問題が即座に現われ、例外の発生元でのコール スタック等、重要なデバッグ情報を提供してくれることが多い?は、しばしばシステム パフォーマンスの低下を招き、最終的にシステム ハングやデッドロックが発生します。CLR チームは、この未処理例外ポリシーが不十分であるという判断を下し、その結果として、.NET Framework 2.0 では、従来のポリシーに代わり、こうした問題をより簡単に追跡することができる新しいポリシーに置き換えています。

.NET Framework 2.0 では、すべての未処理例外は、プロセスの強制終了となります。このルールに唯一の例外は、、フロー制御用に CLR で使われている、ThreadAbortException や AppDomainUnloadedException 等の例外です。新しいバージョンの .NET Framework で行われた数少ない変更の中の一つで、急遽決まった変更でもあります。そのため、動作を元に戻す方法が 2 通り用意されています。1 つ目は、コンフィグレーション ファイルを使用する方法です。次のようなコンフィグレーション ファイルを使用することで、従来の動作を有効にすることができます。

<system>
    <runtime><legacyUnhandledExceptionPolicy enabled="1"/></runtime>
</system>

2 つ目は、ICLRPolicyManager::SetUnhandledExceptionPolicy メソッドを使用する方法です。CLR をホストしているアプリケーションは、ICLRPolicyManager::SetUnhandledExceptionPolicy メソッドを利用することで、CLR に対して使用したい未処理例外ポリシーを伝えることができます。eRuntimeDeterminedPolicy を指定すると、ランタイムは、あらゆる未処理例外上のプロセスを破棄する、デフォルト ポリシーを使用します。一方、eHostDeterminedPolicy は、ほとんどの例外をプロセスにとっては致命的として扱わない、.NET Framework 1.x の動作に戻すことを CLR に伝えるときに使用します。ホストは、該当する例外関連のイベントを適切な AppDomain 上で発行することができるため、独自の未処理例外ポリシーを実装することができます。

ページのトップへ


13. 補足記事: マネージド デバッグ アシスタント

**堅牢なコードを記述することは簡単なことではありません。**これを支援するため、.NET Framework 2.0 には、実行時に発生する問題を検出してくれる、マネージド デバッグ アシスタント (MDA) という仕組みが用意されています。.NET Framework 1.x の機能として利用できる、カスタマ デバッグ プローブ (CDP) を使ったことがある人は、MDA は CDP の「先輩格」と考えてください。MDA は、.NET Framework に組み込まれたプローブ機能で、有効になっていると、ある特定の状態の有無をチェックし、状態が検出されるとユーザーに対して警告を行ってくれます。.NET Framework は、CER 関連の MDA として次の 4 種類の MDA を提供しています。これらの MDA を使用することにより、CER 関連のコードを記述した際に、期待通りに動作しない箇所の発見が楽になります。以下は、.NET Framework 2.0 で提供されている CER 関連 MDA の簡単な説明です。

IllegalPrepareConstrainedRegions: 誤った RuntimeHelpers.PrepareConstrainedRegions への呼び出しを警告します。MSIL レベルでは、PrepareConstrainedRegions への呼び出しは、try ブロックの開始前の命令である必要があります。それ以外の場所で呼び出しが行われると、この MDA が発動されます。

InvalidCERCall: CER 呼び出しグラフに脆弱な信頼性規約を持つメソッドが含まれていると発動されます。たとえば、信頼性規約を明示的に定義していないメソッドあるいは、非同期例外が発生した場合にアプリケーション ドメインまたはプロセス ワイド ステートが不正状態になる可能性を記した規約を持つメソッドなどがこれにあたります。

VirtualCERCall: CER 内の呼び出しグラフが、仮想メソッドへの呼び出しや、インターフェイス経由の呼び出し等、仮想ターゲットを利用した場合に発動されます。この MDA は、この呼び出しの実際のターゲットである可能性があるメソッドを準備するために、RuntimeHelpers.PrepareMethod を適切に使用できているかどうかを検証することを促します。

OpenGenericCERCall: CER 呼び出しグラフ内で少なくとも 1 つの参照型パラメータを使用してジェネリック タイプが使われていると発動されます。JIT コンパイラによって参照型として生成されたネイティブ コードは共有されますが、ジェネリック タイプ変数を持つメソッドは、そのメソッドの初めての実行中、遅れてリソース割り当てを行います (これらのリソースは、ジェネリック ディクショナリ エントリとして参照されます)。このシナリオの対処方法としては、仮想メソッドやインターフェイス メソッドの場合と同様、PrepareMethod を使用することができます。

MDA は、上記のような問題を早期に発見するために非常に便利です。もちろん、このような静的解析作業は、コンパイル済みのコード解析を行って問題がないかどうかをチェックする FxCop の規則をうまく利用することで、実行前でも可能です。しかしながら、残念なことに、Visual Studio 2005 リリースの FxCop には、このような規則は含まれていません (FxCop 規則の作成はよい宿題であると考えてください)。MDA を有効にする方法を含む、MDA の詳細については、.NET Framework のドキュメントを参照してください。


Stephen Toub は、MSDN Magazine のテクニカル エディターであり、NET Matters コラムの著者でもあります。


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

ページのトップへ