聊聊“装箱”在CLR内部的实现

原文连接:https://mattwarren.org/2017/08/02/A-look-at-the-internals-of-boxing-in-the-CLR/ 作者 Matt Warren。授权翻译,转载请保留原文链接。

它是.NET的基本组成部分,并且经常会在你不知情的情况下发生,但是它实际上是如何工作的呢?.NET运行时做了什么才使得装箱成为可能?

注意:本文不会讨论如何检测装箱,以及它是如何影响性能的或者如何避免装箱发生(和Ben Adams来讨论这些吧!)。本文只谈论装箱是如何工作的。


顺便说一句,如果你喜欢读一些关于CLR内部实现的内容,你会发现下面的文章会很有趣:

  • How the .NET Runtime loads a Type
  • Arrays and the CLR - a Very Special Relationship
  • The CLR Thread Pool ‘Thread Injection’ Algorithm
  • The 68 things the CLR does before executing a single line of your code
  • How do .NET delegates work?
  • Why is reflection slow?
  • How does the ‘fixed’ keyword work?

CLR规范中的装箱

首先值得指出的是,装箱是CLR规范“ECMA-335”的要求,因此运行时必须提供:

聊聊“装箱”在CLR内部的实现_第1张图片

这意味着CLR需要处理一些关键事项,我们将在本文的后续部分中进行探讨。


创建“装箱”类型

运行时首先需要为每一个它加载的struct创建一个对应的引用类型(“装箱类型”)。

你可以在运行时创建“方法表”的方法中找到一个实际的案例,在该方法中,运行时首先会检查它是否在处理“值类型”,然后进行相应的操作。因此,任何struct的“装箱类型”都是在导入.dll时预先创建的,之后它们可以在程序执行期间被用于“装箱”操作。

上文引用的代码中的注释非常有趣,因为它揭示了运行时必须处理的一些底层细节:

// Check to see if the class is a valuetype; but we don't want to mark System.Enum
// as a ValueType. To accomplish this, the check takes advantage of the fact
// that System.ValueType and System.Enum are loaded one immediately after the
// other in that order, and so if the parent MethodTable is System.ValueType and
// the System.Enum MethodTable is unset, then we must be building System.Enum and
// so we don't mark it as a ValueType.

特定CPU的代码生成

但是,为了了解程序执行期间会发生什么,让我们从一个简单的C#程序开始。 下面的代码创建了一个自定义的struct或者说值类型,然后对其“装箱”和“拆箱”:

public struct MyStruct
{
    public int Value; } var myStruct = new MyStruct(); // boxing var boxed = (object)myStruct; // unboxing var unboxed = (MyStruct)boxed; 

以上的C#代码将变成以下IL代码,在其中你可以看到box和unbox.any 这2个IL指令:

L_0000: ldloca.s myStruct
L_0002: initobj TestNamespace.MyStruct
L_0008: ldloc.0 
L_0009: box TestNamespace.MyStruct
L_000e: stloc.1 
L_000f: ldloc.1 
L_0010: unbox.any TestNamespace.MyStruct

Runtime and JIT code

那么,JIT如何处理这些IL操作码呢? 通常情况下,它会连接(wires up)内联(inline)运行时提供的“JIT Helper 方法“——经过优化并且手写的汇编代码。 下面的链接会带你进入CoreCLR源代码中的相关代码行:

  • 特定CPU的优化版本(会在运行时进行wired-up)
    • JIT_BoxFastMP_InlineGetThread (AMD64 - multi-proc or Server GC, implicit TLS)
    • JIT_BoxFastMP (AMD64 - multi-proc or Server GC)
    • JIT_BoxFastUP (AMD64 - single-proc and Workstation GC)
    • JIT_TrialAlloc::GenBox(..) (x86), which is independently wired-up
  • 在常见情况下,JIT内联“helper“函数调用,请参见Compiler :: impImportAndPushBox(..)
  • 最通用的则是用作后备的未优化版本,MethodTable::Box(..)
    • 最终会调用CopyValueClassUnchecked(..)
    • 和Stack Overflow上的问题“Why is struct better with being less than 16 bytes?“的答案相关。

有趣的是,唯一得到这种“JIT Helper 方法“特殊待遇是object,string以及array的分配,这恰好说明了装箱对性能的敏感性。

作为对比,“拆箱“只有一个叫做JIT_Unbox(..)的”helper方法“,在一些不常见的情况下有可能会使用JIT_Unbox_Helper(..)作为后备方法。它的连接可以查看这里( CORINFO_HELP_UNBOX 到 JIT_Unbox )。在常见的情况下,JIT也会将这个helper方法进行内联以节约方法调用的开销,详情查看Compiler::impImportBlockCode(..)。

请注意,“Unbox helper”仅获取“装箱”数据的引用/指针,然后必须将其放入堆栈中。 正如我们在上面看到的,当C#编译器执行拆箱操作时,它使用的是“Unbox_Any”操作码,而不仅是“Unbox”,请参见Unboxing does not create a copy of the value以获取更多信息。(Unbox_Any等价于unbox操作之后再执行ldobj操作,即拷贝操作——译者注)。


创建拆箱存根

除了对一个struct进行“装箱”和“拆箱”外,运行时同样需要在一个类型处于“装箱”的时间内提供帮助。要了解这样说的原因,让我们来拓展MyStruct并且对ToString()方法进行重写,以使得它显示当前Value的值:

