Silverlight をインストールするには、ここをクリックします*
Japan変更|すべてのMicrosoft のサイト
MSDN
|MSDN ライブラリ|デベロッパー センター|ダウンロード情報|開発ツール製品|コミュニティ|ご意見・ご要望|サイトマップ
MSDN Magazine  
   MSDN Home >  MSDN マガジン >  2005 年 7 月
リフレクション

陥りがちなパフォーマンス問題を回避することによる高速アプリケーションの作成


この記事の一部は、.NET Framework 2.0 のプレリリース バージョンに基づいています。各セクションは、変更されることがあります。

この記事で取り上げる話題:
  • リフレクションのパフォーマンスを改善する方法
  • 早期バインドと遅延バインドの起動
  • メンバのキャッシュとハンドル
  • リフレクション使用のベスト プラクティス
この記事で使用する技術:
.NET Framework および C#


目次
補足記事


リフレクションを使うことは、API を介した値切り交渉に似ています。基本的な支払の他、ある程度の譲歩も必要です。しかし、それだけの価値はあります。.NET でのリフレクションは、アプリケーションの拡張性を高めるためのの最も強力な機能の 1 つです。リフレクションを使用することにより、型のロード、型のメンバの把握、型に関する意思決定、および実行を、いずれも管理されたランタイムの安全性を確保した上で達成することができます。ただし、このパワーを巧みに使用するには、関連するコストと問題点を把握することが重要です。

この記事では、どのリフレクション タスクのコストが高く、どのリフレクション タスクのコストが低いかについて説明します。この記事から、コストと効果のトレードオフを判定し、フレームワークの内容に分け入り、内部機能や内部構造をきちんと把握することが可能になります。次に、Microsoft .NET Framework 2.0 でのリフレクションに関する新着情報を紹介し、一部の機能の実行コストが低くなった理由について説明します。


遅い処理と速い処理

図 1 に通常使用されるリフレクション API の一部を紹介します。間もなくわかるように、一部の API は高速かつ軽量ですが、一部の API は反対です。

一般に軽量な機能が高速な理由は、リフレクション インフラストラクチャが超高速ランタイム データ構造体に必要な情報をあらかじめ保持しているか、Just-In-Time (JIT) コンパイラがこれらのメソッド呼び出しとコード パターンを検出し、特別な最適化を実現することにより処理のある程度の高速化を図っている、のいずれかです。リフレクション JIT 最適化の適例は、C# typeof メソッドと基本クラス ライブラリの (BCL) Object.GetType メソッドの 2 つです。いずれも、型相等性の検査のため BCL で広く使用され、その結果、これらのメソッドは、最適なパフォーマンスを確保するため、特別なコーディングを必要としていました。ただし、この記事では、コストがかかるリフレクション API に注目します。

Back to top

リフレクションを使用すべき場面

リフレクションを使用する方法については、必ず慎重に検討してください。厳密なパフォーマンス条件を適用しないで適時リフレクションを使用することが、望ましい方法と考えられます。リフレクション API の呼び出しを、アプリケーションの中でサードパーティのプラグインをロードして起動する部分の呼び出し時に限定すれば、コストは妥当なものとなるはずです。ただし、高いスループットと速いレスポンスが必要な大容量 ASP.NET Web サイトが絡むシナリオで、"高速パス" (アプリケーションの中で、非常に高速で実行する必要があり、頻繁に使用されるコード) で重いリフレクション API をかなり使用する条件では、アーキテクチャに関する検討を実際に加えることにより、リフレクションの使用に関する判断が正しかったかどうかを判定することが必要です。

古典的例として、サードパーティのプラグインのホストとして機能するアプリケーションを作成しているとします。このようなプラグインの呼び出しは、そのほとんどが、処理をそれほど実行しないコード領域で発生していますか。それとも、そのような呼び出しは頻繁にアクセスされますか。また、リフレクションが必要であり、同時にアプリケーションを高速にしなければならない場合にはどうしますか。

リフレクションを検討するときに最初に考慮することは、アプリケーションの拡張部が静的にインターフェイスとして定義できるか、基本クラスとして定義できるかとか、という点です。拡張部のメンバを呼び出すときの最もクリーンでパフォーマンスが最良となる方法は、コンシューマが実装するコントラクトを静的に定義し、そのコントラクトを使用して安全な起動を実現することです。通常このコントラクトは、インターフェイスまたは基本クラスにより定義され、コンシューマは、このコントラクトに対するアクセス権を得て、その実装を実現します。例を示すため、ログ記録インフラストラクチャを接続するための拡張可能な方法を備えたサンプルを作成しました。

