.NET Framework 2.0 でのプロファイラー スタックのウォーク: 基本以降

 

2006 年 9 月

David Broman
Microsoft Corporation

適用対象:
   Microsoft .NET Framework 2.0
   共通言語ランタイム (CLR)

概要:.NET Frameworkの共通言語ランタイム (CLR) でマネージド スタックをウォークするようにプロファイラーをプログラムする方法について説明します。 (14ページ印刷)

内容

はじめに
同期呼び出しと非同期呼び出し
ミックスアップ
最善の行動を取る
もうたくさん
クレジットの期限が切れているクレジット
著者について

はじめに

この記事は、マネージド アプリケーションを調べるためにプロファイラーを構築することに関心があるすべてのユーザーを対象にしています。 .NET Frameworkの共通言語ランタイム (CLR) でマネージド スタックをウォークするようにプロファイラーをプログラミングする方法について説明します。 時にはトピック自体が重くなる可能性があるため、気分を軽く保つようにします。

CLR のバージョン 2.0 のプロファイリング API には、プロファイリングするアプリケーションの呼び出し履歴をプロファイラーが確認できる DoStackSnapshot という名前の新しいメソッドがあります。 CLR のバージョン 1.1 では、インプロセス デバッグ インターフェイスを通じて同様の機能が公開されました。 ただし、 DoStackSnapshot を使用すると、呼び出し履歴の歩き方が簡単で正確になり、より安定します。 DoStackSnapshot メソッドは、ガベージ コレクター、セキュリティ システム、例外システムなどによって使用されるのと同じスタック ウォーカーを使用します。 だから、それは正しくなければならないことを 知っています

フル スタック トレースにアクセスすると、プロファイラーのユーザーは、関心のあることが発生したときにアプリケーションで何が起こっているかを把握できます。 アプリケーションやユーザーがプロファイルする内容に応じて、オブジェクトが割り当てられるときの呼び出し履歴、クラスの読み込み時、例外がスローされたときなどをユーザーが望んでいると考えることができます。 アプリケーション イベント以外の呼び出し履歴 (タイマー イベントなど) を取得する場合でも、サンプリング プロファイラーにとって興味深いものになります。 ホット スポットを含む関数を呼び出した関数を呼び出した関数を呼び出したユーザーを確認すると、コード内のホット スポットを見ると、より明るくなります。

DoStackSnapshot API を使用してスタック トレースを取得することに焦点を当てます。 スタック トレースを取得するもう 1 つの方法は、シャドウ スタックを構築することです。 FunctionEnterFunctionLeave をフックして、現在のスレッドのマネージド呼び出し履歴のコピーを保持できます。 シャドウ スタックの構築は、アプリケーションの実行中にスタック情報が常に必要な場合や、プロファイラーのコードをマネージド呼び出しごとに実行して戻るたびにパフォーマンス コストを気にしない場合に便利です。 DoStackSnapshot メソッドは、イベントへの応答など、スタックの少しスパースなレポートが必要な場合に最適です。 数ミリ秒ごとにスタック スナップショットを取得するサンプリング プロファイラーでも、シャドウ スタックの構築よりもはるかにスパースです。 そのため、 DoStackSnapshot はサンプリング プロファイラーに適しています。

ワイルドサイドでスタックウォークをする

呼び出し履歴は、必要なときにいつでも取得できると非常に便利です。 しかし、力には責任が伴います。 プロファイラー ユーザーは、スタック ウォークによってアクセス違反 (AV) やランタイムのデッドロックが発生することを望んでいません。 プロファイラー ライターは、注意して能力を発揮する必要があります。 DoStackSnapshot の使用方法と、それを慎重に行う方法について説明します。 ご覧のように、この方法を使用する必要が多いほど、正しく行うのが難しくなります。

このテーマを見てみましょう。 プロファイラーが呼び出す内容を次に示します (これは Corprof.idl の ICorProfilerInfo2 インターフェイスで確認できます)。

HRESULT DoStackSnapshot( 
  [in] ThreadID thread, 
  [in] StackSnapshotCallback *callback, 
  [in] ULONG32 infoFlags, 
  [in] void *clientData, 
  [in, size_is(contextSize), length_is(contextSize)] BYTE context[], 
  [in] ULONG32 contextSize); 

次のコードは、CLR がプロファイラーで呼び出すコードです。 (これは Corprof.idl でも確認できます)。前の例の callback パラメーターで、この関数の実装へのポインターを渡します。

