C# 構造体インスタンス メソッドが構造体フィールドのインスタンス メソッドを呼び出すときに、最初に ecx をチェックするのはなぜですか? 質問する

C# 構造体インスタンス メソッドが構造体フィールドのインスタンス メソッドを呼び出すときに、最初に ecx をチェックするのはなぜですか? 質問する

次の C# メソッドの X86 に命令CallViaStructが含まれているのはなぜですかcmp?

struct Struct {
    public void NoOp() { }
}
struct StructDisptach {

    Struct m_struct;

    [MethodImpl(MethodImplOptions.NoInlining)]
    public void CallViaStruct() {
        m_struct.NoOp();
        //push        ebp  
        //mov         ebp,esp  
        //cmp         byte ptr [ecx],al  
        //pop         ebp  
        //ret
    }
}

以下は、さまざまな (リリース) デコンパイルをコメントとしてコンパイルできる、より完全なプログラムです。 と の両方の X86 の は同じであると予想していましたがCallViaStruct、 (上記で抽出した)のバージョンには命令が含まれていますが、もう一方には含まれていません。ClassDispatchStructDispatchStructDispatchcmp

cmp命令は、変数が null でないことを確認するために使用されるイディオムのようです。値 0 のレジスタを逆参照すると、 がavトリガーされ、 に変換されますNullReferenceException。ただし、 では、構造体を指しているため、 が null になるStructDisptach.CallViaStruct方法が思いつきません。ecx

更新: 私が受け入れようとしている回答には、命令がゼロのレジスタを逆参照StructDisptach.CallViaStructすることによってNRE がスローされるコードが含まれます。これは、設定によっていずれかの方法で簡単に実行できますが、命令がないため実行できないことに注意してください。cmpecxCallViaClassm_class = nullClassDisptach.CallViaStructcmp

using System.Runtime.CompilerServices;

namespace NativeImageTest {

    struct Struct {
        public void NoOp() { }
    }

    class Class {
        public void NoOp() { }
    }

    class ClassDisptach {

        Class m_class;
        Struct m_struct;