最初に、アプリケーションのログ記録要件に対応するコントラクトを定義します。ここで使用するアプリケーションは、3 種類のメッセージをログとして記録するため、セキュリティ、アプリケーション、およびエラーという 3 種類のメソッドを呼び出します。こうして、3 つのメソッドとのインターフェイスとなるコントラクトを定義することができます。

public interface LogInterface {
    void WriteErrorEvent(string errorMessage);
    void WriteApplicationEvent(string applicationMessage);
    void WriteSecurityEvent(string securityMessage);
}
このインターフェイスは、DLL としてコンパイルし、このアプリケーション用のログ記録インフラストラクチャ プラグインを必要としているサードパーティに配布することができます。サードパーティは、このコントラクト (インターフェイス) を使用して自分のログ記録インフラストラクチャを実装することができます。これにより、彼らのプラグイン ライブラリは、このアプリケーションから使用できるようになります。このコードは、すべてのログ メッセージをコンソールに書き込むサードパーティ製ログ記録プラグインを示しています。
public class JoelsLogger : LogInterface {
    public void WriteErrorEvent(string errorMessage) {    
        Console.WriteLine("error: " + errorMessage);
    }
    public void WriteApplicationEvent(string applicationMessage) {
        Console.WriteLine("application: " + applicationMessage);
    }
    public void WriteSecurityEvent(string securityMessage) {
        Console.WriteLine("security: " + securityMessage);
    }
}

コントラクトとそれを実装するライブラリが得られたので、プラグインの検出とロードを実行し、インターフェイス、インスタンス化された型、および呼び出しを検出するためのコードが必要となります。このアプリケーション ロジックのごく簡単な例を次に示します。

Assembly asm = Assembly.LoadFrom(@"c:\myapp\plugins\joelslogger.dll");
LogInterface logger = null;

foreach (Type t in asm.GetTypes()) {
    if (t.GetInterface("LogInterface") != null) {
        logger = (LogInterface)Activator.CreateInstance(t);
        break;
    }
}

if (logger != null) logger.WriteApplicationEvent("Initialized...");
明らかにこのコードは最適ではありません。どの型がインターフェイスを実装するのか検出するために、それぞれの型について同じ手順を実行し、該当する型のロードをローダーに指示する方法は非常に手間がかかります。ライブラリのロード ステージ中に検出する型をすべてのプラグインが指定することを宣言する方法の方が優れています。これは、アセンブリ レベルのカスタム属性または設定ファイルにより実現できます。残念ながら、コードによりこの "ルックアップ" コントラクトを実現する方法は存在しないため、ここで使用するアプリケーションでは、サードパーティがこのコードを確実に正しく実装できるように、拡張点については、ドキュメントを充実させる方法で対処することが必要です。アセンブリ レベルのカスタム属性を追加する方法を次に示します。
[AttributeUsage(AttributeTargets.Assembly)]
public class LogInterfaceTypeAttribute : System.Attribute {
    public readonly string TypeName;

    public LogInterfaceTypeAttribute(string typeName) {
        TypeName = typeName;
    }
} 

LogInterfaceTypeAttribute には、"LogInterface" インターフェイスを実装する型の文字列名である 1 つのフィールドのみがあります。アプリケーションのドキュメントでは、すべてのサードパーティがこの LogInterfaceType 属性をサードパーティのライブラリにアセンブリ レベルで追加し、アプリケーションにそのライブラリの中のどの型がインターフェイスを実装するのか指示するように求めています。

[assembly: LogInterfaceType("JoelsLogger")]
class JoelsLogger : LogInterface { ... }

次に、アセンブリをロードした後で属性を検出します。

LogInterfaceType logtype = (LogInterfaceType)
    asm.GetCustomAttributes(typeof(LogInterfaceType), false)[0];
LogInterface log = (LogInterface)
    Activator.CreateInstance(asm.GetType(logtype.TypeName));
log.WriteApplicationEvent("Initialized...");
この方法では、実装されたコントラクトの検出のために前の方法で使用した非効率的なチェック ループを避けることができます。新しい方法では、サードパーティのログ記録ライブラリを使用するために必要な正確な型のみをロードするため、顕著なパフォーマンスの改善が実現します。

Back to top

メンバの起動または呼び出し

リフレクション API は、メンバの起動およびメンバの検査という基本目的により分類されます。ここで、コストが実際に発生している場所を正確に検出するため、リフレクションの背後にある実装について考えます。

