.NET9引用数组协变怪异Bug

文摘   2024-11-12 12:05   美国  

点击上方蓝字 江湖评谈设为关注/星标




前言

引用类型的数组是协变的,这是完全没有问题的。协变的实质是允许你将更具体的类型(子类)代替一个较为抽象的类型(父类)。但正因为协变的存在,引用数组某些情况下会存在问题。本篇看下。本文代码是最新的.NET9 RC2作为蓝本参考。

问题

我们看一个正常引用数组的操作:

using System;
class Animal{ public string Name { get; set; }}class Giraffe : Animal{ public double NeckLength { get; set; }}
class Program{ static void Main() {        Animal[] animals = new Giraffe[2]; animals[0] = new Giraffe { Name = "Tommy", NeckLength = 10 };        animals[1] = new Giraffe { Name = "Tommy", NeckLength = 10 }; }}

再来看一个不正常的操作:

using System;
class Animal{ public string Name { get; set; }}
class Turtle : Animal{ public int ShellSize { get; set; }}
class Giraffe : Animal{ public double NeckLength { get; set; }}
class Program{ static void Main() { Animal[] animals = new Giraffe[2]; animals[0] = new Turtle { Name = "Tommy", ShellSize = 10 }; // 这里会引发运行时异常 }}

把同级派生的Turtle 赋值给了Giraffe,这样会导致一个什么问题呢?因为协变的存在,把Turtle实例给Giraffe实例在前端编译(Roslyn/VS)层面是合法的,它不会报错。当我们运行这段代码,编译流程会把这个错误推到CLR层面。每次将对象放入数组的时候,CLR都会对其进行检查。以确保该类型有效,如果无效,则抛出异常。

本来在前端编译层面可以检查的东西,结果以比较不合理的方式在CLR层面进行了实现,但它的问题远不止于此。

如果你把无数个这些对象放到数组中,这可能会很昂贵。注意这里为何昂贵?我们注意看正常的代码:

 static void Main() {     Animal[] animals = new Giraffe[2];     animals[0] = new Giraffe { Name = "Tommy", NeckLength = 10 };     animals[1] = new Giraffe { Name = "Tommy", NeckLength = 10 }; }

animals[0]和 animals[1]两次赋值,两次都要在CLR层面进行类型检查,如果赋值一万次甚至更多,CLR的开销未免过于巨大。如果在前端编译层面进行了类型分解和MSIL构建,是不是可以减轻CLR的压力,大大的提升性能呢?

如果是不正常的示例代码呢?它同样需要进行较大的开销之后,报出一个异常表示你这代码错误,这种实现非常很奇怪,会有这样的疑问,为什么不跳过开销,直接报异常呢?或者直接在前端处理这个异常是否更好点呢?

这种很明显的不算BUG的BUG,其实是有更好的手段去处理的。但目前似乎没有做到。

CLR层面

.NET9里面CLR托管层面的核心DLL非System.Private.CoreLib.dll莫属了,它是有R2R+AOT+JIT共同Compile的结果。在它里面的函数StelemRef承担了检查引用数组类型的重任。我们看下它的原型:

 [DebuggerHidden]private static void StelemRef(object?[] array, nint index, object? obj){            // This will throw NullReferenceException if array is null.            if ((nuint)index >= (uint)array.Length)                ThrowIndexOutOfRangeException();
Debug.Assert(index >= 0); ref object? element = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index); void* elementType = RuntimeHelpers.GetMethodTable(array)->ElementType;
if (obj == null) goto assigningNull;
if (elementType != RuntimeHelpers.GetMethodTable(obj)) goto notExactMatch;
doWrite: WriteBarrier(ref element, obj); return;
assigningNull: element = null; return;
notExactMatch: if (array.GetType() == typeof(object[])) goto doWrite;
StelemRef_Helper(ref element, elementType, obj);}

它有三个参数,第一个参数array代表的是被赋值的引用数组比如animals ,第二个参数也即是当前animals数组的索引,第三个参数是赋值的实例比如Turtle/Giraffe。StelemRef函数里面会对这些传入的参数进行一一检查,是否符合规范,不符合则报异常。

底层

关于以上说法,我们Debug下.NET9 CLR。我们来到Main入口:

ConsoleApp1.dll!Program.Main():00007FF7E1B67BB0 55                   push        rbp  //省略部分,便于观看00007FF7E1B67BEA 74 05                je          Program.Main()+041h (07FF7E1B67BF1h)  00007FF7E1B67BEC E8 DF 4D C7 5F       call        00007FF8417DC9D0  00007FF7E1B67BF1 90                   nop  00007FF7E1B67BF2 48 B9 40 92 E6 E1 F7 7F 00 00 mov         rcx,7FF7E1E69240h  00007FF7E1B67BFC BA 02 00 00 00       mov         edx,2  00007FF7E1B67C01 E8 1A 6A B5 5F       call        CORINFO_HELP_NEWARR_1_OBJ (07FF8416BE620h)  00007FF7E1B67C06 48 89 45 68          mov         qword ptr [rbp+68h],rax  00007FF7E1B67C0A 48 8B 4D 68          mov         rcx,qword ptr [rbp+68h]  00007FF7E1B67C0E 48 89 4D 70          mov         qword ptr [rbp+70h],rcx  00007FF7E1B67C12 48 8B 4D 70          mov         rcx,qword ptr [rbp+70h]  00007FF7E1B67C16 48 89 4D 78          mov         qword ptr [rbp+78h],rcx  00007FF7E1B67C1A 48 B9 D0 91 E6 E1 F7 7F 00 00 mov         rcx,7FF7E1E691D0h  00007FF7E1B67C24 E8 77 68 B5 5F       call        CORINFO_HELP_NEWSFAST (07FF8416BE4A0h)  00007FF7E1B67C29 48 89 45 60          mov         qword ptr [rbp+60h],rax  00007FF7E1B67C2D 48 8B 4D 60          mov         rcx,qword ptr [rbp+60h]  00007FF7E1B67C31 E8 8A 90 30 00       call        Giraffe..ctor() (07FF7E1E70CC0h)  00007FF7E1B67C36 48 8B 4D 60          mov         rcx,qword ptr [rbp+60h]  00007FF7E1B67C3A 48 BA E8 B1 00 80 4C 02 00 00 mov         rdx,24C8000B1E8h  00007FF7E1B67C44 39 09                cmp         dword ptr [rcx],ecx  00007FF7E1B67C46 E8 15 90 30 00       call        Animal.set_Name(System.String) (07FF7E1E70C60h)  00007FF7E1B67C4B 90                   nop  00007FF7E1B67C4C 48 8B 4D 78          mov         rcx,qword ptr [rbp+78h]  00007FF7E1B67C50 48 89 4D 58          mov         qword ptr [rbp+58h],rcx  00007FF7E1B67C54 33 C9                xor         ecx,ecx  00007FF7E1B67C56 89 4D 54             mov         dword ptr [rbp+54h],ecx  00007FF7E1B67C59 C5 FB 10 0D FF 00 00 00 vmovsd      xmm1,qword ptr [Program.Main()+01B0h (07FF7E1B67D60h)]  00007FF7E1B67C61 48 8B 4D 60          mov         rcx,qword ptr [rbp+60h]  00007FF7E1B67C65 39 09                cmp         dword ptr [rcx],ecx  00007FF7E1B67C67 E8 3C 90 30 00       call        Giraffe.set_NeckLength(Double) (07FF7E1E70CA8h)  00007FF7E1B67C6C 90                   nop  00007FF7E1B67C6D 8B 55 54             mov         edx,dword ptr [rbp+54h]  00007FF7E1B67C70 48 63 D2             movsxd      rdx,edx  00007FF7E1B67C73 48 8B 4D 58          mov         rcx,qword ptr [rbp+58h]  00007FF7E1B67C77 4C 8B 45 60          mov         r8,qword ptr [rbp+60h]  00007FF7E1B67C7B E8 A0 83 FF FF       call        System.Runtime.CompilerServices.CastHelpers.StelemRef(System.Array, IntPtr, System.Object) (07FF7E1B60020h) //后面省略,便于观看 

最后一行,调用了StelemRef进行了数组参数检查

00007FF7E1B67C7B E8 A0 83 FF FF call System.Runtime.CompilerServices.CastHelpers.StelemRef(System.Array, IntPtr, System.Object) (07FF7E1B60020h) 

如果是错误的示例代码,它会报以下错误:

而这个错误实际上是经过StelemRef函数的,代价开销有点大。

结尾

以上代码简析了下.NET框架实现不合理的地方,这种类似的有很多。当然像这种类型的实现,也不止于.NET,比如Rust/C++里面也有非常多不合理的设计。

欧美计算机技术各种更新,迭代。实际上就是不合理的设计导致的,后期需要花费大量的精力去弥补和填充。

中国对于这方面似乎更好些,比如汉字,一千多年了,汉字几乎都没有变过,对于现在的大模型,网络流行词汇,汉字组合形成几个词语依旧流畅。然英文,则造出了许多新的词汇,以适应时代。

以上本篇内容,如有疏漏错误,可不吝赐教。

往期精彩回顾

.NET9异常(CLR)原理(顶阶技术

长按/扫一扫关注作者


江湖评谈
记录,分享,自由。
 最新文章