进入和退出 C# 检查块是否有成本?

Is there a cost to entering and exiting a C# checked block?

考虑这样一个循环:

for (int i = 0; i < end; ++i)
    // do something

如果我知道 i 不会溢出,但我想在 "do something" 部分检查溢出、截断等,我是不是更好用循环内部或外部的 checked 块关闭?

for (int i = 0; i < end; ++i)
    checked {
         // do something
    }

checked {
    for (int i = 0; i < end; ++i)
         // do something
}

更一般地说,在选中和未选中模式之间切换是否有成本?

如果您真的想看到区别,请检查一些生成的 IL。让我们举一个非常简单的例子:

using System;

public class Program 
{
    public static void Main()
    {
        for(int i = 0; i < 10; i++)
        {
            var b = int.MaxValue + i;
        }
    }
}

我们得到:

.maxstack  2
.locals init (int32 V_0,
         int32 V_1,
         bool V_2)
IL_0000:  nop
IL_0001:  ldc.i4.0
IL_0002:  stloc.0
IL_0003:  br.s       IL_0013

IL_0005:  nop
IL_0006:  ldc.i4     0x7fffffff
IL_000b:  ldloc.0
IL_000c:  add
IL_000d:  stloc.1
IL_000e:  nop
IL_000f:  ldloc.0
IL_0010:  ldc.i4.1
IL_0011:  add
IL_0012:  stloc.0
IL_0013:  ldloc.0
IL_0014:  ldc.i4.s   10
IL_0016:  clt
IL_0018:  stloc.2
IL_0019:  ldloc.2
IL_001a:  brtrue.s   IL_0005

IL_001c:  ret

现在,让我们确认一下:

public class Program 
{
    public static void Main()
    {
        for(int i = 0; i < 10; i++)
        {
            checked
            {
                var b = int.MaxValue + i;
            }
        }
    }
}

现在我们得到以下 IL:

.maxstack  2
.locals init (int32 V_0,
         int32 V_1,
         bool V_2)
IL_0000:  nop
IL_0001:  ldc.i4.0
IL_0002:  stloc.0
IL_0003:  br.s       IL_0015

IL_0005:  nop
IL_0006:  nop
IL_0007:  ldc.i4     0x7fffffff
IL_000c:  ldloc.0
IL_000d:  add.ovf
IL_000e:  stloc.1
IL_000f:  nop
IL_0010:  nop
IL_0011:  ldloc.0
IL_0012:  ldc.i4.1
IL_0013:  add
IL_0014:  stloc.0
IL_0015:  ldloc.0
IL_0016:  ldc.i4.s   10
IL_0018:  clt
IL_001a:  stloc.2
IL_001b:  ldloc.2
IL_001c:  brtrue.s   IL_0005

IL_001e:  ret

如您所见,唯一的区别(除了一些额外的 nops 之外)是我们的添加操作发出 add.ovf 而不是简单的 add。您将产生的唯一开销是这些操作的不同。

现在,如果我们移动 checked 块以包含整个 for 循环会发生什么:

public class Program 
{
    public static void Main()
    {
        checked
        {
            for(int i = 0; i < 10; i++)
            {
                var b = int.MaxValue + i;
            }
        }
    }
}

我们得到了新的 IL:

.maxstack  2
.locals init (int32 V_0,
         int32 V_1,
         bool V_2)
IL_0000:  nop
IL_0001:  nop
IL_0002:  ldc.i4.0
IL_0003:  stloc.0
IL_0004:  br.s       IL_0014

IL_0006:  nop
IL_0007:  ldc.i4     0x7fffffff
IL_000c:  ldloc.0
IL_000d:  add.ovf
IL_000e:  stloc.1
IL_000f:  nop
IL_0010:  ldloc.0
IL_0011:  ldc.i4.1
IL_0012:  add.ovf
IL_0013:  stloc.0
IL_0014:  ldloc.0
IL_0015:  ldc.i4.s   10
IL_0017:  clt
IL_0019:  stloc.2
IL_001a:  ldloc.2
IL_001b:  brtrue.s   IL_0006

IL_001d:  nop
IL_001e:  ret

您可以看到两个 add 操作都已转换为 add.ovf 而不仅仅是内部操作,因此您得到了 "overhead" 的两倍。无论如何,我猜 "overhead" 对于大多数用例来说都可以忽略不计。

checkedunchecked 块不会出现在 IL 级别。它们仅在 C# 源代码中用于告诉编译器在覆盖构建配置的默认首选项(通过编译器标志设置)时是否选择检查或非检查 IL 指令。

当然,通常会有性能差异,因为为算术运算发出了不同的操作码(但不是由于进入或退出块)。通常预计经过检查的算法会比相应的未经检查的算法有一些开销。

事实上,考虑这个 C# 程序:

class Program
{
    static void Main(string[] args)
    {
        var a = 1;
        var b = 2;
        int u1, c1, u2, c2;

        Console.Write("unchecked add ");
        unchecked
        {
            u1 = a + b;
        }
        Console.WriteLine(u1);

        Console.Write("checked add ");
        checked
        {
            c1 = a + b;
        }
        Console.WriteLine(c1);

        Console.Write("unchecked call ");
        unchecked
        {
            u2 = Add(a, b);
        }
        Console.WriteLine(u2);

        Console.Write("checked call ");
        checked
        {
            c2 = Add(a, b);
        }
        Console.WriteLine(c2);
    }