リフレクションの起動は、通常はリフレクション名前空間の中の MethodBase.Invoke と Type.InvokeMember という 2 つの API を介して実行されます。この起動は、メンバの呼び出しがコンパイル時ではなく実行時に分析されるため、"遅延バインド" と呼ばれることがあります。それとは対照的に、コンパイラにより発行される呼び出し命令は、"早期バインド" 呼び出しと呼ばれます。メンバの起動に遅延バインド API を使用すると、対応する早期バインド呼び出し命令を使うことに比較して、処理が顕著に遅くなります。このような API のパフォーマンス上の問題点を包括的に把握する上で、実行時に使用されるメンバ起動のためのコスト以外のメカニズムとコストを比較することは有効な手段です。図 2 に、該当する起動メカニズムとそのそれぞれの相対的なパフォーマンス オーバーヘッドを示します。詳細については、Eric Gunnerson の労作 "Calling Code Dynamically" (英語) を参照してください。

図 2 起動メカニズムの相対的パフォーマンス
図 2 起動メカニズムの相対的パフォーマンス

このメトリックは 1 つの抽象的なシナリオに基づいており、実際のシナリオではパフォーマンス特性が異なる場合があるため、メトリックは出発点として使用することに限定してください。実際のシナリオに該当する正確な数値を得る最善の方法は、繰り返し計測することです。

Back to top

早期バインドと遅延バインドの起動

起動メカニズムは、早期バインド、遅延バインド、その混合の 3 つのカテゴリに分けることができます。早期バインド起動メカニズムは、直接中間言語 (IL) レベルの命令 (call、callvirt、および calli)、インターフェイス呼び出し、およびデリゲート呼び出しの結果として発動します。これらの早期バインド呼び出しメカニズムは、通常はコンパイル時にコンパイラにより静的に発動しますが、この場合、コンパイラには起動対象に関する必要情報のすべてが供給されます。早期バインド方式の方が、遅延バインド方式や混合方式と比較して、JIT またはネイティブ コード生成 (NGEN) コンパイラから発行される少数の x86 命令にマップされるため、著しく高速です。呼び出しサイトおよびメソッドに変更がなく、ランタイムから認識されているため、JIT による積極的な最適化が実現できます。

遅延バインド方式は、MethodBase.Invoke、DynamicMethod via Invoke、Type.InvokeMember、および遅延バインドのデリゲート呼び出し (Delegate.DynamicInvoke を介したデリゲート上での呼び出し) によります。これらのメソッドは、早期バインド方式と比較して、パフォーマンスを阻害する要因がはるかに多く含まれています。最善の場合でも、最も遅い早期バインド方式と比較して、1 桁分遅いのが一般的です。Type.InvokeMember は、メンバを適切に起動するために実行が必要な関数が 2 つあるため、遅延バインド起動メカニズムの中で最も遅いメソッドです。最初に、起動する予定のメンバをユーザーの文字列入力から正しく判定し、起動が安全に実行されるようにチェックを実行することが必要です。他方、MethodBase.Invoke は、呼び出す必要のあるメソッドの ID を既に保有しているため、それを判別する必要はありません。

リフレクションによりメンバが設定、起動される方法をさらに調べるため、MethodBase.Invoke の代表的な使用例を検討します。

public class D {
    public void MyMethod(string arg) { ... }
}
...
MethodInfo mi = typeof(D).GetMethod("MyMethod");
mi.Invoke(dobj, new object[] { "testing" });
リフレクションは、MyMethod に対応するユーザーの文字列表現を取り込み、この名前と一致しているメソッドに対応する MethodInfo を作成することが必要です。共通言語ランタイム (CLR) がメソッドの名前についての情報をメタデータに格納しているため、型 "D" に対するどの型が指定された名前を持っているか知るため、リフレクションはメタデータの内部を参照することが必要です。このロジックだけではコストがかさみます。文字列名 MyMethod を持つメソッドがわかれば、MethodInfo が作成されます (MethodInfo は MethodBase から派生します)。

Invoke の呼び出し時に、リフレクションは呼び出しを安全かつセキュアにするために必要なすべてのチェックを実行しなければなりません。通常は、引数とパラメータの型照合を最初に実行します。上記のコードでは、MyMethod が文字列をパラメータとして受け付けていることがわかります。また、Invoke の呼び出しでは、メソッドの引数として使用される対応文字列を格納したオブジェクト配列が設定されています。ここでは一致が取れているため、型安全性が確保されています。引数配列内の型とメソッド パラメータの型が正確に一致していない場合には、リフレクション フレームワークは、引数の型とパラメータの型を強制的に一致させる型強制の実行が可能かどうかチェックを実行します。引数とパラメータの型照合と強制が終了した後、リフレクションは、メソッド上にコード アクセス セキュリティ (CAS) 要求の有無を調べ、必要に応じてセキュリティ サブシステムを起動します。最後に、セキュリティ サブシステムにより呼び出しの安全性が保証された後、リフレクションはランタイムに呼び出しの実行開始を指示します。

Back to top

遅延バインド起動と早期バインド起動の混合

