数组和CLR-非常特殊的关系



数组和CLR-非常特殊的关系

原文地址:https://mattwarren.org/2017/05/08/Arrays-and-the-CLR-a-Very-Special-Relationship/
译文作者:杰哥很忙

前段时间,我写了关于字符串和CLR之间的"特殊关系",事实证明,Array 和 CLR 具有更深的关系。

顺便说一下,如果你喜欢阅读CLR本质类的文章,你可能会对这些文章很有趣:

公共语言运行时(CLR)的基础

数组是 CLR 的基本部分,它们包含在 ECMA 规范中,以明确运行时必须实现数组:

20200225233847.png

另外,还有一些专门处理数组的IL(中间语言)指令:

  • newarr <etype>
    创建一个元素类型为etype的数组
  • ldelem.ref
    将位于指定数组索引处的包含对象引用的元素作为 O 类型(对象引用)加载到计算堆栈的顶部,O类型与在 CIL 堆栈上推送的数组的元素类型相同。
  • stelem <typeTok>
    堆栈上的值保存到数组索引位置(stelem.istelem.i1stelem.i2stelem.r4 等类似)
  • ldlen
    将数组长度推到堆栈(本机无符号int类型)

有专用IL指令非常有意义,因为数组是很多其他数据类型的构建基块,你会你希望它能在 C# 等现代高级语言中可用、定义良好且高效。如果没有数组,就不可能有列表、字典、队列、堆栈、树等数据结构,它们都是构建在数组之上的,这些数组以类型安全的方式提供对连续内存片段的低级访问。

内存和类型安全

这种内存类型安全很重要,因为没有它,.NET就不能被描述为"托管运行时",并且当你以更低级的语言编写代码时,你必须自己处理所遇到的类型安全的问题。

更具体地说,CLR 在使用数组时提供以下保护(来自BORT的"CLR 简介"页面中有关内存和类型安全部分):

GC 是保证内存安全的必要条件,但不充分。GC 并不会禁止程序越界访问数组,或是越界访问一个对象的成员(如果你通过基地址和偏移来计算成员地址的话)。不过,如果我们有办法解决这些问题,我们就能够实现内存安全的程序
公共中间语言(CIL)确实 提供了一些操作符,它们可以用来在任意内存上读取和写入数据(因此破坏了内存安全性),不过他还提供了下面这些内存安全的操作符,CLR 也强烈建议在大多数的情况下使用它们:

  1. 字段访问操作符(LDFLD、STFLD、LDFLDA),它们能够通过名字来读取、写入一个字段,以及获取一个字段的地址。
  2. 数组访问操作符(LDELEM、STELEM、LDELEMA),它们能够通过数据索引来读取、设置数组元素,以及获取数组元素的地址。所有的数组都有一个标签,写明了数组的长度。在每次访问数组元素时,都会自动进行边界检查。

译者补充:BORT是Book Of The Runtime的缩写。
对于完整的IL指令可以点击这里查看
《托管数组结构》一文中有介绍数组的布局,在类型句柄之后是数组长度。

此外,从BOTR页的可验证代码-强制内存和类型安全"一节中提到:

事实上,需要运行时检查的数量实际上是非常小的。它们包括以下操作:

  1. 将指向基类型的指针转换为为指向派生类型的指针(可以静态检查相反的操作)
  2. 数组边界检查(正如我们看到的内存安全性一样)
  3. 指针数组中的一个元素赋值为一个新的(指针)值。需要这种检查的原因是,CLR 的数组支持自由转换规则(后文会详细介绍)

但是,并不能免费获得类型安全保护,需要有一些性能开销:

需要注意的是,需要执行这些检查会对运行时进行要求。特别是:

  1. GC堆中的所有内存必须标记类型(以便可以实现强制转换运算符)。在运行时类型信息必须可以被获取到,而且必须包含足够的信息来确定类型转换是否合法(例如,运行时需要知道继承层次结构)。实际上,GC 堆上每个对象中的第一个字段指向表示其类型的运行时数据结构。
  2. 所有数组还必须有大小字段(用于边界检查)。
  3. 数组必须包含有关其元素类型的完整类型信息

译者补充:在《托管数组结构》一文中介绍了托管数组的布局结构。第一个字段指向其类型的运行时数据结构指的是类型句柄,指向的是该对象的方法表。

实现细节

事实证明,数组大部分的内部实现最好描述为“魔术”,Stack Overflow 的来自 Marc Gravell的回答很好的总结了它。

数组基本上是使用了“魔术”。因为它们早于泛型,但必须允许动态类型创建(即使在.NET 1.0中也需要),因此它们通过使用一些技巧、黑客等手段实现。

是的,数组在泛型存在之前就被参数化(即通用化)。这意味着你可以在编写 List<int>List<string> 之前创建数组(如 int[]string[]),这仅在 .NET 2.0 中成为可能。

特殊帮助器类

所有这些魔术或技巧可能造成两件事:

  • CLR 违反了所有常见的类型安全规则
  • 特殊的数组帮助器类叫做 SZArrayHelper

但首先,为什么需要这些技巧?来自《.NET Arrays, IList, Generic Algorithms, and what about STL?》

