首页 > 解决方案 > 理解结构的“this”参数(特别是迭代器/异步)

问题描述

我目前正在使用 Profiler API 检查 CLR 中的深层对象。我在分析迭代器/异步方法的“this”参数时遇到了一个特定问题(由编译器生成,格式为<name>d__123::MoveNext)。

在研究这个时,我发现确实有一种特殊的行为。首先,C# 编译器将这些生成的方法编译为结构(仅在发布模式下)。ECMA-334(C# 语言规范,第 5 版:https ://www.ecma-international.org/publications/files/ECMA-ST/ECMA-334.pdf)状态(12.7.8 此访问):

“...如果方法或访问器是迭代器或异步函数,则 this 变量表示为其调用方法或访问器的结构的副本,...。”

这意味着与其他“this”参数不同,在这种情况下“this”是按值发送的,而不是通过引用发送的。我确实看到副本没有在外面修改。我试图了解结构实际上是如何发送的。

我冒昧地剥离了复杂的案例,并用一个小结构复制了它。看下面的代码:

struct Struct
    {
        public static void mainFoo()
        {
            Struct st = new Struct();
            st.a = "String";
            st.p = new Program();
            System.Console.WriteLine("foo: " + st.foo1());
            System.Console.WriteLine("static foo: " + Struct.foo(st));
        }

        int i;
        String a;
        Program p;

        [MethodImplAttribute(MethodImplOptions.NoInlining)]
        public static int foo(Struct st)
        {
            return st.i;
        }

        [MethodImplAttribute(MethodImplOptions.NoInlining)]
        public int foo1()
        {
            return i;
        }
    }

NoInlining只是为了我们可以正确检查 JITted 代码。我正在研究三个不同的东西:mainFoo 如何调用 foo/foo1、foo 是如何编译的以及 foo1 是如何编译的。以下是生成的 IL 代码(使用 ildasm):

.method public hidebysig static int32  foo(valuetype nitzan_multi_tester.Struct st) cil managed noinlining
{
  // Code size       7 (0x7)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldfld      int32 nitzan_multi_tester.Struct::i
  IL_0006:  ret
} // end of method Struct::foo

.method public hidebysig instance int32  foo1() cil managed noinlining
{
  // Code size       7 (0x7)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldfld      int32 nitzan_multi_tester.Struct::i
  IL_0006:  ret
} // end of method Struct::foo1

.method public hidebysig static void  mainFoo() cil managed
{
  // Code size       86 (0x56)
  .maxstack  2
  .locals init ([0] valuetype nitzan_multi_tester.Struct st)
  IL_0000:  ldloca.s   st
  IL_0002:  initobj    nitzan_multi_tester.Struct
  IL_0008:  ldloca.s   st
  IL_000a:  ldstr      "String"
  IL_000f:  stfld      string nitzan_multi_tester.Struct::a
  IL_0014:  ldloca.s   st
  IL_0016:  newobj     instance void nitzan_multi_tester.Program::.ctor()
  IL_001b:  stfld      class nitzan_multi_tester.Program nitzan_multi_tester.Struct::p
  IL_0020:  ldstr      "foo: "
  IL_0025:  ldloca.s   st
  IL_0027:  call       instance int32 nitzan_multi_tester.Struct::foo1()
  IL_002c:  box        [mscorlib]System.Int32
  IL_0031:  call       string [mscorlib]System.String::Concat(object,
                                                              object)
  IL_0036:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_003b:  ldstr      "static foo: "
  IL_0040:  ldloc.0
  IL_0041:  call       int32 nitzan_multi_tester.Struct::foo(valuetype nitzan_multi_tester.Struct)
  IL_0046:  box        [mscorlib]System.Int32
  IL_004b:  call       string [mscorlib]System.String::Concat(object,
                                                              object)
  IL_0050:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0055:  ret
} // end of method Struct::mainFoo

生成的汇编代码(仅相关部分):

foo/foo1:
mov eax,dword ptr [rcx+10h]
ret