混合方式を考える場合には、実際には、コンパイラがコンパイル時に実行するすべての作業を、遅延バインド方式が実行時に行うことを意味します。基本的な問題は、メソッドを起動するたびに同じ作業を繰り返す可能性がある一方で、コンパイラが負荷の高い作業をすべて一度に実行することです。ただし、ランタイムのコード生成機能を使用することにより、実行時でのコンパイラのような動作が可能となり、その結果、遅延バインド起動と早期バインド機能の間のギャップを埋めることができます。ここではこのやり方を混合方式と呼んでいます。遅延バインド起動ではメソッド検出の作業を実行してから、呼び出しサイトのパッチアップを静的に実行するコードをメソッドに発行します。これはコンパイラによる処理とほとんど同じです。.NET Framework 2.0 では、Lightweight Code Generation (LCG) と呼ばれる新しいリフレクション機能がランタイムに組み込まれています。この機能は、上記の混合シナリオを有効にする上で最適です。混合方式は、.NET Framework 1.x でも実現はできますが、実装にかかる負担がやや大きくなります。

Back to top

MemberInfo について

MemberInfo を作成するとき、1 つのメンバに関する情報を取得するためにリフレクションが調べる場所として、通常、メタデータ構造体とランタイム データ構造体の 2 つがあります。ランタイムが起動して 1 つのプログラムを実行するとき、それ自身のランタイム データ構造体に、ディスク上の PE ファイルから得られたメタデータの情報の一部を挿入します。メタデータとは、アセンブリとそこに格納されたエンティティを記述しているデータ テーブルのセットであることを思い出してください。メソッドを例に取ると、すべてのメソッド名と 1 つのメソッドの属性をメタデータからごく簡単に見つけることができます。ランタイム データ構造体には、メタデータのランタイム サービス (JIT コンパイル、セキュリティ チェックなど) により一貫した方法で常時参照されているメタデータの情報のみを格納しています。CLR が、現在実行中のジョブを終了させるために、そのデータ構造体にメタデータの情報を挿入する処理は高速ではありません。ランタイムのデータ構造体の中の情報は、メタデータのほんのわずかな一部分と考えることができます。

リフレクションは、MemberInfo を作成するため、メタデータ情報を取り込むことが必要です。 このデータを見つけるために、リフレクションはランタイム データ構造体を参照するか、メタデータにアクセスして、その計算を実行します。ランタイム データ構造体は最適化され、参照の局所性に優れているため、この構造体は高速で使用できます。ただし、ランタイム データ構造体には必要な情報がないため、リフレクションを使用してメタデータの内部を参照します。これは著しく遅い処理となります。

どの種類のメタデータにアクセスできるかは予測ができず、またディスクからメタデータを取り込むときページ違反で終了する可能性があるため面倒な操作です。メタデータにアクセスすることにより、ワーキング セットが顕著に大きくなり、ガベージ コレクタ (GC) ヒープ上での一時割り当て数が大きくなる可能性があります。ワーキング セットとは、アプリケーションが実行状態で消費するメモリの量を指します。ワーキング セットについては、Rico Mariani が "My mom doesn't care about space" (英語) という文書の中で説明しています。

ワーキング セットは、パフォーマンスに著しい影響を及ぼすことがあります。さらに悪いことは、メタデータへのアクセスについて、および各呼び出しで発生するワーキング セットへの影響について、リフレクションにはどのような不変量もポリシーもないことです。このようにパフォーマンスの挙動が不確定である理由は、必要な情報を拾い集めるためリフレクション ランタイムがメタデータにアクセスする回数を減少させるために、リフレクションとランタイムが複数のキャッシュを持っていることです。包括的レベルでは、リフレクションが MemberInfo作成の指示をうけてアクセスする可能性のある基本キャッシュは、メタデータ キャッシュとリフレクション MemberInfo キャッシュの 2 つです。しかし、ワーキング セットの増大は、メタデータ ページの引き込みだけではなく、リフレクション MemberInfo キャッシュの基本設計にも原因があります。

Back to top

リフレクション MemberInfo キャッシュ

リフレクション MemberInfo キャッシュは、設計エラーが何かを教えてくれる教訓のようなものです。すべてのリリースで、このキャッシュ ソリューションは、同じ MemberInfo を複数回請求した場合にタデータ アクセスが繰り返し発生することによるコストを減少させることを意図しています。.NET Framework 1.x でのキャッシュ設計に関連して、予想外の事実が発生しました。それは、ワーキング セットへの悪影響の可能性が出てきたことです。

図 3 B と D
図 3 B と D