typedef HRESULT __stdcall StackSnapshotCallback( 
  FunctionID funcId, 
  UINT_PTR ip, 
  COR_PRF_FRAME_INFO frameInfo, 
  ULONG32 contextSize, 
  BYTE context[], 
  void *clientData); 

まるでサンドイッチのような感じです。 プロファイラーがスタックをウォークする場合は、 DoStackSnapshot を呼び出します。 CLR は、その呼び出しから戻る前に、 StackSnapshotCallback 関数を複数回呼び出します。マネージド フレームごとに 1 回、またはスタック上のアンマネージド フレームの実行ごとに 1 回呼び出します。 このサンドイッチを図 1 に示します。

図 1. プロファイリング中の呼び出しの "サンドイッチ"

私の表記からわかるように、CLR は、フレームがスタックにプッシュされた方法 (リーフ フレームが最初 (最後にプッシュ)、フレームが最後にプッシュされた (最初にプッシュされた) メイン逆の順序でフレームを通知します。

これらの関数のすべてのパラメーターは何を意味しますか? 私はまだそれらすべてを話し合う準備ができていませんが、 DoStackSnapshotから始めて、それらのいくつかについて話し合います。 (しばらくすると残りの部分に到達します。) infoFlags 値は Corprof.idl の COR_PRF_SNAPSHOT_INFO 列挙体から取得され、CLR がレポートするフレームの登録コンテキストを提供するかどうかを制御できます。 clientData には任意の値を指定できます。CLR によって StackSnapshotCallback 呼び出しで返されます。

StackSnapshotCallback では、CLR は funcId パラメーターを使用して、現在ウォークされているフレームの FunctionID 値を渡します。 現在のフレームがアンマネージド フレームの実行である場合、この値は 0 です。これについては後で説明します。 funcId が 0 以外の場合は、関数に関する詳細情報を取得するために、funcIdframeInfoGetFunctionInfo2GetCodeInfo2 などの他のメソッドに渡すことができます。 この関数情報は、スタック ウォーク中にすぐに取得することも、 funcId 値を保存して後で関数情報を取得することもできます。これにより、実行中のアプリケーションへの影響が軽減されます。 後で関数情報を取得する場合は、 frameInfo 値が、それを提供するコールバック内でのみ有効であることを覚えておいてください。 後で使用するために funcId 値を保存してもかまいませんが、後で使用するために frameInfo を保存しないでください。

StackSnapshotCallback から戻ると、通常は S_OKが返され、CLR はスタックのウォークを続行します。 必要に応じて、スタック ウォーク を停止するS_FALSEを返すことができます。 DoStackSnapshot 呼び出しでは、CORPROF_E_STACKSNAPSHOT_ABORTEDが返されます。

同期呼び出しと非同期呼び出し

DoStackSnapshot は、同期と非同期の 2 つの方法で呼び出すことができます。 同期呼び出しは、正しく行うのが最も簡単です。 CLR がプロファイラーの ICorProfilerCallback(2) メソッドのいずれかを呼び出すときに同期呼び出しを行い、それに応じて DoStackSnapshot を呼び出して現在のスレッドのスタックをウォークします。 これは、 ObjectAllocated のような興味深い通知ポイントでスタックの外観を確認する場合に便利です。 同期呼び出しを実行するには、ICorProfilerCallback(2) メソッド内から DoStackSnapshot を呼び出し、伝えていないパラメーターに対して 0 または null を渡します。

非同期スタック ウォークは、別のスレッドのスタックをウォークするとき、またはスレッドを強制的に割り込んでスタック ウォークを実行する (それ自体または別のスレッドで) 場合に発生します。 スレッドを中断するには、スレッドの命令ポインターを乗っ取って、任意の時間に独自のコードを強制的に実行する必要があります。 これは、ここにリストする理由が多すぎるため、非常に危険です。 お願い、やめないでください。 非同期スタック ウォークの説明を 、DoStackSnapshot の非ハイジャック使用に制限して、別のターゲット スレッドをウォークします。 スタック ウォークの開始時にターゲット スレッドが任意の時点で実行されていたため、この "非同期" と呼びます。 この手法は、一般的にサンプリング プロファイラーによって使用されます。

他の人の上を歩く

クロススレッド、つまり非同期のスタック ウォークを少し分解しましょう。 現在のスレッドとターゲット スレッドの 2 つのスレッドがあります。 現在のスレッドは、 DoStackSnapshot を実行しているスレッドです。 ターゲット スレッドは、 DoStackSnapshot によってスタックがウォークされるスレッドです。 ターゲット スレッドを指定するには、スレッド パラメーターの スレッド ID を DoStackSnapshot に渡します。 次に何が起こるかは、心のかすかな人のためではありません。 スタックをウォークするように求められたときに、ターゲット スレッドが任意のコードを実行していた点に注意してください。 そのため、CLR はターゲット スレッドを中断し、そのスレッドが歩いている間ずっと中断された状態を維持します。 これは安全に行うことができますか?

私はあなたが尋ねてうれしいです。 これは本当に危険です、そして私はこれを安全に行う方法について後でいくつか話します。 しかし、まず、混合モードのスタックに入ります。

ミックスアップ

マネージド アプリケーションは、マネージド コードですべての時間を費やす可能性は高いわけではありません。 PInvoke 呼び出しと COM 相互運用機能を使用すると、マネージド コードでアンマネージ コードを呼び出すことができ、デリゲートを使用して再び呼び出すことがあります。 マネージド コードは、アンマネージ ランタイム (CLR) を直接呼び出して JIT コンパイル、例外の処理、ガベージ コレクションの実行などを行います。 そのため、スタック ウォークを実行すると、混合モードのスタックが発生する可能性があります。一部のフレームはマネージド関数、他のフレームはアンマネージド関数です。

もう、大きくなれ!

先に進む前に、短い間奏で。 最新の PC のスタックが小さなアドレスに拡張 (つまり"プッシュ") されることを誰もが知っています。 しかし、心やホワイトボードでこれらのアドレスを視覚化する場合、それらを垂直方向に並べ替える方法に同意しません。 私たちの中には、スタックが 成長 している(上に小さなアドレス)と想像する人もいます。一部の人は、それが 下に 成長しているのを見る(下部の小さなアドレス)。 この問題はチームでも分かれています。 私は今まで使用したデバッガーを使用することを選択します。コールスタックトレースとメモリダンプは、小さなアドレスが大きなアドレスの上にあると私に伝えます。 そのため、スタックは大きくなります。メインは下部にあり、リーフ呼び出し先は上部にあります。 同意しない場合は、記事のこの部分を通過するためにいくつかの精神的な再配置を行う必要があります。

ウェイター、私のスタックに穴がある

同じ言語を話すようになったので、混合モードスタックを見てみましょう。 図 2 は、混合モード スタックの例を示しています。

図 2. マネージド フレームとアンマネージド フレームを含むスタック

少し戻ると、 最初に DoStackSnapshot が存在する理由を理解する価値があります。 スタック上の マネージド フレームをウォークするのに役立ちます。 マネージド フレームを自分でウォークしようとすると、マネージド コードで使用される異常な呼び出し規則が原因で、特に 32 ビット システムでは信頼性の低い結果が得られます。 CLR はこれらの呼び出し規則を理解しているため、 DoStackSnapshot はデコードに役立ちます。 ただし、 DoStackSnapshot は、アンマネージド フレームを含むスタック全体をウォークできるようにする場合は、完全なソリューションではありません。

選択できる場所を次に示します。

オプション 1: 何も行わないで、"アンマネージド ホール" を含むスタックをユーザー (... ) に報告します。

オプション 2: これらの穴を埋めるために、独自のアンマネージド スタック ウォーカーを記述します。

DoStackSnapshot がアンマネージド フレームのブロックに遭遇すると、前に説明したように、funcId を 0 に設定して StackSnapshotCallback 関数を呼び出します。 オプション 1 を使用する場合は、 funcId が 0 のときにコールバックで何も行わないでください。 CLR は次のマネージド フレームに対して再度呼び出しを行い、その時点でウェイクアップできます。

アンマネージ ブロックが複数のアンマネージド フレームで構成されている場合でも、CLR は StackSnapshotCallback を 1 回だけ呼び出します。 CLR はアンマネージド ブロックをデコードする努力を行っていないので、ブロックを次のマネージド フレームにスキップするのに役立つ特別なインサイダー情報があり、それがどのように進行するかに注意してください。 CLR は、必ずしもアンマネージド ブロック内の内容を認識するとは限りません。 これは、そのため、オプション 2 を理解する必要があります。

その最初のステップはドジーです

どのオプションを選択しても、アンマネージド 穴を埋めるのが唯一の難しい部分ではありません。 歩き始めたばかりののは難しいかもしれません。 上のスタックを見てみましょう。 先頭にアンマネージド コードがあります。 場合によっては、運が良く、アンマネージド コードは COM または PInvoke コードになります。 その場合、CLR はそれをスキップする方法を知るのに十分なスマートであり、最初のマネージド フレーム (例では D) でウォークを開始します。 ただし、スタックを可能な限り完全に報告するために、最も管理されていないブロックをウォークすることもできます。

最上位のブロックを歩きたくない場合でも 、運が 良くない場合は、アンマネージ コードは COM または PInvoke コードではなく、CLR 自体のヘルパー コード (JIT コンパイルやガベージ コレクションを実行するコードなど) に強制される可能性があります。 その場合、CLR はヘルプなしで D フレームを見つけることができません。 そのため、 DoStackSnapshot に対するシードされていない呼び出しでは、エラー CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX または CORPROF_E_STACKSNAPSHOT_UNSAFEが発生します。 (ところで、corerror.h を訪問することは本当に価値があります。)

"unseeded" という単語を使用していることに注意してください。DoStackSnapshot は、context パラメーターと contextSize パラメーターを使用してシードコンテキスト を取得します。 "context" という単語は、多くの意味でオーバーロードされています。 この場合、私はレジスタコンテキストについて話しています。 アーキテクチャに依存する Windows ヘッダー (nti386.h など) を熟読すると、 CONTEXT という名前の構造体が見つかります。 これには CPU レジスタの値が含まれており、特定の時点での CPU の状態を表します。 それは私が話しているコンテキストの種類です。

コンテキスト パラメーターに null を渡すと、スタック ウォークはシード解除され、CLR は先頭から始まります。 ただし、 コンテキスト パラメーターに null 以外の値を渡し、スタックの下にある特定の場所 (D フレームを指すなど) の CPU 状態を表す場合、CLR はコンテキストでシード処理されたスタック ウォークを実行します。 スタックの実際の最上部は無視され、ポイントした場所から開始されます。

さて、まったく真実ではありません。 DoStackSnapshot に渡すコンテキストは、完全なディレクティブよりもヒントです。 CLR が最初のマネージド フレームを見つけることができると確信している場合 (最もアンマネージド ブロックが PInvoke または COM コードであるため)、それを行い、シードを無視します。 ただし、個人的には受け入れないでください。 CLR は、可能な最も正確なスタック ウォークを提供することで支援しようとしています。 シードは、最もアンマネージ ブロックが CLR 自体のヘルパー コードである場合にのみ役立ちます。これは、スキップに役立つ情報がないためです。 したがって、シードは、CLR がそれ自体でウォークを開始する場所を決定できない場合にのみ使用されます。

あなたは最初に私たちに種子を提供する方法を疑問に思うかもしれません。 ターゲット スレッドがまだ中断されていない場合は、ターゲット スレッドのスタックをウォークして D フレームを見つけ、シード コンテキストを計算することはできません。 しかし、DoStackSnapshotを呼び出す前にアンマネージドウォークを行い、DoStackSnapshotがターゲットスレッドを中断する前にシードコンテキストを計算するように指示しています。 ターゲット スレッドは、ユーザー と CLR によって 中断される必要がありますか? 実際には、はい。

このバレエを振り付ける時だと思います。 しかし、私が深くなる前に、スタックウォークをシードするかどうかと方法の問題は 非同期 ウォークにのみ適用されることに注意してください。 同期的なウォークを行っている場合、 DoStackSnapshot は常に、あなたの助けを借りずに最上位のマネージド フレームへの道を見つけることができます。シードは必要ありません。

今すぐまとめる

アンマネージド ホールを埋めながら、非同期のクロススレッドのシード処理されたスタック ウォークを実行している、本当に冒険的なプロファイラーの場合、スタック ウォークの外観を次に示します。 ここで示すスタックは、図 2 で見たものと同じスタックであり、少しだけ分割されているとします。

スタック コンテンツ プロファイラーと CLR のアクション

1. ターゲット スレッドを中断します。 (ターゲット スレッドの中断回数が 1 になりました)。

2. ターゲット スレッドの現在のレジスタ コンテキストを取得します。

3. レジスタ コンテキストがアンマネージ コードを指しているかどうかを判断します。つまり、ICorProfilerInfo2::GetFunctionFromIP を呼び出し、FunctionID 値 0 を取得するかどうかをチェックします。

4. この例では、レジスタ コンテキストがアンマネージド コードを指しているため、最も一番上のマネージド フレーム (関数 D) が見つかるまでアンマネージド スタック ウォークを実行します。

5. シード コンテキストを使用して DoStackSnapshot を呼び出すと、CLR によってターゲット スレッドが再度中断されます。 (中断回数は 2 になりました)。サンドイッチが始まります。
a. CLR は、D の FunctionID を使用して StackSnapshotCallback 関数を呼び出します。
b. CLR は、FunctionID が 0 の StackSnapshotCallback 関数を呼び出します。 このブロックは自分で歩く必要があります。 最初のマネージド フレームに到達したら停止できます。 または、次のコールバックによって次のマネージド フレームの開始場所とアンマネージド ウォークの終了場所が正確に示されるため、アンマネージド ウォークをチートして、次のコールバックの後にしばらく待つことができます。
c. CLR は、C の FunctionID を使用して StackSnapshotCallback 関数を呼び出します。
d. CLR は、B の FunctionID を使用して StackSnapshotCallback 関数を呼び出します。
e. CLR は、FunctionID が 0 の StackSnapshotCallback 関数を呼び出します。 ここでも、このブロックを自分で歩く必要があります。
f. CLR は、A の FunctionID を使用して StackSnapshotCallback 関数を呼び出します。
g. CLR は、Main の FunctionID を使用して StackSnapshotCallback 関数を呼び出します。

h. Dostacksnapshot Win32 ResumeThread() API を呼び出してターゲット スレッドを "再開" します。これにより、スレッドの中断カウント (中断カウントが 1 になりました) がデクリメントされ、 が返されます。 サンドイッチは完成です。
6. ターゲット スレッドを再開します。 中断カウントが 0 になったため、スレッドは物理的に再開されます。

最善の行動を取る

さて、これは深刻な注意なしにあまりにも多くの力です。 最も高度なケースでは、タイマー割り込みに応答し、アプリケーション スレッドを任意に中断してスタックをウォークします。 Yikes!

良いことは難しく、最初は明らかではないルールが含まれます。 それでは、詳しく見ていきましょう。

悪い種

簡単なルールから始めましょう。不適切なシードを使用しないでください。 DoStackSnapshot を呼び出すときにプロファイラーが無効な (null 以外の) シードを提供すると、CLR によって不適切な結果が得られます。 ここでは、ポイント先のスタックを確認し、スタック上の値が何を表すことになっているかを想定します。 これにより、CLR はスタック上のアドレスと見なされるものを逆参照します。 シードが正しくない場合、CLR は値をメモリ内の不明な場所に逆参照します。 CLR は、プロファイリングするプロセスを破棄する可能性のある、万全の 2 回目の AV を回避するためにできることをすべて実行します。 しかし、あなたは本当にあなたの種子を正しく得るために努力する必要があります。

中断の苦境

スレッドを中断するその他の側面は、複数のルールを必要とするほど複雑です。 クロススレッド ウォーキングを行う場合は、少なくとも CLR に代わってスレッドを中断するように求めることにしました。 さらに、スタックの上部にあるアンマネージド ブロックをウォークする場合は、現時点でこれが良いアイデアかどうかについて CLR の知恵を呼び出さずに、スレッドを自分で中断することにしました。

コンピュータサイエンスの授業を受けた場合、おそらく「食事の哲学者」の問題を覚えています。 哲学者のグループがテーブルに座り、それぞれ右側にフォークが 1 つ、左側に 1 つが配置されています。 問題によると、彼らはそれぞれ2つのフォークを食べる必要があります。 各哲学者は彼の右のフォークを拾いますが、各哲学者が必要なフォークを置くために彼の左に哲学者を待っているので、誰も彼の左フォークを拾うことができます。 哲学者が円形のテーブルに座っている場合、あなたは待っているサイクルと空の胃の多くを持っています。 すべてが枯渇している理由は、デッドロック回避の単純なルールを破るためです。複数のロックが必要な場合は、常に同じ順序で行います。 この規則に従うと、A が B で待機し、B が C で待機し、C が A で待機するサイクルを回避できます。

アプリケーションがルールに従い、常に同じ順序でロックを受け取るとします。 コンポーネント (プロファイラーなど) が提供され、スレッドの任意の中断が開始されます。 複雑さが大幅に増加しています。 サスペンダーがサスペンディーによって保持されているロックを取る必要がある場合はどうしますか? または、サスペンダーが、サスペンダーによって保持されているロックを待機している別のスレッドによって保持されているロックを待機しているスレッドによって保持されているロックが必要な場合はどうなりますか? 中断によってスレッド 依存関係グラフに新しいエッジが追加され、サイクルが発生する可能性があります。 いくつかの特定の問題を見てみましょう。

問題 1: サスペンダーが必要とするロック、またはサスペンダーが依存するスレッドで必要なロックを、中断対象ユーザーが所有します。

問題 1a: ロックは CLR ロックです。

ご想像のとおり、CLR は多くのスレッド同期を実行するため、内部的に使用されるロックがいくつかあります。 DoStackSnapshot を呼び出すと、CLR は、スタック ウォークを実行するために現在のスレッド (DoStackSnapshot を呼び出しているスレッド) が必要とする CLR ロックをターゲット スレッドが所有していることを検出します。 その状態が発生すると、CLR は中断の実行を拒否し、 DoStackSnapshot はエラー CORPROF_E_STACKSNAPSHOT_UNSAFEと共に直ちに戻ります。 この時点で、 DoStackSnapshot の呼び出しの前にスレッドを自分で中断した場合は、スレッドを自分で再開し、問題を回避しました。

問題 1b: ロックは、独自のプロファイラーのロックです。

この問題は、実際にはより一般的な問題です。 ここでは、独自のスレッド同期を行う場合があります。 アプリケーション スレッド (スレッド A) がプロファイラー コールバックを検出し、プロファイラーのロックのいずれかを受け取るプロファイラー コードの一部を実行するとします。 その後、スレッド B はスレッド A をウォークする必要があります。つまり、スレッド B はスレッド A を中断します。スレッド A が中断されている間は、スレッド A が所有する可能性があるプロファイラー独自のロックをスレッド B が取得しないように注意する必要があります。 たとえば、スレッド B はスタック ウォーク中に StackSnapshotCallback を 実行するため、スレッド A が所有できるそのコールバック中にロックを解除しないでください。

問題 2: ターゲット スレッドを中断している間に、ターゲット スレッドによって中断が試みられます。

"それは起こり得ません" と言うかもしれません。信じられないかもしれませんが、次の場合に可能です。

  • アプリケーションはマルチプロセッサ ボックスで実行され、
  • スレッド A は 1 つのプロセッサで実行され、スレッド B は別のプロセッサで実行され、
  • スレッド A はスレッド B を中断しようとしますが、スレッド B はスレッド A を中断しようとします。

その場合、両方の中断が優先され、両方のスレッドが中断される可能性があります。 各スレッドは、他のスレッドが起動するのを待機しているため、中断された状態を永久に維持します。

DoStackSnapshot を呼び出すにスレッドが互いに中断することを CLR に依存して検出できないため、この問題は問題 1 よりも混乱しています。 そして、あなたが中断を実行した後、それは遅すぎます!

ターゲット スレッドがプロファイラーを中断しようとしているのはなぜですか? 架空の記述が不十分なプロファイラーでは、スタック ウォーキング コードと中断コードは、任意の回数のスレッドによって任意の回数実行される可能性があります。 スレッド A がスレッド A を歩こうとしているのと同時にスレッド B を歩こうとしているとします。どちらもプロファイラーのスタック ウォーキング ルーチンの SuspendThread 部分を実行しているため、互いに同時に中断しようとします。 win とプロファイリング対象のアプリケーションの両方がデッドロックしています。 ここでのルールは明らかです。プロファイラーが 2 つのスレッドでスタック ウォーキング コード (つまり中断コード) を同時に実行することを許可しないでください。

ターゲット スレッドがウォーキング スレッドを中断しようとする可能性がある明らかな理由は、CLR の内部動作によるものです。 CLR は、ガベージ コレクションなどのタスクに役立つアプリケーション スレッドを中断します。 ガベージ コレクター スレッドがウォーカーを中断しようと同時にガベージ コレクションを実行するスレッドをウォーク (したがって中断) しようとすると、プロセスはデッドロックされます。

しかし、問題を回避するのは簡単です。 CLR は、その作業を行うために中断する必要があるスレッドのみを中断します。 スタック ウォークに 2 つのスレッドが関係しているとします。 スレッド W は、現在のスレッド (ウォークを実行しているスレッド) です。 スレッド T はターゲット スレッド (スタックがウォークされるスレッド) です。 スレッド W が マネージド コードを実行したことがなく、CLR ガベージ コレクションの対象にならない限り、CLR はスレッド W の中断を試みることはありません。これは、プロファイラーが Thread W でスレッド T を中断しても安全です。

サンプリング プロファイラーを記述する場合は、このすべてを確実に行うのが自然です。 通常、タイマー割り込みに応答し、他のスレッドのスタックを歩く独自の作成の別のスレッドがあります。 これをサンプラー スレッドと呼びます。 サンプラー スレッドを自分で作成し、実行内容を制御できるため (したがってマネージド コードは実行されません)、CLR はそれを中断する理由はありません。 すべてのスタック ウォークを実行する独自のサンプリング スレッドを作成するようにプロファイラーを設計すると、前述の "不適切に記述されたプロファイラー" の問題も回避できます。 サンプラー スレッドは、他のスレッドをウォークまたは中断しようとしているプロファイラーの唯一のスレッドであるため、プロファイラーはサンプラー スレッドを直接中断しようとしません。

これは私たちの最初の非トリビアルルールなので、強調するために私はそれを繰り返しましょう:

ルール 1: マネージド コードを実行したことがないスレッドのみが、別のスレッドを中断する必要があります。

誰も死体を歩くのが好き

スレッド間スタック ウォークを実行する場合は、ターゲット スレッドがウォーク中もアクティブなままであることを確認する必要があります。 ターゲット スレッドをパラメーターとして DoStackSnapshot 呼び出しに渡したからといって、それに暗黙的に任意の種類の有効期間参照を追加したわけではありません。 アプリケーションは、いつでもスレッドを削除できます。 スレッドをウォークしようとしているときにそれが発生した場合は、アクセス違反が発生する可能性があります。

幸いなことに、CLR は、ICorProfilerCallback(2) インターフェイスで定義された適切な名前の ThreadDestroyed コールバックを使用して、スレッドが破棄されようとしたときにプロファイラーに通知します。 ThreadDestroyed を実装し、そのスレッドを歩くプロセスが完了するまで待機させるのは、ユーザーの責任です。 これは、次のルールとして資格を得るのに十分な興味深いものです。

ルール 2: ThreadDestroyed コールバックをオーバーライドし、破棄されるスレッドのスタックのウォークが完了するまで実装を待機させます。

規則 2 に従うと、そのスレッドのスタックのウォークが完了するまで、CLR はスレッドを破棄できなくなります。

ガベージ コレクションは、サイクルを作成するのに役立ちます

この時点で物事は少し混乱する可能性があります。 次のルールのテキストから始めて、そこから解読してみましょう。

ルール 3: ガベージ コレクションをトリガーできるプロファイラー呼び出し中にロックを保持しないでください。

先ほど述べましたが、所有しているスレッドが中断される可能性があり、スレッドが同じロックを必要とする別のスレッドによって移動される可能性がある場合は、プロファイラーが自身のロックを保持するのは悪い考えです。 ルール 3 は、より微妙な問題を回避するのに役立ちます。 ここでは、所有スレッドがガベージ コレクションをトリガーする可能性のある ICorProfilerInfo(2) メソッドを呼び出す場合は、独自のロックを保持してはいけないと言っています。

いくつかの例が役立つはずです。 最初の例では、スレッド B がガベージ コレクションを実行しているとします。 シーケンスは次のとおりです。

  1. スレッド A は プロファイラー ロックの 1 つを受け取り、所有するようになりました。
  2. スレッド B は、プロファイラーの GarbageCollectionStarted コールバックを 呼び出します。
  3. スレッド B は、手順 1 のプロファイラー ロックでブロックします。
  4. スレッド A は 、GetClassFromTokenAndTypeArgs 関数を実行します
  5. GetClassFromTokenAndTypeArgs 呼び出しはガベージ コレクションのトリガーを試みますが、ガベージ コレクションが既に進行中であることを検出します。
  6. スレッド A はブロックし、現在進行中のガベージ コレクション (スレッド B) の完了を待機します。 ただし、プロファイラーロックのため、スレッド B はスレッド A を待機しています。

図 3 は、この例のシナリオを示しています。

図 3: プロファイラーとガベージ コレクターの間のデッドロック

2 番目の例は、少し異なるシナリオです。 シーケンスは次のとおりです。

  1. スレッド A がプロファイラー ロックの 1 つを取得し、所有するようになりました。
  2. スレッド B は、プロファイラーの ModuleLoadStarted コールバックを 呼び出します。
  3. 手順 1 のプロファイラー ロックのスレッド B ブロック。
  4. スレッド A は 、GetClassFromTokenAndTypeArgs 関数を実行します
  5. GetClassFromTokenAndTypeArgs 呼び出しによってガベージ コレクションがトリガーされます。
  6. スレッド A (現在はガベージ コレクションを実行しています) は、スレッド B が収集される準備が整うのを待ちます。 ただし、プロファイラーロックのため、スレッド B はスレッド A を待機しています。
  7. 図 4 は、2 番目の例を示しています。

図 4: プロファイラーと保留中のガベージ コレクションの間のデッドロック

あなたは狂気を消化しましたか? 問題の核心は、ガベージ コレクションに独自の同期メカニズムがあるということです。 最初の例の結果は、一度に 1 つのガベージ コレクションしか発生できないために発生します。 これは確かにフリンジケースです。これは、一般的にガベージ コレクションが頻繁に発生しないため、ストレスの多い条件下で動作しない限り、別のガベージ コレクションを待機する必要があるためです。 それでも、このシナリオが十分に長くプロファイルされている場合は、それに備える必要があります。

2 番目の例の結果は、ガベージ コレクションを実行するスレッドが、他のアプリケーション スレッドがコレクションの準備ができているのを待機する必要があるために発生します。 この問題は、独自のロックの 1 つをミックスに導入し、サイクルを形成するときに発生します。 どちらの場合も、スレッド A がプロファイラー ロックの 1 つを所有し、 GetClassFromTokenAndTypeArgs を呼び出すことでルール 3 が壊れます。 (実際には、ガベージ コレクションをトリガーする可能性のあるメソッドを呼び出すことで、プロセスを実行できます)。

あなたはおそらく今までにいくつかの質問があります。

Q. ガベージ コレクションをトリガーする可能性がある ICorProfilerInfo(2) メソッドを知る方法

A. これを MSDN、または少なくとも ブログ または Jonathan Keljo のブログで文書化する予定です。

Q. これはスタックウォーキングと何の関係がありますか? DoStackSnapshot のメンションはありません。

A. 正解です。 DoStackSnapshot は、ガベージ コレクションをトリガーする ICorProfilerInfo(2) メソッドの 1 つでもありません。 ここでルール 3 について説明するのは、冒険的なプログラマが、独自のプロファイラー ロックを実装する可能性が最も高く、このトラップに陥りやすい任意のサンプルからスタックを非同期的に歩いているからです。 実際、ルール 2 は基本的にプロファイラーに同期を追加するように指示します。 サンプリング プロファイラーには、共有データ構造の読み取りと書き込みを任意の時間に調整するために、他の同期メカニズムも含まれる可能性が高いです。 もちろん、 DoStackSnapshot に触れることのないプロファイラーでも、この問題が発生する可能性があります。

もうたくさん

私はハイライトの簡単な要約で終わるつもりです。 覚えておくべき重要なポイントを次に示します。

  • 同期スタックウォークでは、プロファイラーコールバックに応答して現在のスレッドを歩きます。 シード処理、中断、または特別なルールは必要ありません。
  • 非同期ウォークでは、スタックの先頭がアンマネージ コードであり、 PInvoke または COM 呼び出しの一部ではない場合はシードが必要です。 シードを指定するには、ターゲット スレッドを直接中断し、最上位のマネージド フレームが見つかるまで自分で実行します。 この場合にシードを指定しない場合、 DoStackSnapshot はエラー コードを返すか、スタックの上部にあるフレームをスキップする可能性があります。
  • スレッドを中断する必要がある場合は、マネージド コードを実行したことがないスレッドのみが別のスレッドを中断する必要があります。
  • 非同期ウォークを実行する場合は、 常に ThreadDestroyed コールバックをオーバーライドして、スレッドのスタック ウォークが完了するまで CLR がスレッドを破棄するのをブロックします。
  • ガベージ コレクションをトリガーできる CLR 関数がプロファイラーで呼び出されている間は、ロックを保持しないでください。

プロファイリング API の詳細については、MSDN Web サイトの 「プロファイル (アンマネージド)」 を参照してください。

クレジットの期限が切れているクレジット

これらのルールを作成することは本当にチームの努力であるため、CLR プロファイリング API チームの残りの部分に感謝のメモを含めておきたいと思います。 このコンテンツの多くを以前に提供したショーン・セリトレンニコフに感謝します。

 

作成者について

David は、知識と成熟度が限られているため、Microsoft の開発者として、思った以上に長い時間を過ごしてきました。 コードでチェックすることはもう許可されていませんが、新しい変数名のアイデアを提供しています。 Davidはチョクラカウントの熱心なファンであり、彼自身の車を所有しています。