字符串与 CLR - 一种特殊关系






4.92/5 (8投票s)
字符串与 CLR - 一种特殊关系
字符串与公共语言运行时 (CLR) 之间存在一种特殊关系,但这与人们常谈论的英美特殊关系有点不同(且政治色彩少得多)。
这种关系意味着字符串可以做你我用 C# 代码无法实现的事情,它们还能从运行时获得帮助,以实现最佳性能,考虑到它们在 .NET 应用程序中的无处不在,这也就不足为奇了。
字符串在内存中的布局
首先,string与 CLR 中的任何其他数据类型(数组除外)不同,其大小不是固定的。通常,.NET GC 在分配对象时就知道对象的大小,因为它是基于对象内字段/属性的大小,并且它们不会改变。然而,在 .NET 中,string对象不包含指向实际string数据的指针,实际数据存储在堆的其他位置。那些原始数据,即构成文本的实际字节,包含在string对象本身中。这意味着string的内存表示如下所示

这样做的好处是提供了出色的内存局部性,并确保当 CLR 想要访问原始string数据时,无需进行额外的指针查找。更多信息,请参阅 Stack Overflow 问题“Where does .NET place the String value?”以及 Jon Skeet 关于字符串的精彩文章。
然而,如果你自己实现一个string类,就像这样
public class MyString
{
    int Length;
    byte [] Data;
}
它在内存中会是这样

在这种情况下,实际的string数据将保存在byte []中,位于内存的其他位置,因此需要一个指针引用和查找才能找到它。
在出色的 BOTR 的mscorlib 部分对此进行了很好的总结
调用原生代码的托管机制还必须支持String 构造函数使用的特殊托管调用约定,其中构造函数分配对象使用的内存(而不是通常的约定,即在 GC 分配内存后调用构造函数)。
在非托管代码中实现
尽管String 类是一个托管的 C# 源文件,但它的很大一部分是在非托管代码中实现的,即 C++ 甚至是汇编语言。例如,String.cs中有 15 个方法没有方法体,被标记为extern并带有[MethodImplAttribute(MethodImplOptions.InternalCall)]属性。这表明它们的实现是由运行时在其他地方提供的。同样,来自BOTR 的 mscorlib 部分(我的重点)
我们有两种从托管代码调用 CLR 的技术。
FCall允许您直接调用 CLR 代码,并在操作对象方面提供了很大的灵活性,尽管如果不正确跟踪对象引用,很容易导致 GC 漏洞。QCall 允许您通过 P/Invoke 调用 CLR,比FCall更难意外误用。FCalls 在托管代码中被标识为设置了 MethodImplOptions.InternalCall 位的 extern 方法。QCall是看起来像常规 P/Invokes 的static extern方法,但调用的是名为“QCall”的库。
具有托管/非托管双重性的类型
String在非托管和托管代码中实现的一个结果是,它们必须在两者中都定义,并且这些定义必须保持同步
某些托管类型必须在托管和原生代码中都有表示。你可能会问一个类型的规范定义是在 CLR 内的托管代码还是原生代码中,但答案无关紧要——关键是它们都必须是相同的。这将允许 CLR 的原生代码以非常快速、易于使用的方式访问托管对象中的字段。还有一种更复杂的方法,本质上是使用 CLR 等同于反射(通过
MethodTables和FieldDescs)来检索字段值,但这可能不如你所希望的性能好,而且也不是很实用。对于常用类型,在原生代码中声明数据结构并尝试使两者保持同步是有意义的。
所以在String.cs中,我们可以看到
//NOTE NOTE NOTE NOTE
//These fields map directly onto the fields in an EE StringObject.  
//See object.h for the layout.
[NonSerialized]private int  m_stringLength;
[NonSerialized]private char m_firstChar;
这与object.h中的以下内容相对应。
private:
    DWORD   m_StringLength;
    WCHAR   m_Characters[0];
快速字符串分配
在典型的 .NET 程序中,动态分配string最常见的方法是使用StringBuilder或String.Format(它在底层使用StringBuilder)。
所以你可能有一些这样的代码
var builder = new StringBuilder();
...
builder.Append(valueX);
...
builder.Append("Some text")
...
var text = builder.ToString();
或
var text = string.Format("{0}, {1}", valueX, valueY);
然后,当调用StringBuilder的ToString()方法时,它内部会调用String类上的FastAllocateString,该方法声明如下
[System.Security.SecurityCritical]  // auto-generated
[MethodImplAttribute(MethodImplOptions.InternalCall)]
internal extern static String FastAllocateString(int length);
此方法被标记为extern并应用了[MethodImplAttribute(MethodImplOptions.InternalCall)]属性,正如我们之前看到的,这意味着它将由 CLR 在非托管代码中实现。结果是,调用堆栈最终会进入一个手写的汇编函数,名为AllocateStringFastMP_InlineGetThread,来自JitHelpers_InlineGetThread.asm。
这还展示了我们之前讨论过的另一件事。汇编代码实际上是根据调用代码传入的所需长度为string分配内存。
LEAF_ENTRY AllocateStringFastMP_InlineGetThread, _TEXT
        ; We were passed the number of characters in ECX
        ; we need to load the method table for string from the global
        mov     r9, [g_pStringClass]
        ; Instead of doing elaborate overflow checks, we just limit the number of elements
        ; to (LARGE_OBJECT_SIZE - 256)/sizeof(WCHAR) or less.
        ; This will avoid avoid all overflow problems, as well as making sure
        ; big string objects are correctly allocated in the big object heap.
        cmp     ecx, (ASM_LARGE_OBJECT_SIZE - 256)/2
        jae     OversizedString
        mov     edx, [r9 + OFFSET__MethodTable__m_BaseSize]
        ; Calculate the final size to allocate.
        ; We need to calculate baseSize + cnt*2, 
        ; then round that up by adding 7 and anding ~7.
        lea     edx, [edx + ecx*2 + 7]
        and     edx, -8
        PATCHABLE_INLINE_GETTHREAD r11, AllocateStringFastMP_InlineGetThread__PatchTLSOffset
        mov     r10, [r11 + OFFSET__Thread__m_alloc_context__alloc_limit]
        mov     rax, [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr]
        add     rdx, rax
        cmp     rdx, r10
        ja      AllocFailed
        mov     [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr], rdx
        mov     [rax], r9
        mov     [rax + OFFSETOF__StringObject__m_StringLength], ecx