最初に、このキャッシュの動作方法についてやや詳しく見てみます。.NET Framework 1.x では、各型はそれ自身の固有な MemberInfo キャッシュを持っています。キャッシュ作成のメイン エントリ ポイントは、リフレクション名前空間内の GetXX API から開始します (ここでは、MemberInfo を取得する主体である GetProperty、GetEvent などを総称して GetXX を使用しています)。この API には 2 つの形式があります。1 つは単数 API で、1 つの MemberInfo を戻します (GetMethod など)。もう 1 つは複数 API (複数の GetMethod など) で、該当する型に含まれるメンバの配列を戻します。.NET Framework 1.x では、複数 API または単数 API の呼び出しが発生したとき、特定の種類のメンバすべてに対して、対象の型とその型の継承階層の両方の上に、MemberInfo オブジェクトを作成するというポリシーがリフレクションにより運用されてきました。ここでは、これを積極的キャッシュと呼びます。すべてのメンバの中で 1 つのメンバを要求しても、リフレクションはデフォルトではすべてのメンバをキャッシュに入れます。たとえば、ある型を持つ特定のプロパティを探して GetProperty を呼び出した場合、その型のすべてのプロパティがキャッシュに入ります。

図 3 は、積極的キャッシュのアルゴリズムの図解です。ここに示した簡単なクラス階層では、型 D が 型 B から派生し、各型に少数のメソッドが設定されています。そこで、次のコードを起動するアプリケーションを考えて見ます。

MethodInfo mi = typeof(D).GetMethod("MyMethod");

型 D を対象にした MyMethod のために MethodInfo を要求するこのコードを最初に実行すると、.NET Framework 1.x は、型 D と B の不可視メソッドを含むすべてのメソッドに対応する MethodInfo を積極的に作成し、それをその型の MemberInfo キャッシュに格納します。図 4 に、結果を示します。このようにキャッシュにデータが格納された後で、同じ型を対象に GetMethod (単数または複数) の呼び出しが発生すると、キャッシュから MethodInfo が引き出されるため、メタデータに再度問い合わせる必要性が減少します。

図 4 .NET Framework 1.x の MemberInfo キャッシュ
図 4 .NET Framework 1.x の MemberInfo キャッシュ

ただし、継承階層が深い型や所属メンバ数の多い型の場合には、このキャッシュ設計ではパフォーマンスに悪影響を及ぼす可能性があります。ユーザーが 1 つのメソッドのみを要求している場面で、GetMethod の呼び出しが 1 回発生すると、リフレクションはキャッシュを作成し、長時間を費やして積極的にメタデータを読み取り、他のすべてのメソッド用のキャッシュにも、その内容が現在も将来も使用されない場合であっても、データを格納します。

積極的キャッシュに関するもう 1 つの問題は、キャッシュ成長ポリシーです。.NET Framework 1.x MemberInfo キャッシュは、再生なしにどこまでも拡張させることが可能です。リフレクションの対象となるメンバが増えるほど、キャッシュは大きくなり、この拡張はアプリケーションがリフレクションの使用を終了しても継続します。その結果としてワーキング セットが拡張し、リフレクションを多用するすべての種類の長期実行アプリケーションに障害の発生を招くことがあります。たとえば、オブジェクト ブラウザのようにユーザーがロードした型を連続的に反映するツールは、深刻な影響をこうむる場合があります。キャッシュおよびワーキング セットは急速に拡大することがあります。

.NET Framework 2.0 のリフレクション キャッシュのキャッシュ ポリシーには改善が必要でした。特に、単数 GetXX API を呼び出すことが可能で、同じ型の残りのメンバに対して API が MemberInfo を作成することにより問題が発生しないようなコンシューマが必要です。さらに、リフレクション キャッシュが拡張する問題も解決が必要でした。

.NET Framework 2.0 が提供するリフレクション MemberInfo バックエンド キャッシュは改善が図られています。その結果、メンバの取得に関するポリシーが改善され、拡張と再生に関するプランもはるかによくなりました。.NET Framework 2.0 で MemberInfo キャッシュ ポリシーに加えられた主要な変更は次の 2 つです。第 1 に、キャッシュ作成アルゴリズムは消極的になり、リフレクションは、指示されたデータのみを取り込み、キャッシュに入れます。第 2 に、各型に対応する MemberInfo キャッシュには、拡張と再生に関する解決策が内蔵されています。キャッシュは管理対象のコードで再作成され (.NET Framework 1.x では反対に非管理対象でした)、GC ヒープに設定されているため、ユーザーがキャッシュの再生をコントロールできます。このようなユーザーのコントロールにより、アプリケーションのワーキング セットを顕著に減少させることができます。

新しい消極的ポリシーにより、単数 GetXX API を呼び出すと、期待通りに、1 つのキャッシュが作成され (その型のメンバに対応するキャッシュが存在していない場合)、そこに 1 つのメンバのみが格納されます。もちろん、複数の GetXX の API が呼び出された場合には、この呼び出しでは 1 つの型について特定の種類に属するすべてのメンバが要求されているため、.NET Framework 1.x キャッシュについて説明したのと同じ積極的アルゴリズムが適用されます。こうして、以前に実行したものと同じコードを実行すると