        internal ClassDisptach(Class cls) {
            m_class = cls;
            m_struct = new Struct();
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public void CallViaClass() {
            m_class.NoOp();
            //push        ebp  
            //mov         ebp,esp  
            //mov         eax,dword ptr [ecx+4]  
            //cmp         byte ptr [eax],al  
            //pop         ebp  
            //ret  
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public void CallViaStruct() {
            m_struct.NoOp();
            //push        ebp
            //mov         ebp,esp
            //pop         ebp
            //ret
        }
    }

    struct StructDisptach {

        Class m_class;
        Struct m_struct;

        internal StructDisptach(Class cls) {
            m_class = cls;
            m_struct = new Struct();
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public void CallViaClass() {
            m_class.NoOp();
            //push        ebp  
            //mov         ebp,esp  
            //mov         eax,dword ptr [ecx]  
            //cmp         byte ptr [eax],al  
            //pop         ebp  
            //ret  
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public void CallViaStruct() {
            m_struct.NoOp();
            //push        ebp  
            //mov         ebp,esp  
            //cmp         byte ptr [ecx],al  
            //pop         ebp  
            //ret  
        }
    }

    class Program {
        static void Main(string[] args) {
            var classDispatch = new ClassDisptach(new Class());
            classDispatch.CallViaClass();
            classDispatch.CallViaStruct();

            var structDispatch = new StructDisptach(new Class());
            structDispatch.CallViaClass();
            structDispatch.CallViaStruct();
        }
    }
}

callvirt更新:非仮想関数で使用すると、this ポインターの null チェックという副作用が発生することが判明しました。これはCallViaClass呼び出しサイトの場合に該当しますが (そこで null チェックが行われるのはそのためです)、命令StructDispatch.CallViaStructを使用しますcall

.method public hidebysig instance void  CallViaClass() cil managed noinlining
{
  // Code size       12 (0xc)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldfld      class NativeImageTest.Class NativeImageTest.StructDisptach::m_class
  IL_0006:  callvirt   instance void NativeImageTest.Class::NoOp()
  IL_000b:  ret
} // end of method StructDisptach::CallViaClass

.method public hidebysig instance void  CallViaStruct() cil managed noinlining
{
  // Code size       12 (0xc)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldflda     valuetype NativeImageTest.Struct NativeImageTest.StructDisptach::m_struct
  IL_0006:  call       instance void NativeImageTest.Struct::NoOp()
  IL_000b:  ret
} // end of method StructDisptach::CallViaStruct

更新:呼び出しサイトで this ポインターがトラップされていないcmp場合に がトラップされる可能性があるという提案がありました。その場合、 はメソッドの先頭で 1 回発生すると予想されます。ただし、 の呼び出しごとに 1 回表示されます。nullcmpNoOp

struct StructDisptach {

    Struct m_struct;

    [MethodImpl(MethodImplOptions.NoInlining)]
    public void CallViaStruct() {
        m_struct.NoOp();
        m_struct.NoOp();
        //push        ebp  
        //mov         ebp,esp  
        //cmp         byte ptr [ecx],al  
        //cmp         byte ptr [ecx],al  
        //pop         ebp  
        //ret  
    }
}

ベストアンサー1

短い答え: JITter は、構造体がポインターによって参照されていないことを証明できず、正しい動作のためには、NoOp() を呼び出すたびに少なくとも 1 回は逆参照する必要があります。


長い答え: 構造体は奇妙です。

JITterは保守的です。可能な限り、コードを最適化できる方法でのみ最適化します。絶対に特定のものは正しい動作を生成します。「ほぼ正しい」では十分ではありません。

では、JITter が逆参照を最適化した場合に問題が発生するシナリオの例を次に示します。次の事実を考慮してください。

まず、構造体は C# の外部に存在できる (実際に存在する) ことを覚えておいてください。たとえば、StructDispatch へのポインターはアンマネージ コードから取得できます。Lucas が指摘したように、ポインターを使用して不正行為をすることはできますが、JITter は、コード内の他の場所で StructDispatch へのポインターを使用していないことを確実に知ることはできません。

2つ目:アンマネージコードでは、構造体が存在する最大の理由であるが、すべてが当てにならないことを覚えておくこと。メモリから値を読み込んだからといって、それが同じ値、あるいはまったく同じ値になるわけではない。なれ次回同じアドレスを読み取ったときに、値が変更されます。スレッド化とマルチプロセス化により、次のクロック ティックで何かがその値を変更する可能性があります。DMA などの CPU 以外のアクターについては言うまでもありません。並列スレッドは、その構造体を含むページを VirtualFree() する可能性があり、JITter はそれを防ぐ必要があります。メモリからの読み取りを要求したので、メモリから読み取りが行われます。オプティマイザを起動すると、これらの cmp 命令の 1 つが削除されると思いますが、両方が削除されることはほとんどありません。

3 つ目: 例外も実際のコードです。NullReferenceException は必ずしもプログラムを停止するわけではなく、キャッチして処理できます。つまり、JITter の観点から見ると、NRE は goto というよりは if 文に似ています。つまり、メモリ参照のたびに処理して考慮する必要がある一種の条件分岐です。

では、それらのピースを組み合わせてみましょう。

JITter は、StructDispatch のメモリを操作するために安全でない C# またはどこか別の場所にある外部ソースを使用していないことを知りません (知ることもできません)。JITter は、CallViaStruct() の実装を別々に生成しません。1 つは「おそらく安全な C# コード」用、もう 1 つは「おそらく危険な外部コード」用です。常に、危険なシナリオ用の保守的なバージョンを生成します。つまり、StructDispatch が、たとえばメモリにページングされていないアドレスにマップされていないという保証がないため、NoOp() の呼び出しを完全に削除することはできません。

NoOp()は空であり省略できる(呼び出しを省略できる)ことは分かっていますが、少なくともシミュレートする構造体のメモリ アドレスを突っついて ldfla を検査する必要があります。これは、その NRE の発生に依存するコードが存在する可能性があるためです。メモリの逆参照は if 文に似ています。分岐を引き起こす可能性があり、分岐を引き起こさない場合はプログラムが壊れる可能性があります。Microsoft は、仮定を立てて「コードはそれに依存してはいけません」と言うことはできません。JITter が、そもそもトリガーするほど「重要ではない」NRE であると判断したために、NRE がビジネスのエラー ログに書き込まれなかった場合、Microsoft に怒りの電話がかかってくることを想像してみてください。JITter は、正しいセマンティクスを確保するために、少なくとも 1 回はそのアドレスを逆参照するしかありません。


クラスにはこうした懸念はありません。クラスでは強制的にメモリの異常が発生することはありません。しかし、構造体は奇妙です。

おすすめ記事