为什么在没有约束的泛型方法上将可空值类型与空进行比较会变慢?

本文关键字:比较 类型 空值 约束 泛型方法 为什么 | 更新日期: 2023-09-27 17:50:24

我遇到了一个非常有趣的情况,在泛型方法中比较可空类型和null比比较值类型或引用类型慢234倍。代码如下:

static bool IsNull<T>(T instance)
{
    return instance == null;
}

执行代码为:

int? a = 0;
string b = "A";
int c = 0;
var watch = Stopwatch.StartNew();
for (int i = 0; i < 1000000; i++)
{
    var r1 = IsNull(a);
}
Console.WriteLine(watch.Elapsed.ToString());
watch.Restart();
for (int i = 0; i < 1000000; i++)
{
    var r2 = IsNull(b);
}
Console.WriteLine(watch.Elapsed.ToString());
watch.Restart();
for (int i = 0; i < 1000000; i++)
{
    var r3 = IsNull(c);
}
watch.Stop();
Console.WriteLine(watch.Elapsed.ToString());
Console.ReadKey();
上面代码的输出是:

00:00:00.1879827

00:00:00.0008779

00:00:00.0008532

可以看到,比较可空的整型和空型比比较整型和字符串要慢234倍。如果我添加第二个带有正确约束的重载,结果将发生巨大变化:

static bool IsNull<T>(T? instance) where T : struct
{
    return instance == null;
}

现在结果是:

00:00:00.0006040

00:00:00.0006017

00:00:00.0006014

为什么?我没有检查字节码,因为我不是很熟练,但即使字节码有一点不同,我也希望JIT能优化它,但它不是(我正在运行优化)。

为什么在没有约束的泛型方法上将可空值类型与空进行比较会变慢?

您应该这样做来调查这个问题。

首先重写程序,使所有操作两次。在两个迭代之间放置一个消息框。在优化的情况下编译程序,并不在调试器中运行程序。这确保了抖动可以生成最优的代码。抖动知道什么时候附加了调试器,如果它认为你在做什么,它可以生成更糟糕的代码,使调试更容易。

当消息框弹出时,附加调试器,然后在汇编代码级别跟踪到三个不同版本的代码,如果实际上甚至有三个不同的版本。我愿意赌一美元,没有任何代码是为第一个生成的,因为抖动知道整个东西可以优化为"返回false",然后返回false可以内联,甚至循环可以被删除。

(将来,在编写性能测试时可能应该考虑这一点。请记住,如果您不使用结果,那么抖动将自由地完全优化所有产生该结果的,只要它没有副作用。)

一旦你能看到汇编代码,你就会明白发生了什么。

我没有亲自调查过这个问题,但是很有可能是这样的:

  • 在int代码路径中,抖动意识到一个装箱的int永远不会为空,并将方法转换为"return false"

  • 在字符串代码路径中,抖动意识到测试字符串是否为空相当于测试指向该字符串的托管指针是否为零,因此它生成一条指令来测试寄存器是否为零。

  • 在int?代码路径,可能抖动是意识到测试一个int?For空可以通过将int装箱来实现。——因为盒装空int是一个空引用,这就减少了之前测试托管指针指向零的问题。但是你要承担装箱的成本。

如果是这种情况,那么抖动可能更复杂,并意识到测试一个int?for null可以通过在int?中返回HasValue bool值的逆来实现。

但就像我说的,这只是一个猜测。如果您感兴趣,可以自己生成代码,看看它在做什么。

如果比较两个重载产生的IL,可以看到其中涉及到装箱:

第一个是:

.method private hidebysig static bool IsNull<T>(!!T instance) cil managed
{
    .maxstack 2
    .locals init (
        [0] bool CS$1$0000)
    L_0000: nop 
    L_0001: ldarg.0 
    L_0002: box !!T
    L_0007: ldnull 
    L_0008: ceq 
    L_000a: stloc.0 
    L_000b: br.s L_000d
    L_000d: ldloc.0 
    L_000e: ret 
}

而第二个看起来像:

.method private hidebysig static bool IsNull<valuetype ([mscorlib]System.ValueType) .ctor T>(valuetype [mscorlib]System.Nullable`1<!!T> instance) cil managed
{
    .maxstack 2
    .locals init (
        [0] bool CS$1$0000)
    L_0000: nop 
    L_0001: ldarga.s instance
    L_0003: call instance bool [mscorlib]System.Nullable`1<!!T>::get_HasValue()
    L_0008: ldc.i4.0 
    L_0009: ceq 
    L_000b: stloc.0 
    L_000c: br.s L_000e
    L_000e: ldloc.0 
    L_000f: ret 
}

在第二种情况下,编译器知道该类型是Nullable,因此它可以对此进行优化。在第一种情况下,它必须处理任何类型,包括引用类型和值类型。所以它必须跳过一些额外的环节。

至于为什么int比int快?

在您不知情的情况下进行装箱和拆箱操作,而装箱操作是出了名的慢。这是因为您在后台将可空引用类型转换为值类型。