当我们设计泛型集合类时,困扰我的一件事就是如何编写一个通用算法,能处理数组和集合。当然,为了驱动泛型编程,我们必须尽可能使数组和泛型集合无缝衔接。应该有一个简单的解决方案来解决这个问题,这意味着你必编写相同的代码两次,一次使用IList<T>,一次使用T[] 我突然意识到的解决方案是数组需要实现我们的通用 IList。我们在 .Net Framework 1.1 中使数组实现了非泛型 IList,由于缺少 IList 强类型和所有数组(System.Array)的基类,所以实现相当简单。我们需要的是以强类型方式为 IList<T> 执行相同的操作。

但它只针对常见情况(即"单维"数组):

不过,这里存在一些限制,我们不想支持多维数组,因为 IList<T> 只提供单维访问。 此外,具有非零下限的数组相当奇怪,并且可能不能很好的与 IList<T> 相匹配,因为大多数人可能会从 IList 的0到 Count 进行遍历。因此,我们不是使 System.Array 实现 IList<T>,使 T[] 实现 IList<T> 在这里,T[] 表示以 0 为下限的单维数组(通常在内部称为 SZArray,但我认为 Brad 希望在某个时间点公开推广术语"矢量"),并且元素类型为 T。因此,Int32[]实现IList<Int32>string[]实现了IList<String>

此外,数组源代码中的此注释进一步阐明了原因:

//----------------------------------------------------------------------------------
// Calls to (IList<T>)(array).Meth are actually implemented by SZArrayHelper.Meth<T>
// This workaround exists for two reasons:
//
//    - For working set reasons, we don't want insert these methods in the array 
//      hierachy in the normal way.
//    - For platform and devtime reasons, we still want to use the C# compiler to 
//      generate the method bodies.
//
// (Though it's questionable whether any devtime was saved.)
//
// ....
//---------------------------------------------------------------------------------- 

因此,这样做是为了方便高效,因为他们不希望 System.Array 的每个实例都携带 IEnumera<T>IList<T> 实现的所有代码。

此映射通过调用 GetActualImplementationForArrayGenericIListOrIReadOnlyListMethod(..),。它负责从 SZArrayHelper 连接相应的方法类,即 IList<T>.Count -> SZArrayHelper.Count<T>,或者如果该方法是 IEnumerator<T> 接口的一部分,则使用 SZGenericArrayenumerator

但是,这有可能导致安全漏洞,因为它打破了正常的 C# 类型系统保证,特别是关于this指针。为了说明这个问题,下面是 Count 属性的源代码,请注意对 JitHelpers.UnsafeCast<T[]> 的调用。

internal int get_Count<T>()
{
    //! Warning: "this" is an array, not an SZArrayHelper. See comments above
    //! or you may introduce a security hole!
    T[] _this = JitHelpers.UnsafeCast<T[]>(this);
    return _this.Length;
}

它必须重新映射 this,以便能够调用正确的对象的 length

译者补充:正如上面的注释所描述,get_Count<T>SZArrayHelper中的实例方法,而this并不是指SZArrayHelper,而是指数组。

以防这些注释描述的不够,在这个类的顶部有一个措辞非常强烈的注释,进一步阐明了风险!!

一般来说,所有这些“魔术”都是隐藏的,但偶尔它会向外暴露出来。例如,如果你运行以下代码,SZArrayHelper 将显示在StackTraceTargetSite中:

try { 
    int[] someInts = { 1, 2, 3, 4 };
    IList<int> collection = someInts;
    // Throws NotSupportedException 'Collection is read-only'
    collection.Clear(); 		
} catch (NotSupportedException nsEx) {				
    Console.WriteLine("{0} - {1}", nsEx.TargetSite.DeclaringType, nsEx.TargetSite);
    Console.WriteLine(nsEx.StackTrace);
}

System.SZArrayHelper - Void Clear[T]()
   在 System.SZArrayHelper.Clear[T]()

移除边界检查

运行时还以更传统的方式为数组提供了支持,其中第一种与性能有关。数组边界检查提供了很好的内存安全,但它们有开销成本,因此,若有可能,JIT 会删除任何它所知道的冗余检查。

它通过计算for循环访问的值范围并将这些值与数组的实际长度进行比较来实现该功能。如果它确定从未尝试访问数组边界以外的项,就会删除运行时检查。

更多详细信息,以下链接将带你到处理此情况的 JIT 源代码:

如果你感兴趣,看看这里,我将数组检索边界检查的“删除”和“不删除”的场景放到了一起。

分配数组

运行时所提供帮助的另一个任务是使用手写的程序集代码分配数组,以便尽可能优化方法,请参阅:

运行时以不同的方式对待数组

最后,由于数组与 CLR 紧密联系在一起,因此在很多地方,它们都被作为特殊情况进行处理。例如,在 CoreCLR 源码中搜索IsArray()会返回超过 60 条记录,包括:

所以,公平地说,数组和CLR有一个非常特殊的关系

进一步阅读

和往常一样,这里有一些更多的链接提供给你阅读

数组源码引用

参考文档

  1. StructLayout特性
  2. Compiling C# Code Into Memory and Executing It with Roslyn
  3. .NET CLR 运行原理
  4. Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects
  5. How do arrays in C# partially implement IList?
  6. .NET Arrays, IList, Generic Algorithms, and what about STL?
  7. 什么是 CLR ?

20191127212134.png
微信扫一扫二维码关注订阅号杰哥技术分享
出处:https://www.cnblogs.com/Jack-Blog/p/12360447.html
作者:杰哥很忙
本文使用「CC BY 4.0」创作共享协议。欢迎转载,请在明显位置给出出处及链接。

posted @ 2020-02-25 15:37  杰哥很忙  阅读(927)  评论(0编辑  收藏  举报