ifdef _DEBUG
        call    DEBUG_TrialAllocSetAppDomain_NoScratchArea
endif ; _DEBUG
        ret
    OversizedString:
    AllocFailed:
        jmp     FramedAllocateString
LEAF_END AllocateStringFastMP_InlineGetThread, _TEXT
还有一个优化程度较低的版本,名为AllocateStringFastMP,来自JitHelpers_Slow.asm。不同版本的原因在jinterfacegen.cpp中解释,然后在运行时根据线程局部存储的状态决定使用哪个版本。
// These are the fastest(?) versions of JIT helpers as they have the code to 
// GetThread patched into them that does not make a call.
EXTERN_C Object* JIT_TrialAllocSFastMP_InlineGetThread(CORINFO_CLASS_HANDLE typeHnd_);
EXTERN_C Object* JIT_BoxFastMP_InlineGetThread (CORINFO_CLASS_HANDLE type, void* unboxedData);
EXTERN_C Object* AllocateStringFastMP_InlineGetThread (CLR_I4 cch);
EXTERN_C Object* JIT_NewArr1OBJ_MP_InlineGetThread (CORINFO_CLASS_HANDLE arrayTypeHnd_, INT_PTR size);
EXTERN_C Object* JIT_NewArr1VC_MP_InlineGetThread (CORINFO_CLASS_HANDLE arrayTypeHnd_, INT_PTR size);
// This next set is the fast version that invoke GetThread but is still faster 
// than the VM implementation (i.e. the "slow" versions).
EXTERN_C Object* JIT_TrialAllocSFastMP(CORINFO_CLASS_HANDLE typeHnd_);
EXTERN_C Object* JIT_TrialAllocSFastSP(CORINFO_CLASS_HANDLE typeHnd_);
EXTERN_C Object* JIT_BoxFastMP (CORINFO_CLASS_HANDLE type, void* unboxedData);
EXTERN_C Object* JIT_BoxFastUP (CORINFO_CLASS_HANDLE type, void* unboxedData);
EXTERN_C Object* AllocateStringFastMP (CLR_I4 cch);
EXTERN_C Object* AllocateStringFastUP (CLR_I4 cch);
优化字符串长度
“特殊关系”的最后一个例子体现在运行时如何优化string的Length属性。查找string的长度是一个非常常见的操作,并且由于 .NET 字符串是不可变的,因此应该非常快速,因为该值可以计算一次然后缓存起来。
正如我们从String.cs中的注释中看到的,CLR 通过以 JIT 可以优化的方式实现它来确保这一点是true的
// Gets the length of this string
//
/// This is a EE implemented function so that the JIT can recognise is specially
/// and eliminate checks on character fetches in a loop like:
///        for(int i = 0; i < str.Length; i++) str[i]
/// The actually code generated for this will be one instruction and will be inlined.
//
// Spec#: Add postcondition in a contract assembly.  Potential perf problem.
public extern int Length {
    [System.Security.SecuritySafeCritical]  // auto-generated
    [MethodImplAttribute(MethodImplOptions.InternalCall)]
    get;
}
此代码在stringnative.cpp中实现,它反过来调用GetStringLength
FCIMPL1(INT32, COMString::Length, StringObject* str) {
    FCALL_CONTRACT;
    FC_GC_POLL_NOT_NEEDED();
    if (str == NULL)
        FCThrow(kNullReferenceException);
    FCUnique(0x11);
    return str->GetStringLength();
}
FCIMPLEND
这是一个 JIT 可以内联的简单方法调用
DWORD   GetStringLength()   { LIMITED_METHOD_DAC_CONTRACT; return( m_StringLength );}
为什么要建立特殊关系?
一言以蔽之,为了性能,string在 .NET 程序中被广泛使用,因此需要尽可能地优化、节省空间并对缓存友好。这就是为什么他们不遗余力,包括在汇编中实现方法,并确保 JIT 可以尽可能多地优化代码。
有趣的是,一位 .NET 开发人员最近在GitHub 问题上就此发表了评论,回应了关于为什么没有更多string函数以托管代码实现的问题,他们说:
我们过去研究过这个问题,并移动了所有可以在不显著损失性能的情况下移动的东西。移动更多取决于对所有 coreclr 架构都有相当好的托管优化。只有当所有 coreclr 运行的架构(x86、x64、arm、arm64)都提供了 RyuJIT 或更好的代码生成时,才值得考虑这一点。
在Hacker News或/r/programming上讨论此文章
文章字符串与 CLR - 一种特殊关系最初发表在我的博客性能就是特性!上