MethodInfo mi = typeof(D).GetMethod("MyMethod"); 
図 4 ではなく 図 5 で示した状況になります。

図 5 .NET Framework 2.0. の MemberInfo キャッシュ
図 5 .NET Framework 2.0. の MemberInfo キャッシュ

図 5 に示すように、以前と同様に、MethodInfo が MemberInfo キャッシュに割り当てられ、1 つの参照が作成され、MethodInfo の要求元に戻ります。ただし、今回は、どちらも GC ヒープ内にあります。MemberInfo キャッシュとは別に、型キャッシュという別のキャッシュがローダーによって作成され、1 つの型がロードされるたびに、データが格納されます。このキャッシュが保持している該当 MemberInfo キャッシュへの参照は弱いため、型キャッシュがまったく再生されない場合でも、MemberInfo キャッシュの存在がこのキャッシュにより保持されることはありません。型キャッシュのワーキング セットに対する影響は軽微なので、特に考慮すべき問題点はありません。このキャッシュが存在することだけ覚えておいてください。

アプリケーションが MethodInfo を参照しなくなり、該当する MemberInfo キャッシュ内の他の MethodInfo に対する既存の参照はなくなったので、GC はそのキャッシュに該当するメモリを収集し、再生することができます。したがって、MemberInfo 参照をすべて削除することにより、すべての MemberInfo キャッシュを削除する好機が与えられたことになります。ここで "好機" と書いたことに注目してください。もちろん、常に注意を払うことは必要です。よい例として次の状況を考えてみましょう。.NET Framework 2.0 MemberInfo キャッシュが 1 つのプロセス全体にわたっており、そのため、実行中のアプリケーションと同じ型の MemberInfo に関する同じプロセスの別のアプリケーション (別のアプリケーション ドメインで稼動) が該当するキャッシュを存続させています。このような状況が発生する機会は、具体的なコード ベースに依存します。

リフレクション パフォーマンスの低下についての詳細は、関連記事 "リフレクション パフォーマンスの改善" を参照してください。

Back to top

ハンドルとハンドル解決 API

ハンドルとは、小型軽量の構造体で、型コンテキストとの関連で、1 つのメンバの ID を定義します。ハンドルは MemberInfo の短縮版と見なすことができます。メソッドとデータのほとんどは省略され、バックエンド キャッシュはありません。.NET Framework 2.0 では、リフレクションにより提供される API は、次に示すように、MemberInfo からハンドルを取得するとともに、ハンドルを解決して元の MemberInfo に戻します。

// MemberInfo からハンドルを取得
RuntimeMethodHandle handle = typeof(D).GetMethod("MyMethod").MethodHandle;

// ハンドルを解決し、元の MemberInfo に戻します。
MethodBase mb = MethodInfo.GetMethodFromHandle(handle);

図 6 ハンドル解決と GetXX 呼び出しのコスト
図 6 ハンドル解決と GetXX 呼び出しのコスト

ハンドルによって、キャッシュ内のメンバの存続が確保されるのではなく、ハンドルにより MemberInfo の ID が表現されます。ユーザーはこれら 2 つの不変量に基づいて自分のキャッシュをセットアップすることができます。ハンドルから MemberInfo までたどるコストは、適切な MemberInfo がすでにキャッシュ内に存在する場合に GetXX メソッドの 1 つを使用するコストとほぼ同じです。MemberInfo がキャッシュに存在しない場合、ハンドルを MemberInfo に解決する処理は、GetXX メソッド の 1 つを使用する場合の処理より約 2 倍高速です。結果は一貫していおり、キャッシュが存続しているかどうか確認する必要はなく、ワーキング セットを低く抑えることができます。 図 6 では、メソッド間のコストを比較します。

Back to top

専用キャッシュの実装

ハンドルの効率性から、Framework 内に存在する既存のキャッシュの上に自分専用のキャッシュを実装する方法も考えられます。しかし、Rico Mariani が彼のブログ "Caching Implies Policy" (英語) で指摘しているように、良いキャッシュを設計することは難しい作業です。キャッシュのポリシーを設定することが困難な場合には、キャッシュを実装する苦労を回避することを検討してください。自分のキャッシュ処理戦略の開発について検討している場合には、Rico のキャッシュ ポリシーに関する文書は一読に値します。

GetMethod への呼び出しをキャッシュに入れることは、簡単な練習問題として試みる価値があるでしょう。その API のために、System.Type、文字列ベースのメソッド名、およびオプションとして、メソッド パラメータ用の型配列 (オーバーロードにより、同じ型に対して同じ名前の複数のメソッドが導入される可能性があるため) が必要です。キャッシュのキーを一意にすることは当然のやり方です。すべての GetMethod 呼び出しサイトに対するキャッシュ キーの実装を次に示します。

