我目前正在使用 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 代码(以及相同的 JITted 汇编代码)。这是有道理的,因为最终我们只是使用第一个参数。我们看到的第二件事是,mainFoo对这两种方法的称呼不同(ldloc vs ldloca)。由于 foo 和 foo1 都期望相同的输入,我希望 mainFoo 将发送相同的参数。这带来了3个问题
1) 在堆栈上加载结构与在该堆栈上加载结构的地址到底意味着什么?我的意思是,大小大于 8 字节(64 位)的结构不能"坐在"堆栈上。
2) CLR 是否在生成结构的副本之前只是用作"this"(根据 C# 规范,我们知道这是真的)?此副本存储在哪里?fooMain 程序集显示调用方法在其堆栈上生成副本。
3)似乎通过值和地址加载结构(ldarg/ldloc vs ldarga/ldloca)实际上加载了一个地址 - 对于第二组,它只是在之前创建一个副本。为什么?我在这里错过了什么吗?
4) 回到迭代器/异步 - foo/foo1 示例是否复制了迭代器和非迭代器结构体的"this"参数之间的差异?为什么需要这种行为?创建副本似乎是浪费工作。动机是什么?
(此示例是使用 .Net Framework4.5 拍摄的,但使用 .Net Framework 2 和 CoreCLR 也可以看到相同的行为)
我将引用 ECMA 335 规范,该规范定义了 C# 所基于的 CLR,然后我们将看到它如何回答您的问题。
I.8.9.7 值类型定义
snip
- 当在值类型上调用非静态方法(即实例或虚拟方法)时,其 this 指针是对实例的托管引用,其中,当在关联的盒装类型上调用该方法时,this 指针是对象引用。/<>值类型上的实例方法接收this指针是指向未装箱类型的托管指针,而虚拟方法(包括由值类型实现的接口上的方法)接收装箱类型的实例。
这告诉我们,结构的实例方法(如上面的foo1()
)有一个表示为托管引用的this指针,即指向实际结构的 GC 指针,您在 C# 中将其称为ref。
对于已知属于该类型的装箱结构,可以在不取消装箱的情况下调用方法,CLR 将自动传递ref指针。见二.13.3。
现在,如果我们需要从存储在本地、ref或直接加载到堆栈中的结构访问字段,会发生什么?
III.4.10 ldfld – 物体的载荷场
堆栈转换
。obj=>值...
ldfld 指令将 obj 字段的值推送到堆栈上。 obj 应为对象(O 类型)、托管指针(&)、一个 非托管指针(本机 int 类型)或值类型的实例。
因此,无论结构在哪里,我们都可以使用ldfld来获取值。堆栈上的整个值被弹出,并加载值。
在foo()
中,你在堆栈(ldloc.0
)上按值传递结构,方法也做同样的事情(ldarg.0
).
在foo1()
中,结构被 ref (ldloca.s
) 作为this
传递,并且它由 ref 加载(这里ldarg.0
表示 ref)。
稍后将涉及以下内容。
I.8.2.1 托管指针和相关类型
剪......它们不能用于字段签名...
snip理由:出于性能原因,GC 堆上的项目可能不包含对其他 GC 对象内部的引用,这激发了对字段的限制...
现在回答您的问题:
- 我们可以将结构直接加载到堆栈中。无论结构有多少字节,这都会占用 。
- 您的示例不是迭代器或异步的情况。ECMA-334 12.7.8 的 c# 规范说这是一个引用,所以这实际上是一个可变指针。您可以通过改变
foo1()
中的结构来证明这一点。 - 当涉及到
foo()
中的 JITted 汇编程序时,您的结构示例有点例外。似乎 JIT 将针对大于 8 字节的结构进行优化,并在可能的情况下按 ref 传递它,即不更改语义。 - 在实际的异步或迭代器函数中,参数被转换为编译器生成的结构的字段,该结构用作状态机。CLR 不允许在字段中存储引用,因此必须遵循按值语义。