Стоит ли входить и выходить из проверенного блока C#?
Рассмотрим такой цикл:
for (int i = 0; i < end; ++i)
// do something
Если я знаю, что я не переполнюсь, но я хочу проверить переполнение, усечение и т. Д. В части "сделать что-то", мне лучше с checked
блок внутри или снаружи петли?
for (int i = 0; i < end; ++i)
checked {
// do something
}
или же
checked {
for (int i = 0; i < end; ++i)
// do something
}
В целом, стоит ли переключаться между проверенным и непроверенным режимом?
4 ответа
checked
а также unchecked
блоки не появляются на уровне 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
}
}
Как видите, checked
а также unchecked
блоки - это просто концепция исходного кода - при переключении назад и вперед между тем, что было (в источнике), не генерируется IL checked
и unchecked
контекст. Что изменится, так это коды операций, генерируемые для прямых арифметических операций (в данном случае add
а также add.ovf
) которые были текстуально заключены в эти блоки. Спецификация охватывает, какие операции затрагиваются:
На следующие операции влияет контекст проверки переполнения, установленный проверенными и непроверенными операторами и операторами:
- Предопределенные ++ и - унарные операторы (§7.6.9 и §7.7.5), когда операнд имеет целочисленный тип.
- Предопределенный унарный оператор (§7.7.2), когда операнд имеет целочисленный тип.
- Предопределенные операторы +, -, * и / бинарные (§7.8), когда оба операнда имеют целочисленные типы.
- Явные числовые преобразования (§6.2.1) из одного целочисленного типа в другой целочисленный тип или из числа с плавающей запятой или двойного в целочисленный тип.
И, как вы можете видеть, метод вызывается из checked
или же unchecked
Блок сохранит свое тело и не получит никакой информации о том, из какого контекста он был вызван. Это также прописано в спецификации:
Проверенные и непроверенные операторы влияют только на контекст проверки переполнения для тех операций, которые текстуально содержатся в токенах "(" и ")". Операторы не влияют на члены-функции, которые вызываются в результате оценки содержащегося выражения.
В примере
class Test { static int Multiply(int x, int y) { return x * y; } static int F() { return checked(Multiply(1000000, 1000000)); } }
использование флажка в F не влияет на оценку x * y в Multiply, поэтому x * y оценивается в контексте проверки переполнения по умолчанию.
Как уже отмечалось, вышеупомянутый IL был сгенерирован с включенной оптимизацией компилятора C#. Те же выводы можно сделать из ИЛ, который излучается без этих оптимизаций.
Если вы действительно хотите увидеть разницу, посмотрите на сгенерированный 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
Как видите, единственное отличие (за исключением некоторых дополнительных nop
s) что наша операция добавления выдает 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
а не только внутренняя операция, так что вы получаете вдвое больше "накладных расходов". В любом случае, я предполагаю, что "накладные расходы" будут незначительными для большинства вариантов использования.
В дополнение к ответам выше я хочу уточнить, как выполняется проверка. Единственный метод, который я знаю, это проверить OF
а также CF
флаги. CF
флаг устанавливается без знака арифметических инструкций, тогда как OF
устанавливается подписанными арифметическими инструкциями.
Эти флаги можно прочитать с seto\setc
инструкции или (наиболее используемый способ) мы можем просто использовать jo\jc
инструкция перехода, которая будет переходить на нужный адрес, если OF\CF
флаг установлен.
Но есть проблема. jo\jc
это "условный" переход, который является полной болью в *** для конвейера ЦП. Поэтому я подумал, что, возможно, есть еще один способ сделать это, например, установить специальный регистр для прерывания выполнения при обнаружении переполнения, поэтому я решил выяснить, как это делает Microsoft JIT.
Я уверен, что большинство из вас слышали, что Microsoft открыла исходный код.NET, который называется.NET Core. Исходный код.NET Core включает в себя CoreCLR, поэтому я углубился в него. Код обнаружения переполнения генерируется в CodeGen::genCheckOverflow(GenTreePtr tree)
метод (строка 2484). Хорошо видно, что jo
Инструкция используется для проверки переполнения со знаком и jb
(сюрприз!) для неподписанного переполнения. Я давно не программировал на сборке, но похоже jb
а также jc
одни и те же инструкции (они оба проверяют только флаг переноса). Я не знаю, почему разработчики JIT решили использовать jb
вместо jc
потому что, если бы я был производителем процессора, я бы сделал предиктор ветвления, чтобы предположить, jo\jc
прыжки как очень маловероятно.
Подводя итог, нет никаких дополнительных инструкций, вызываемых для переключения между проверенным и непроверенным режимом, но арифметические операции в checked
Блок должен быть заметно медленнее, если проверка выполняется после каждой арифметической инструкции. Тем не менее, я уверен, что современные процессоры справятся с этим хорошо.
Я надеюсь, что это помогает.
"Вцелом, стоит ли переключаться между проверенным и непроверенным режимом?"
Нет, не в вашем примере. Единственные накладные расходы ++i
,
В обоих случаях компилятор C# сгенерирует add.ovf
, sub.ovf
, mul.ovf
или же conv.ovf
,
Но когда цикл находится в проверенном блоке, появится дополнительный add.ovf
за ++i