class CacheKey
{
    public CacheKey(RuntimeTypeHandle th, string methodName, Type[] args)
    {
        InstanceHandle = th;
        MethodName = methodName;
        TypeArguments = args;
    }
    ...
}

このキーを使用することにより、関連付けられた MethodHandle を格納することができます。GetMethod の呼び出しが必要なときにはいつでも、該当する型の RuntimeTypeHandle、文字列ベースのメソッド名、および型パラメータの配列によりキャッシュ内を検索し、該当する メソッド ハンドルを取り戻すことができます。MethodInfo.ResolveMethodFromHandle を呼び出すと、その MethodHandle から直ちに MethodInfo が取りこまれ、使用することができます。

使用しているキャッシュでキャッシュ ミスとなった場合には、いずれかの方法で GetMethod を呼び出し、取得された MethodInfo からハンドルを取り込むことが必要です。使用済みの MethodInfo は放棄して、使用を終了し、ハンドルのみを保持します。これにより、GC は、一時的に作成された MemberInfo キャッシュを直ちに再生します。ただし、キャッシュにヒットしても、GetMethod が呼び出されるわけではありません。ハンドルから MethodInfo に直接アクセスし、必要な操作を実行し、その後 MethodInfo をもう一度削除することができます。

適切な MemberInfo を取り込んだ後、それを起動できることが必要です。MemberInfo での起動を複数回可能にすることを必要とし、何らかの理由で静的なコントラクトを定義できないシナリオの場合は、起動メソッドの呼び出しコストを減じるためのコード生成を考慮することができます。繰り返しとなりますが、Invoke の呼び出しには、多数のセキュリティ チェック、型パラメータ チェック、およびメタデータの検索が必要であり、この結果、コストの急速な増大を招くことがあります。同じメソッドを複数回呼び出す場合には、このようなチェックを繰り返すことは意味がありません。.NET Framework 2.0 の新しい Lightweight Code Generation (LCG) 機能を起動します。この機能は、純粋に動的な起動と早期バインド呼び出しのギャップを埋める役割を果たします。これは、System.Reflection.Emit 名前空間から離れ、実行時に新しいメソッドを生成する能力を提供します。これらの動的メソッドは、GC による完全な再生が可能です。これは、アプリケーションのワーキング セットに対するコントロールのために重要です。LCG 機能は、既存の Reflection.Emit.ILGenerator クラスと新規の DynamicMethod クラスを使用して、新しいメソッドのための IL を発行し、デリゲートおよび DynamicMethod.Invoke という 2 つの方法により起動を実行します。

DynamicMethod を紹介するための例として、コンソールに "Hello World" を表示するメソッドを生成する次のコードを考えてみます。

RuntimeMethodHandle myMethodHandle = 
    typeof(Console).GetMethod("WriteLine"),
    new Type[]{typeof(String)})).MethodHandle;
DynamicMethod dm = new DynamicMethod(
    "HelloWorld",          // メソッドの名前
    typeof(void),          // メソッドの型を戻す
    new Type[]{},          // メソッドの引数型
    typeof(LCGHelloWorld), // 関連付けられるモジュール内の型
    false);                // JIT 可視性チェックのスキップ
ILGenerator il = dm.GetILGenerator();
il.Emit(OpCodes.Ldstr, "Hello, world");
il.Emit(OpCodes.Call, myMethodHandle);
il.Emit(OpCodes.Ret);

この LCG メソッドを起動する方法は、DynamicMethod.Invoke の使用および DynamicMethod.CreateDelegate の使用の 2 とおりあります。デリゲート起動の方が高速で、推奨される方法です。

delegate void MyHelloWorldDelegate();
...
MyHelloWorldDelegate del = (MyHelloWorldDelegate)
    dm.CreateDelegate(typeof(MyHelloWorldDelegate));
...
del();

このように簡単な紹介を基にして、元に戻り、解決しようとしているコード生成に関する問題を検討してみます。

コンパイル時にメソッドが未知の場合には、コンパイラが動的呼び出しサイト用の CLR をセットアップする方法について認識していないため、非常に高速な呼び出し命令を使用することはできません。コンパイラが必要な情報のすべてを持っている場合の例を次に示します。

public static int GetEmployeeIdNumber(string employeeName) { ... }

// 呼び出しサイト
int empNum = GetEmployeeIdNumber("Joel");