public struct MyStruct
{
    public int Value; public override string ToString() { return "Value = " + Value.ToString(); } } 

现在,如果我们查看运行时为装箱版本的MyStruct创建的“方法表”(请记住,值类型没有“方法表”),我们会发现发生了一些奇怪的事情。 请注意,MyStruct::ToString有2个条目,我将其中之一标记为“拆箱存根”

Method table summary for 'MyStruct':
 Number of static fields: 0
 Number of instance fields: 1
 Number of static obj ref fields: 0
 Number of static boxed fields: 0
 Number of declared fields: 1
 Number of declared methods: 1
 Number of declared non-abstract methods: 1
 Vtable (with interface dupes) for 'MyStruct':
   Total duplicate slots = 0

 SD: MT::MethodIterator created for MyStruct (TestNamespace.MyStruct).
   slot  0: MyStruct::ToString  0x000007FE41170C10 (slot =  0) (Unboxing Stub)
   slot  1: System.ValueType::Equals  0x000007FEC1194078 (slot =  1) 
   slot  2: System.ValueType::GetHashCode  0x000007FEC1194080 (slot =  2) 
   slot  3: System.Object::Finalize  0x000007FEC14A30E0 (slot =  3) 
   slot  5: MyStruct::ToString  0x000007FE41170C18 (slot =  4) 
   <-- vtable ends here

(完整版戳)

那么,这个“拆箱存根”是什么?为什么需要?

之所以需要它,是因为如果你在装箱版的MyStruct上调用ToString()方法,会调用在MyStruct内声明的重写方法(这是你想要执行的操作),而不是Object::ToString()的版本。 但是,MyStruct::ToString()希望能够访问struct中的任何字段,例如本例中的Value。 为此,运行时/JIT必须在调用MyStruct::ToString()之前调整this指针,如下图所示:

1. MyStruct:         [0x05 0x00 0x00 0x00]

                     |   Object Header   |   MethodTable  |   MyStruct    |
2. MyStruct (Boxed): [0x40 0x5b 0x6f 0x6f 0xfe 0x7 0x0 0x0 0x5 0x0 0x0 0x0]
                                          ^
                    object 'this' pointer | 

                     |   Object Header   |   MethodTable  |   MyStruct    |
3. MyStruct (Boxed): [0x40 0x5b 0x6f 0x6f 0xfe 0x7 0x0 0x0 0x5 0x0 0x0 0x0]
                                                           ^
                                   adjusted 'this' pointer | 

图的关键点

  1. 原始的struct,在栈上。
  2. struct被装箱到一个存在在堆上的object。
  3. 调整this指针,以使MyStruct::ToString()能够正常工作。

(如果你想了解更多.NET object的内部机制,可以查看这篇有用的文章)

我们可以在下面的代码链接中看到这一点,请注意,存根由一些汇编指令组成(它不如方法调用那么繁重),并且有特定于CPU的版本:

  • MethodDesc::DoPrestub(..) (calls MakeUnboxingStubWorker(..))
  • MakeUnboxingStubWorker(..) (calls EmitUnboxMethodStub(..) to create the stub)
    • i386
    • arm
    • arm64

运行时/JIT必须采取这些技巧来帮助维持这样一种错觉,即struct可以像class一样运行,即使它们在底层区别很大。 请参阅Eric Lipperts对 How do ValueTypes derive from Object (ReferenceType) and still be ValueTypes? 问题的回答, 以对此有更多的了解。

 


 

希望这篇文章能让你对“装箱”的底层实现有所了解。

 


进一步阅读

Useful code comments related to boxing/unboxing stubs

  • MethodTableBuilder::AllocAndInitMethodDescChunk(..)
  • MethodDesc::FindOrCreateAssociatedMethodDesc(..) (in genmeth.cpp)
  • Compiler::impImportBlockCode(..)
  • Note on different ‘Boxing’ modes, added as part of the work on JIT: modify box/unbox/isinst/castclass expansions for fast jitting

GitHub Issues

  • Question: Boxing on stack for function calls
  • Boxing Cache?
  • Improve the default hash code for structs (read the whole discussion)
  • JIT: Fix value type box optimization
  • (Discussion) Lightweight Boxing?

Other similar/related articles

  • .NET Type Internals - From a Microsoft CLR Perspective (section on ‘Boxing and Unboxing’)
  • C# value type boxing under the hood (section on ‘Interface call into the value type instance method’)
  • Value type methods – call, callvirt, constrained and hidden boxing
  • Performance Quiz #12 – The Cost of a Good Hash – Solution (Rico Mariani)
  • To box or not to box (Eric Lippert)
  • Beware of implicit boxing of value types
  • Method calls on value types and boxing

Stack Overflow Questions

  • CLR specification on boxing
  • How CLR works when invoking a method of a struct
  • boxing on structs when calling ToString()
  • Does calling a method on a value type result in boxing in .NET?
  • Why does implicitly calling toString on a value type cause a box instruction
  • Why is struct better with being less than 16 bytes
  • When are Type Objects for Value Types created?
  • If my struct implements IDisposable will it be boxed when used in a using statement?
  • When does a using-statement box its argument, when it’s a struct?

 

 

 
https://docs.microsoft.com/en-au/learn/?WT.mc_id=DT-MVP-5001664
 
欢迎大家关注我的公众号"慕容的游戏编程":chenjd01
聊聊“装箱”在CLR内部的实现_第2张图片
 

你可能感兴趣的:(聊聊“装箱”在CLR内部的实现)