    static int Add(int a, int b)
    {
        return a + b;
    }
}

这是生成的 IL,默认情况下启用了优化和未经检查的算法:

.class private auto ansi beforefieldinit Checked.Program
    extends [mscorlib]System.Object
{    
    .method private hidebysig static int32 Add (
            int32 a,
            int32 b
        ) cil managed 
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: add
        IL_0003: ret
    }

    .method private hidebysig static void Main (
            string[] args
        ) cil managed 
    {
        .entrypoint
        .locals init (
            [0] int32 b
        )

        IL_0000: ldc.i4.1
        IL_0001: ldc.i4.2
        IL_0002: stloc.0

        IL_0003: ldstr "unchecked add "
        IL_0008: call void [mscorlib]System.Console::Write(string)
        IL_000d: dup
        IL_000e: ldloc.0
        IL_000f: add
        IL_0010: call void [mscorlib]System.Console::WriteLine(int32)

        IL_0015: ldstr "checked add "
        IL_001a: call void [mscorlib]System.Console::Write(string)
        IL_001f: dup
        IL_0020: ldloc.0
        IL_0021: add.ovf
        IL_0022: call void [mscorlib]System.Console::WriteLine(int32)

        IL_0027: ldstr "unchecked call "
        IL_002c: call void [mscorlib]System.Console::Write(string)
        IL_0031: dup
        IL_0032: ldloc.0
        IL_0033: call int32 Checked.Program::Add(int32,  int32)
        IL_0038: call void [mscorlib]System.Console::WriteLine(int32)

        IL_003d: ldstr "checked call "
        IL_0042: call void [mscorlib]System.Console::Write(string)
        IL_0047: ldloc.0
        IL_0048: call int32 Checked.Program::Add(int32,  int32)
        IL_004d: call void [mscorlib]System.Console::WriteLine(int32)

        IL_0052: ret
    }
}

如您所见,checkedunchecked 块仅仅是一个源代码概念——在(源代码中的)checked 和一个 unchecked 上下文。发生变化的是为直接算术运算(在本例中为 add and add.ovf)发出的操作码,这些操作码以文本形式包含在这些块中。该规范涵盖哪些操作受到影响:

The following operations are affected by the overflow checking context established by the checked and unchecked operators and statements:

  • The predefined ++ and -- unary operators (§7.6.9 and §7.7.5), when the operand is of an integral type.
  • The predefined - unary operator (§7.7.2), when the operand is of an integral type.
  • The predefined +, -, *, and / binary operators (§7.8), when both operands are of integral types.
  • Explicit numeric conversions (§6.2.1) from one integral type to another integral type, or from float or double to an integral type.

如您所见,从 checkedunchecked 块调用的方法将保留其主体,并且不会收到有关调用它的上下文的任何信息。这在规范中也有说明:

The checked and unchecked operators only affect the overflow checking context for those operations that are textually contained within the “(” and “)” tokens. The operators have no effect on function members that are invoked as a result of evaluating the contained expression.

In the example

class Test
{
  static int Multiply(int x, int y) {
      return x * y;
  }
  static int F() {
      return checked(Multiply(1000000, 1000000));
  }
}

the use of checked in F does not affect the evaluation of x * y in Multiply, so x * y is evaluated in the default overflow checking context.

如前所述,上述 IL 是在启用 C# 编译器优化的情况下生成的。从没有这些优化的 IL 中可以得出相同的结论。

"更一般地说,在选中和未选中模式之间切换是否有成本?"

不,在你的例子中不是。唯一的开销是 ++i

在这两种情况下,C# 编译器都会生成 add.ovfsub.ovfmul.ovfconv.ovf

但是当循环在检查块内时,++i

会有一个额外的add.ovf

除了上面的答案之外,我还想澄清检查是如何执行的。我知道的唯一方法是检查 OFCF 标志。 CF 标志由无符号算术指令设置,而 OF 由有符号算术指令设置。

这些标志可以用 seto\setc 指令读取,或者(最常用的方式)我们可以只使用 jo\jc 跳转指令,如果 OF\CF 标志已设置。

但是,有一个问题。 jo\jc 是一个 "conditional" 跳跃,这对 CPU 管道来说是一个彻底的痛苦。所以我想可能还有另一种方法可以做到这一点,比如设置一个特殊的寄存器来在检测到溢出时中断执行,所以我决定找出微软的 JIT 是如何做到这一点的。

我相信你们中的大多数人都听说过 Microsoft 已经开源了名为 .NET Core 的 .NET 子集。 .NET Core 的源代码包括 CoreCLR,所以我深入研究了它。溢出检测代码在CodeGen::genCheckOverflow(GenTreePtr tree)方法中生成(第2484行)。可以清楚地看到,jo 指令用于有符号溢出检查,jb(惊喜!)用于无符号溢出。我已经很久没有用汇编编程了,但看起来 jbjc 是相同的指令(它们都只检查进位标志)。我不知道为什么 JIT 开发人员决定使用 jb 而不是 jc 因为如果我是 CPU-maker,我会让分支预测器假设 jo\jc跳跃的可能性很小。

综上所述,没有调用额外的指令来在已检查和未检查模式之间切换,但是 checked 块中的算术运算必须明显变慢,只要在每个算术指令之后执行检查即可。但是,我很确定现代 CPU 可以很好地处理这个问题。

希望对您有所帮助。