fooMain (line 18):
mov rcx,offset mscorlib_ni+0x8aaf8 (00007ffc`37d6aaf8) (MT: System.Int32)
call    clr+0x2510 (00007ffc`392f2510) (JitHelp: CORINFO_HELP_NEWSFAST)
mov     rsi,rax
lea     rcx,[rsp+40h]
call    00007ffb`d9db04e0 (nitzan_multi_tester.Struct.foo1(), mdToken: 000000000600000b)
mov     dword ptr [rsi+8],eax
mov     rdx,rsi
mov rcx,1DBCE383690h
mov     rcx,qword ptr [rcx]
call    mscorlib_ni+0x635bd0 (00007ffc`38315bd0) (System.String.Concat(System.Object, System.Object), mdToken: 000000000600054f)
mov     rcx,rax
call    mscorlib_ni+0x56d290 (00007ffc`3824d290) (System.Console.WriteLine(System.String), mdToken: 0000000006000b78)

fooMain (line 19):
mov rcx,offset mscorlib_ni+0x8aaf8 (00007ffc`37d6aaf8) (MT: System.Int32)
call    clr+0x2510 (00007ffc`392f2510) (JitHelp: CORINFO_HELP_NEWSFAST)
mov     rsi,rax
lea     rcx,[rsp+28h]
mov     rax,qword ptr [rsp+40h]
mov     qword ptr [rcx],rax
mov     rax,qword ptr [rsp+48h]
mov     qword ptr [rcx+8],rax
mov     eax,dword ptr [rsp+50h]
mov     dword ptr [rcx+10h],eax
lea     rcx,[rsp+28h]
call    00007ffb`d9db04d8 (nitzan_multi_tester.Struct.foo(nitzan_multi_tester.Struct), mdToken: 000000000600000a)
mov     dword ptr [rsi+8],eax
mov     rdx,rsi
mov rcx,1DBCE383698h
mov     rcx,qword ptr [rcx]
call    mscorlib_ni+0x635bd0 (00007ffc`38315bd0) (System.String.Concat(System.Object, System.Object), mdToken: 000000000600054f)
mov     rcx,rax
call    mscorlib_ni+0x56d290 (00007ffc`3824d290) (System.Console.WriteLine(System.String), mdToken: 0000000006000b78)

我们可以看到的第一件事是 foo 和 foo1 都生成相同的 IL 代码(以及相同的 JIT 汇编代码)。这是有道理的,因为最终我们只是使用第一个参数。我们看到的第二件事是 mainFoo 以不同的方式调用这两种方法(ldloc 与 ldloca)。由于 foo 和 foo1 都期望相同的输入,我希望 mainFoo 将发送相同的参数。这提出了3个问题

1)在堆栈上加载结构与在该堆栈上加载结构的地址到底是什么意思?我的意思是,大小大于 8 字节(64 位)的结构不能“坐在”堆栈上。

2) CLR 是否在用作“this”之前生成结构的副本(我们知道这是真的,根据 C# 规范)?这个副本存储在哪里?fooMain 程序集显示调用方法在其堆栈上生成副本。

3) 似乎按值和地址加载结构(ldarg/ldloc vs ldarga/ldloca)实际上加载了一个地址——对于第二组它只是在之前创建了一个副本。为什么?我在这里错过了什么吗?

4) 回到迭代器/异步 - foo/foo1 示例是否复制了迭代器和非迭代器结构的“this”参数之间的差异?为什么需要这种行为?创建副本似乎是浪费工作。动机是什么?

(此示例是使用 .Net framework 4.5 进行的,但使用 .Net framework 2 和 CoreCLR 也可以看到相同的行为)

标签: c#clrcilclr-profiling-api

解决方案


我将引用ECMA 335 规范,它定义了 C# 所基于的 CLR,然后我们将看到它如何回答您的问题。


I.8.9.7 值类型定义
片段

  1. 当在值类型上调用非静态方法(即实例或虚拟方法)时,它的this指针是对实例的托管引用,而当在关联的装箱类型上调用方法时,this指针是对象引用。
    值类型的实例方法接收this指针,该指针是指向未装箱类型的托管指针,而虚拟方法(包括由值类型实现的接口上的方法)接收装箱类型的实例。

这告诉我们 struct 的实例方法,例如foo1()上面,有一个this指针,它表示为托管引用,即指向实际结构的 GC 指针,您在 C# 中将 this 称为ref

对于已知属于该类型的装箱结构,可以在不拆箱的情况下调用方法,CLR 将自动传递ref指针。见 II.13.3。


现在,如果我们需要从存储在localref或直接加载到堆栈中的结构访问字段,会发生什么?

III.4.10 ldfld – 对象的加载字段

堆栈转换

... obj =>...

ldfld 指令将 obj 字段的值压入堆栈。obj 应该是对象(O 类型)、托管指针(& 类型)、非托管指针(本机 int 类型)或值类型的实例。

所以无论struct在哪里,我们都可以使用ldfld来获取值。弹出堆栈上的整个值,并加载该值。但是您必须了解,逻辑(理论)堆栈上的对象在每种情况下都是不同的。
foo()中,您在堆栈上按值传递结构 ( ldloc.0),方法执行相同的操作 ( ldarg.0)。
foo1()中,结构体是通过thisref ( ldloca.s) 传递的,并且是通过-ref 加载的(这里ldarg.0表示 ref)。


稍后将涉及以下内容。

I.8.2.1 托管指针和相关类型

snip ...它们不能用于字段签名...
snip 理由:出于性能原因,GC堆上的项目可能不包含对其他GC对象内部的引用,这激发了对字段的限制...


现在回答你的问题:

  1. 我们可以将结构直接加载到堆栈中。这将占用结构的多少字节。
  2. 您的示例不是迭代器或异步的情况。ECMA-334 12.7.8 的 c# 规范说这是一个ref,所以这实际上是一个可变指针。你可以通过改变结构来证明这一点foo1()
  3. 当涉及到 JITted 汇编器时,您的结构示例有点例外foo()。似乎 JIT 会针对大于 8 个字节的结构进行优化,并在可能的情况下通过引用传递它,即不改变语义。
  4. 在实际的异步或迭代器函数中,参数被转换为编译器生成的结构的字段,该结构用作状态机。CLR 不允许将ref存储在字段中,因此必须遵循按值语义。

推荐阅读