コンパイル時に、コンパイラは IL を発行し、この呼び出しサイト用のスタックをセットアップします。この特定の呼び出しサイトに関して、コンパイラはすべてを認識しています。コンパイラは、引数の型がパラメータの型と一致することを確認し、メソッドが呼び出しサイトから "可視" である (この場合は、メソッドがパブリックであるから) ことを確認することができます。これらのチェックの後、コンパイラは、スタックに文字列 "Joel" をプッシュし、メソッドを起動する "呼び出しメソッド" 命令を発行する IL を安全に発行することができます。この呼び出しサイト用の IL を次に示します。

  ldstr      "Joel"
  call       int32 EmployeeData::GetEmployeeIdNumber(string)
  stloc.0

この例とは反対に、リフレクション起動のほとんどは純粋に動的です。リフレクション API を使用することにより、起動しようとしているメソッドの署名や場所を知らず、それらに対するコンパイル時アクセス権も持たない実行時環境において、メソッドを起動することができます。ただし、コンパイラがコンパイル時に実行するチェックの多くをリフレクションは実行時に行わなければならないため、起動時のコストは高くなります。

リフレクションがこれらのチェックを簡単に一度で実行できない理由について説明しましょう。リフレクションとその各種の起動メソッドは、たとえ引数が同じであっても、起動時ごとにこれらのチェックを繰り返し実行します。これには、セキュリティを含むいろいろな理由があります。特定のメソッドの起動についてコンパイラとリフレクションのギャップを埋めるために LCG を使用することができます。呼び出すメソッドが安全であることがわかっている場合には、LCG を使用することにより、起動ごとにリフレクションが実行するチェックをバイパスすることができます。

LCG が呼び出しサイトとメソッドの間をつなぐことができるようするには、LCG メソッドを参照するためにコード内で静的に使用できるデリゲートを定義することが最初に必要です。拡張点のメソッドが完全に未知の場合には、すべてのメソッド署名に適用できるほど十分な汎用性をそなえたデリゲート署名を作成することが必要です。デリゲート署名はオブジェクトを戻し、オブジェクト配列を取リ込むことができるため、これは非常に簡単です。

delegate object CallSiteDelegate(object[] args);

次に、この汎用署名を照合するための LCG メソッドをセットアップすることが必要です。LCG MethodWrapper メソッドはオブジェクトを戻し、オブジェクト配列をパラメータとして取り込みます。

DynamicMethod dm = new DynamicMethod("MethodWrapper", typeof(object), 
    new Type[] { typeof(object[]) }, ..., ...);

LCG メソッドを作成するコードは複雑であり、この記事の範囲を超えています。概要のみを示すと、このメソッドは、最終的に意図したメソッドを呼び出す前に、いくつかの動作を実行することが必要です。LCG ラッパー メソッドに共有されるオブジェクト配列内に十分な引数が存在することを確認することが必要です。さらに、呼び出されているメソッドがインスタンス メソッドの場合には、インスタンスまたはこの "この" ポインタを引き出すことが必要です。次に引数配列を解き、引数をスタック上に展開してから、メソッドを呼び出し、その結果を戻します。

LCG メソッド ラッパーの中で最も興味深い部分は、引数がスタック上に展開された後で呼び出しを実行する最後の少数の IL 命令です。これらの命令は、最終的には直接的呼び出しまたは callvirt になります。これは、遅延バインドと早期バインドのギャップを埋めるための基本的構成要素です。図 2 を見ると、これらの IL 命令呼び出しが超高速であることがわかります。

最後に、動的に生成されたメソッドの実行は、オブジェクト配列に引数を指定された CallSiteDelegate デリゲートによります。GetEmployeeNumberId の場合、これは次のようになります。

DynamicMethod dm = ...;
CallSiteDelegate getEmployeeIdMethod = (CallSiteDelegate)
    dm.CreateDelegate(typeof(CallSiteDelegate));
getEmployeeIdMethod(new object[] { "Joel" });
メソッド呼び出しの全体的なパフォーマンスは、デリゲート呼び出しに呼び出し命令または callvirt 命令を加えた場合と比較して、やや遅くなります。

Back to top

結論

この記事では、リフレクションを使用する方法および条件についてできるだけ明解に説明しようとしてきました。見えにくい特徴なども明るみにだすとともに、リフレクションを活用するためのヒントや便利な方法を紹介してきました。この記事が、開発者が自分のリフレクション シナリオについて考えるために十分な情報を提供し、自分の設計についてある程度明確な判断が下せるように十分な技術的深さをそなえていることを目指しました。

Back to top


Joel Pobar Microsoft の共通言語ランタイム (CLR) チームのプログラム マネージャです。主として、CLR の動的機能に携わっています。連絡先は、joelpob@microsoft.com (英語) です。

 この記事は、 MSDN マガジン - 2005 年 7 月号からの翻訳です。 .
Back to topBack to top QJ: 050703

Microsoft